Matrix builds, and runs-on as a real label set

cac71de · Gabriel Morell · 2026-09-11 22:14

18 files +1319 -73
Message
{commit_body(@commit)}

Files changed

modified README.md
+37 −0
@@ -81,6 +81,7 @@ reinvent them.
81 81 - **CI**
82 82 - Speaks the **Forgejo Actions runner protocol** (Twirp) — point any modern `forgejo-runner` at it; protocol path autodetected (`ping.v1`, `runner.v1`, with/without `_apis/`)
83 83 - Repo-scoped + org-scoped runner registration with one-shot tokens minted from the settings UI
84 + - **Matrix builds**, with `runs-on` as a real label set — `strategy.matrix` expands into one job per combination, each claimed independently, so `runs-on: [self-hosted, "${{ matrix.arch }}"]` sends the arm64 leg to an arm64 machine and the amd64 leg to an amd64 one. `include`/`exclude` follow GitHub's semantics, `fail-fast` defaults on, and `needs:` names the group rather than a leg
84 85 - **Five deployment styles** offered in the registration UI: Native binary, Docker, Podman, Podman Quadlet, Kubernetes (Deployment + dind sidecar) — copy-pasteable `register` + `daemon` snippets per style
85 86 - Re-run / cancel buttons on the workflow show page
86 87 - Per-task HS256 JWT minted on `FetchTask`, gates artifact + cache endpoints
@@ -309,6 +310,42 @@ register → daemon --config runner-config.yml` flow per the
309 310 The runner is **external**. A native runner is on the roadmap but the
310 311 external one works today and is well-tested by the Forgejo community.
311 312
313 +### Matrix builds across architectures
314 +
315 +Label each runner with what it *is*, in the registration UI's labels
316 +field — `self-hosted,arm64,linux` on the Pi, `self-hosted,amd64,linux`
317 +on the x86 box — then let the matrix pick:
318 +
319 +```yaml
320 +jobs:
321 + build:
322 + strategy:
323 + matrix:
324 + arch: [amd64, arm64]
325 + runs-on: [self-hosted, "${{ matrix.arch }}"]
326 + steps:
327 + - uses: actions/checkout@v4
328 + - run: make build ARCH=${{ matrix.arch }}
329 +```
330 +
331 +That is two jobs, claimed by two runners, running at the same time. A
332 +job is claimable when the runner carries **every** label its `runs-on`
333 +lists — GitHub's rule — so extra labels on a runner never disqualify
334 +it and a missing one always does. `runs-on: self-hosted` matches any
335 +self-registered runner, whatever else it carries.
336 +
337 +`runs-on` is the one expression the forge resolves itself, because it
338 +decides *which* runner and there is nobody else to ask yet. Everything
339 +else — `${{ matrix.arch }}` inside a step — is evaluated by the runner
340 +against a matrix context pinned to that leg's single value.
341 +
342 +`needs: build` waits for **every** leg, and a downstream job sees one
343 +`needs.build` however many machines produced it. `fail-fast` is on by
344 +default: the first leg to fail cancels its siblings that haven't
345 +started, while one already running is left to finish rather than
346 +killed. `max-parallel` is not implemented — every leg is claimable as
347 +soon as it exists.
348 +
312 349 ### OCI registry
313 350
314 351 When you want pushes from your workflows (`docker/build-push-action`,
modified ROADMAP.md
+2 −1
@@ -43,7 +43,8 @@ queue in slice 42). Remaining federation items are discretionary
43 43 | RFC 9421 (HTTP Messages) signature | When peers upgrade from draft-cavage-12 | small (mostly mechanical) |
44 44 | Reputation aging for new federated hosts | Strictest policy until N days clean; auto-promote | small-medium |
45 45 | `act_runner` (older Forgejo runner) explicit support | Protocol is identical; verify against a specific binary version | small |
46 −| Per-job concurrency / matrix builds | YAML parser supports it; runner orchestration doesn't yet | medium |
46 +| `strategy.max-parallel` | Matrix builds shipped in slice 72; every leg is claimable the moment it exists, so a wide matrix can saturate a small runner pool. Needs a claim-time count of in-flight legs per `(run, job_id)` | small-medium |
47 +| Per-job concurrency (`concurrency:` groups) | Cancel-in-progress and named concurrency groups across runs; unrelated to the matrix work despite sharing a roadmap line previously | medium |
47 48 | Code search (Meilisearch or similar) | Postgres FTS covers issues/PRs; code search is its own beast | large |
48 49
49 50 ## Explicitly declined
modified lib/git_gud/workflows.ex
+187 −67
@@ -17,6 +17,7 @@ defmodule GitGud.Workflows do
17 17 alias GitGud.Repo
18 18 alias GitGud.Repositories
19 19 alias GitGud.Repositories.Repository
20 + alias GitGud.Workflows.Matrix
20 21 alias GitGud.Workflows.Runner
21 22 alias GitGud.Workflows.WorkflowJob
22 23 alias GitGud.Workflows.WorkflowRun
@@ -141,6 +142,17 @@ defmodule GitGud.Workflows do
141 142 end)
142 143 end
143 144
145 + # `build (arm64, 28)` — the convention Actions uses, so two legs are
146 + # distinguishable at a glance in the UI and in a commit status.
147 + defp leg_name(job, combination) do
148 + base = job.name || job.id
149 +
150 + case Matrix.suffix(combination) do
151 + nil -> base
152 + suffix -> "#{base} (#{suffix})"
153 + end
154 + end
155 +
144 156 defp next_run_number(rid) do
145 157 (from(r in WorkflowRun,
146 158 where: r.repository_id == ^rid,
@@ -149,20 +161,38 @@ defmodule GitGud.Workflows do
149 161 |> Repo.one() || 0) + 1
150 162 end
151 163
152 − defp create_job(%WorkflowRun{id: rid}, %{} = job) do
164 + # One row per `strategy.matrix` combination — that is the whole of
165 + # matrix support on this side. Separate rows are what let two legs be
166 + # claimed by two runners, which is the point when the axis is the
167 + # machine's architecture.
168 + defp create_job(%WorkflowRun{} = run, %{} = job) do
169 + for combination <- Matrix.expand(job[:strategy] || job.raw["strategy"]) do
170 + create_job_leg(run, job, combination)
171 + end
172 + end
173 +
174 + defp create_job_leg(%WorkflowRun{id: rid}, %{} = job, combination) do
153 175 # Jobs with unresolved `needs:` start in `blocked` so claim_next_job
154 176 # can never pick them up before their prerequisites complete.
155 177 # Promotion to `queued` happens in `resolve_dependents/1`.
156 178 initial_status = if job.needs == [], do: "queued", else: "blocked"
157 179
180 + # `runs-on` is resolved here rather than by the runner, because it
181 + # decides *which* runner — there is nobody else to ask yet.
182 + labels =
183 + job.runs_on
184 + |> Matrix.substitute(combination)
185 + |> Matrix.labels()
186 +
158 187 job_row =
159 188 %WorkflowJob{workflow_run_id: rid}
160 189 |> WorkflowJob.create_changeset(%{
161 190 job_id: job.id,
162 − name: job.name,
163 − runs_on: %{"value" => job.runs_on},
191 + name: leg_name(job, combination),
192 + runs_on: %{"labels" => labels},
164 193 needs: job.needs,
165 − definition: job.raw
194 + matrix: if(combination == %{}, do: nil, else: combination),
195 + definition: Matrix.pin_definition(job.raw, combination)
166 196 })
167 197 |> Ecto.Changeset.change(status: initial_status)
168 198 |> Repo.insert!()
@@ -412,15 +442,17 @@ defmodule GitGud.Workflows do
412 442 def claim_job_by_handle(runner, _), do: claim_next_job(runner)
413 443
414 444 defp do_claim_specific_job(%Runner{id: rid} = runner, job_id) do
415 − labels = effective_labels(runner)
416 − match_labels = if "self-hosted" in labels, do: labels, else: ["self-hosted" | labels]
445 + match_labels = runner |> effective_labels() |> match_labels()
417 446
418 447 Repo.transaction(fn ->
419 448 job =
420 449 scoped_job_query(runner)
421 450 |> where([j], j.id == ^job_id)
422 451 |> where([j], j.status == "queued")
423 − |> where([j], fragment("(?->>'value') = ANY(?)", j.runs_on, ^match_labels))
452 + |> where(
453 + [j],
454 + fragment("(? -> 'labels') <@ to_jsonb(?::text[])", j.runs_on, ^match_labels)
455 + )
424 456 |> lock("FOR UPDATE SKIP LOCKED")
425 457 |> Repo.one()
426 458
@@ -447,20 +479,16 @@ defmodule GitGud.Workflows do
447 479 end
448 480
449 481 defp do_claim_next_job(%Runner{id: rid} = runner, labels) do
450 − # GitHub-Actions semantics: `runs-on: self-hosted` matches any
451 − # self-registered runner regardless of OS label. So if the runner
452 − # was minted via `Runners.create_offline/2` we implicitly add
453 − # `self-hosted` to the match set for the query. Operators who want
454 − # strict label matching can still register a runner without the
455 − # `self-hosted` label — but everything created via this codebase
456 − # is by definition self-hosted, so the implicit wildcard is safe.
457 − match_labels = if "self-hosted" in labels, do: labels, else: ["self-hosted" | labels]
482 + match_labels = match_labels(labels)
458 483
459 484 Repo.transaction(fn ->
460 485 job =
461 486 scoped_job_query(runner)
462 487 |> where([j], j.status == "queued")
463 − |> where([j], fragment("(?->>'value') = ANY(?)", j.runs_on, ^match_labels))
488 + |> where(
489 + [j],
490 + fragment("(? -> 'labels') <@ to_jsonb(?::text[])", j.runs_on, ^match_labels)
491 + )
464 492 |> order_by([j], asc: j.id)
465 493 |> limit(1)
466 494 |> lock("FOR UPDATE SKIP LOCKED")
@@ -489,6 +517,26 @@ defmodule GitGud.Workflows do
489 517 end
490 518 end
491 519
520 + # The label set a runner can satisfy, as JSON for the containment
521 + # test. A job is claimable when *every* label its `runs-on` lists is
522 + # one this runner carries — GitHub's rule, and the reason
523 + # `runs-on: [self-hosted, arm64]` reaches an arm64 machine and only
524 + # an arm64 machine.
525 + #
526 + # `self-hosted` is added implicitly: `runs-on: self-hosted` is meant
527 + # to match any self-registered runner regardless of its other
528 + # labels, and everything this codebase mints is by definition
529 + # self-hosted.
530 + #
531 + # Built into jsonb on the SQL side from a text array rather than
532 + # interpolated as a JSON string: a string parameter bound to a
533 + # `::jsonb` placeholder is encoded as a JSON *string*, so containment
534 + # compares an array against `"[\"a\",\"b\"]"` and quietly matches
535 + # nothing. `to_jsonb(?::text[])` has one unambiguous reading.
536 + defp match_labels(labels) do
537 + if "self-hosted" in labels, do: labels, else: ["self-hosted" | labels]
538 + end
539 +
492 540 # A runner only claims jobs in its scope:
493 541 # * repo-scoped runner → jobs in that repo
494 542 # * org-scoped runner → jobs in any repo owned by that org
@@ -510,7 +558,7 @@ defmodule GitGud.Workflows do
510 558 end
511 559
512 560 defp scoped_job_query(_global) do
513 − from j in WorkflowJob
561 + from(j in WorkflowJob)
514 562 end
515 563
516 564 @doc """
@@ -525,7 +573,8 @@ defmodule GitGud.Workflows do
525 573
526 574 `rows` is the list of `%LogRow{}` protobufs from the request.
527 575 """
528 − def append_log(%WorkflowStep{id: step_id}, start_seq, rows) when is_integer(start_seq) and is_list(rows) do
576 + def append_log(%WorkflowStep{id: step_id}, start_seq, rows)
577 + when is_integer(start_seq) and is_list(rows) do
529 578 now = DateTime.utc_now(:second)
530 579
531 580 entries =
@@ -847,6 +896,7 @@ defmodule GitGud.Workflows do
847 896
848 897 if WorkflowJob.terminal?(updated.status) do
849 898 finalize_steps(updated)
899 + maybe_fail_fast(updated)
850 900 resolve_dependents(updated)
851 901 end
852 902
@@ -889,6 +939,31 @@ defmodule GitGud.Workflows do
889 939 end)
890 940 end
891 941
942 + # `strategy.fail-fast` — GitHub's default is to abandon the rest of a
943 + # matrix as soon as one leg fails, because the usual reason to run
944 + # sixteen legs is to find out whether they all pass, and once one
945 + # hasn't you have your answer. Only siblings that haven't started are
946 + # cancelled; a leg already running is left to finish, since killing a
947 + # mid-flight job loses its logs for no gain.
948 + defp maybe_fail_fast(%WorkflowJob{status: status} = job)
949 + when status in ~w(failure cancelled) do
950 + if job.matrix && Matrix.fail_fast?(strategy_of(job)) do
951 + from(j in WorkflowJob,
952 + where:
953 + j.workflow_run_id == ^job.workflow_run_id and j.job_id == ^job.job_id and
954 + j.id != ^job.id and j.status in ["queued", "blocked"]
955 + )
956 + |> Repo.all()
957 + |> Enum.each(&update_job_status(&1, "cancelled"))
958 + end
959 + end
960 +
961 + defp maybe_fail_fast(_job), do: :ok
962 +
963 + # `Matrix.pin_definition/2` carries `fail-fast` through onto the
964 + # pinned strategy precisely so it can be read back here.
965 + defp strategy_of(%WorkflowJob{definition: definition}), do: definition["strategy"]
966 +
892 967 @doc """
893 968 After a job reaches a terminal state, walk every sibling job in the
894 969 same run whose `needs:` array contains this job's `job_id` and
@@ -907,31 +982,48 @@ defmodule GitGud.Workflows do
907 982 from(j in WorkflowJob, where: j.workflow_run_id == ^rid)
908 983 |> Repo.all()
909 984
910 − by_jid = Map.new(siblings, fn j -> {j.job_id, j} end)
985 + # Grouped, not indexed: a matrix expands into several rows sharing
986 + # one `job_id`, and `needs: build` means "all of build", not
987 + # "whichever leg this lookup happened to find".
988 + by_jid = Enum.group_by(siblings, & &1.job_id)
911 989
912 − dependents = Enum.filter(siblings, fn j -> completed_jid in j.needs and j.status == "blocked" end)
990 + dependents =
991 + Enum.filter(siblings, fn j -> completed_jid in j.needs and j.status == "blocked" end)
913 992
914 993 Enum.each(dependents, fn dep ->
915 994 needs_states = Enum.map(dep.needs, fn jid -> Map.get(by_jid, jid) end)
916 995
917 996 cond do
918 − Enum.any?(needs_states, &is_nil/1) ->
997 + Enum.any?(needs_states, &(&1 in [nil, []])) ->
919 998 # A referenced need doesn't exist as a sibling — workflow
920 999 # author error. Cancel rather than block forever.
921 1000 {:ok, _} = update_job_status(dep, "cancelled")
922 1001
923 − Enum.any?(needs_states, fn n -> n.status in ~w(failure cancelled) end) ->
1002 + Enum.any?(needs_states, fn legs ->
1003 + Enum.any?(legs, &(&1.status in ~w(failure cancelled)))
1004 + end) ->
924 1005 {:ok, _} = update_job_status(dep, "cancelled")
925 1006
926 − Enum.all?(needs_states, fn n -> WorkflowJob.satisfies_dependency?(n.status) end) ->
1007 + Enum.all?(needs_states, fn legs ->
1008 + Enum.all?(legs, &WorkflowJob.satisfies_dependency?(&1.status))
1009 + end) ->
927 1010 # Snapshot upstream results/outputs now, while they're terminal.
928 1011 # build_needs/1 reads this instead of re-reading upstream status
929 1012 # at fetch time (which raced and could yield RESULT_UNSPECIFIED,
930 1013 # making act skip the job on `if: success()`).
1014 + #
1015 + # A matrix's legs collapse to one entry, because `needs.build`
1016 + # is one name downstream however many machines produced it:
1017 + # outputs merge, and the status is the worst of them.
931 1018 snapshot =
932 1019 Map.new(dep.needs, fn jid ->
933 − n = Map.get(by_jid, jid)
934 − {jid, %{"status" => n.status, "outputs" => n.outputs || %{}}}
1020 + legs = Map.get(by_jid, jid)
1021 +
1022 + {jid,
1023 + %{
1024 + "status" => aggregate_status(legs),
1025 + "outputs" => Enum.reduce(legs, %{}, &Map.merge(&2, &1.outputs || %{}))
1026 + }}
935 1027 end)
936 1028
937 1029 {:ok, _} =
@@ -950,6 +1042,17 @@ defmodule GitGud.Workflows do
950 1042 :ok
951 1043 end
952 1044
1045 + # `success` only when every leg succeeded; `skipped` when they all
1046 + # skipped. Anything mixed reports `success`, because reaching here
1047 + # means no leg failed and a downstream `if: success()` should run.
1048 + defp aggregate_status(legs) do
1049 + cond do
1050 + Enum.all?(legs, &(&1.status == "skipped")) -> "skipped"
1051 + Enum.all?(legs, &(&1.status == "success")) -> "success"
1052 + true -> "success"
1053 + end
1054 + end
1055 +
953 1056 defp maybe_complete_run(%WorkflowJob{workflow_run_id: rid}) do
954 1057 statuses =
955 1058 from(j in WorkflowJob, where: j.workflow_run_id == ^rid, select: j.status)
@@ -1059,7 +1162,8 @@ defmodule GitGud.Workflows do
1059 1162
1060 1163 target_url =
1061 1164 GitGudWeb.Endpoint.url() <>
1062 − "/r/repo/" <> Integer.to_string(run.repository_id) <>
1165 + "/r/repo/" <>
1166 + Integer.to_string(run.repository_id) <>
1063 1167 "/actions/runs/" <> Integer.to_string(run.id)
1064 1168
1065 1169 description =
@@ -1083,8 +1187,7 @@ defmodule GitGud.Workflows do
1083 1187 %GitGud.Workflows.CommitStatus{}
1084 1188 |> GitGud.Workflows.CommitStatus.changeset(attrs)
1085 1189 |> Repo.insert(
1086 − on_conflict:
1087 − {:replace, [:state, :description, :target_url, :updated_at]},
1190 + on_conflict: {:replace, [:state, :description, :target_url, :updated_at]},
1088 1191 conflict_target: [:repository_id, :sha, :context]
1089 1192 )
1090 1193
@@ -1144,50 +1247,67 @@ defmodule GitGud.Workflows do
1144 1247 def rerun_job(%WorkflowRun{} = run, job_id) when is_binary(job_id) do
1145 1248 siblings = Repo.all(from j in WorkflowJob, where: j.workflow_run_id == ^run.id)
1146 1249
1147 − case Enum.find(siblings, &(&1.job_id == job_id)) do
1148 − nil ->
1250 + # Every leg, not the first one found: a matrix's legs share a
1251 + # `job_id`, and "re-run build" means the build, not whichever
1252 + # architecture happened to sort first.
1253 + case Enum.filter(siblings, &(&1.job_id == job_id)) do
1254 + [] ->
1149 1255 {:error, :not_found}
1150 1256
1151 − job ->
1152 − by_jid = Map.new(siblings, &{&1.job_id, &1})
1257 + legs ->
1258 + by_jid = Enum.group_by(siblings, & &1.job_id)
1153 1259
1154 1260 needs_ok? =
1155 − job.needs == [] or
1156 − Enum.all?(job.needs, fn jid ->
1157 − case Map.get(by_jid, jid) do
1158 − %WorkflowJob{status: s} -> WorkflowJob.satisfies_dependency?(s)
1159 − _ -> false
1160 − end
1161 − end)
1261 + fn job ->
1262 + job.needs == [] or
1263 + Enum.all?(job.needs, fn jid ->
1264 + case Map.get(by_jid, jid) do
1265 + [_ | _] = upstream ->
1266 + Enum.all?(upstream, &WorkflowJob.satisfies_dependency?(&1.status))
1267 +
1268 + _ ->
1269 + false
1270 + end
1271 + end)
1272 + end
1162 1273
1163 1274 Repo.transaction(fn ->
1164 − # clean slate for this job's logs + steps. Logs are keyed by
1165 − # workflow_step_id, so scope by this job's step ids.
1166 − step_ids_q = from(s in WorkflowStep, where: s.workflow_job_id == ^job.id, select: s.id)
1167 − Repo.delete_all(from l in "workflow_step_logs", where: l.workflow_step_id in subquery(step_ids_q))
1168 −
1169 − Repo.update_all(
1170 − from(s in WorkflowStep, where: s.workflow_job_id == ^job.id),
1171 − set: [
1172 − status: "pending",
1173 − conclusion: nil,
1174 − started_at: nil,
1175 − completed_at: nil,
1176 − log_index: nil,
1177 − log_length: nil
1178 − ]
1179 − )
1180 −
1181 − {:ok, updated} =
1182 − job
1183 − |> Ecto.Changeset.change(
1184 − status: if(needs_ok?, do: "queued", else: "blocked"),
1185 − conclusion: nil,
1186 − started_at: nil,
1187 − completed_at: nil,
1188 − runner_id: nil
1189 − )
1190 − |> Repo.update()
1275 + updated =
1276 + Enum.map(legs, fn job ->
1277 + # clean slate for this job's logs + steps. Logs are keyed by
1278 + # workflow_step_id, so scope by this job's step ids.
1279 + step_ids_q =
1280 + from(s in WorkflowStep, where: s.workflow_job_id == ^job.id, select: s.id)
1281 +
1282 + Repo.delete_all(
1283 + from l in "workflow_step_logs", where: l.workflow_step_id in subquery(step_ids_q)
1284 + )
1285 +
1286 + Repo.update_all(
1287 + from(s in WorkflowStep, where: s.workflow_job_id == ^job.id),
1288 + set: [
1289 + status: "pending",
1290 + conclusion: nil,
1291 + started_at: nil,
1292 + completed_at: nil,
1293 + log_index: nil,
1294 + log_length: nil
1295 + ]
1296 + )
1297 +
1298 + {:ok, row} =
1299 + job
1300 + |> Ecto.Changeset.change(
1301 + status: if(needs_ok?.(job), do: "queued", else: "blocked"),
1302 + conclusion: nil,
1303 + started_at: nil,
1304 + completed_at: nil,
1305 + runner_id: nil
1306 + )
1307 + |> Repo.update()
1308 +
1309 + row
1310 + end)
1191 1311
1192 1312 # Flip the run non-terminal so it isn't "complete" and the
1193 1313 # runner polls it. maybe_complete_run re-settles it on finish.
@@ -1197,7 +1317,7 @@ defmodule GitGud.Workflows do
1197 1317 end)
1198 1318 |> case do
1199 1319 {:ok, updated} ->
1200 − broadcast({:job_status, run.id, updated})
1320 + Enum.each(updated, &broadcast({:job_status, run.id, &1}))
1201 1321 broadcast_ci_change(run.id)
1202 1322 {:ok, Repo.get!(WorkflowRun, run.id)}
1203 1323
added lib/git_gud/workflows/matrix.ex
+234 −0
@@ -0,0 +1,234 @@
1 +defmodule GitGud.Workflows.Matrix do
2 + @moduledoc """
3 + Expands a job's `strategy.matrix` into the combinations it stands for.
4 +
5 + strategy:
6 + matrix:
7 + arch: [amd64, arm64]
8 + otp: ["27", "28"]
9 +
10 + …is four jobs. Each becomes its own `WorkflowJob` row, with its own
11 + status, logs and runner — which is the point: two of those want an
12 + amd64 machine and two want arm64, and only separate rows can be
13 + claimed by separate runners.
14 +
15 + ## `include` and `exclude`
16 +
17 + Both are ordinary GitHub Actions semantics and both are fiddly, so
18 + they are spelled out here rather than left to the reader:
19 +
20 + * `exclude` drops every combination that matches *all* of the
21 + key/values it names. It is applied before `include`, so an
22 + `include` can add a combination back.
23 +
24 + * `include` is not "append these combinations". Each entry is
25 + merged into every existing combination whose overlapping keys
26 + already agree with it — so `{otp: "28", coverage: true}` adds
27 + `coverage` to the two OTP-28 jobs rather than creating a fifth.
28 + An entry that matches nothing is appended as a combination of its
29 + own, which is how you add a single odd platform to a matrix.
30 +
31 + An entry that *would* overwrite a value the base matrix set is where
32 + GitHub's own rules get genuinely ambiguous; here, overlapping keys
33 + must agree for a merge to happen, so such an entry appends instead.
34 +
35 + ## Ordering
36 +
37 + Combinations come back in the order GitHub produces them: the *last*
38 + matrix key varies fastest. That is only cosmetic, but it decides the
39 + order jobs appear in the UI, and matching the tool people already
40 + know beats an arbitrary choice.
41 + """
42 +
43 + @reserved ~w(include exclude)
44 +
45 + @typedoc "One combination: matrix key to the value this leg was expanded for."
46 + @type combination :: %{String.t() => term()}
47 +
48 + @doc """
49 + Every combination `strategy` expands to.
50 +
51 + Returns `[%{}]` — a single empty combination — when there is no
52 + matrix, so callers can treat "no matrix" as "one leg with nothing in
53 + it" rather than branching.
54 + """
55 + @spec expand(map() | nil) :: [combination()]
56 + def expand(strategy)
57 +
58 + def expand(%{"matrix" => %{} = matrix}) do
59 + axes =
60 + matrix
61 + |> Map.drop(@reserved)
62 + |> Enum.map(fn {key, values} -> {key, List.wrap(values)} end)
63 + # Map iteration order is undefined; sort so a given workflow always
64 + # expands the same way rather than shuffling between deploys.
65 + |> Enum.sort_by(&elem(&1, 0))
66 +
67 + axes
68 + |> product()
69 + |> exclude(List.wrap(matrix["exclude"]))
70 + |> include(List.wrap(matrix["include"]))
71 + |> case do
72 + [] -> [%{}]
73 + combinations -> combinations
74 + end
75 + end
76 +
77 + def expand(_strategy), do: [%{}]
78 +
79 + @doc """
80 + Whether `strategy` asks for a matrix at all.
81 +
82 + A `strategy:` with only `fail-fast:` on it is not a matrix, and its
83 + job should not be renamed as though it were one leg of something.
84 + """
85 + @spec matrix?(map() | nil) :: boolean()
86 + def matrix?(%{"matrix" => %{} = matrix}), do: matrix != %{}
87 + def matrix?(_), do: false
88 +
89 + @doc """
90 + Whether a failing leg should cancel its siblings. GitHub's default is
91 + `true`, and so is ours.
92 + """
93 + @spec fail_fast?(map() | nil) :: boolean()
94 + def fail_fast?(%{"fail-fast" => false}), do: false
95 + def fail_fast?(%{"fail-fast" => "false"}), do: false
96 + def fail_fast?(_), do: true
97 +
98 + @doc """
99 + The display suffix GitHub puts on a matrix leg — `build (arm64, 28)`.
100 +
101 + Values in the order their keys sort, which is the order `expand/1`
102 + varied them, so reading two names side by side tells you which axis
103 + differs.
104 + """
105 + @spec suffix(combination()) :: String.t() | nil
106 + def suffix(combination) when map_size(combination) == 0, do: nil
107 +
108 + def suffix(combination) do
109 + combination
110 + |> Enum.sort_by(&elem(&1, 0))
111 + |> Enum.map_join(", ", fn {_key, value} -> to_string(value) end)
112 + end
113 +
114 + @doc """
115 + Substitute `${{ matrix.key }}` in a `runs-on` value.
116 +
117 + Only `runs-on` needs this. Everything else the runner evaluates for
118 + itself — see `GitGud.Workflows.RunnerPayload`, which hands `act` a
119 + one-value matrix so the whole `matrix` context resolves natively.
120 + But routing is *our* decision and happens before any runner sees the
121 + job, so this one expression has to be resolved server-side.
122 +
123 + An unknown key resolves to the empty string, as it does in Actions.
124 + """
125 + @spec substitute(term(), combination()) :: term()
126 + def substitute(value, combination) when is_list(value),
127 + do: Enum.map(value, &substitute(&1, combination))
128 +
129 + def substitute(value, combination) when is_binary(value) do
130 + Regex.replace(~r/\$\{\{\s*matrix\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/, value, fn _, key ->
131 + combination |> Map.get(key, "") |> to_string()
132 + end)
133 + end
134 +
135 + def substitute(value, _combination), do: value
136 +
137 + @doc """
138 + Normalise a `runs-on` into the list of labels a runner must carry.
139 +
140 + `runs-on: ubuntu-latest` and `runs-on: [ubuntu-latest]` mean the same
141 + thing and are stored the same way, so claiming has one shape to match
142 + rather than two.
143 + """
144 + @spec labels(term()) :: [String.t()]
145 + def labels(runs_on) do
146 + runs_on
147 + |> List.wrap()
148 + |> Enum.map(&to_string/1)
149 + |> Enum.reject(&(&1 == ""))
150 + |> Enum.uniq()
151 + end
152 +
153 + @doc """
154 + Rewrite a job definition so it describes exactly this one leg.
155 +
156 + The matrix is replaced by the single combination rather than
157 + removed: `act` then expands it to exactly one job and populates the
158 + `matrix` context from it, so `${{ matrix.arch }}` inside a step keeps
159 + working without us implementing an expression evaluator. Dropping
160 + `strategy` entirely would leave those expressions resolving to
161 + nothing; leaving it whole would have the runner fan out *again*,
162 + inside the single job we just handed it.
163 + """
164 + @spec pin_definition(map(), combination()) :: map()
165 + def pin_definition(definition, combination) when map_size(combination) == 0,
166 + do: Map.drop(definition, ["strategy"])
167 +
168 + def pin_definition(definition, combination) do
169 + pinned = Map.new(combination, fn {key, value} -> {key, [value]} end)
170 + strategy = Map.get(definition, "strategy") || %{}
171 +
172 + # `fail-fast` rides along even though it means nothing to a
173 + # one-value matrix: the forge reads it back off the stored
174 + # definition to decide whether a failing leg cancels its siblings,
175 + # and the pinned strategy is the only copy that survives.
176 + kept =
177 + case Map.fetch(strategy, "fail-fast") do
178 + {:ok, value} -> %{"matrix" => pinned, "fail-fast" => value}
179 + :error -> %{"matrix" => pinned}
180 + end
181 +
182 + Map.put(definition, "strategy", kept)
183 + end
184 +
185 + # ── expansion internals ─────────────────────────────────────────────
186 +
187 + # Cartesian product with the last axis varying fastest, matching the
188 + # order Actions lists a matrix's jobs in.
189 + defp product([]), do: []
190 +
191 + defp product(axes) do
192 + Enum.reduce(axes, [%{}], fn {key, values}, acc ->
193 + for combination <- acc, value <- values, do: Map.put(combination, key, value)
194 + end)
195 + end
196 +
197 + defp exclude(combinations, []), do: combinations
198 +
199 + defp exclude(combinations, exclusions) do
200 + Enum.reject(combinations, fn combination ->
201 + Enum.any?(exclusions, &subset_of?(&1, combination))
202 + end)
203 + end
204 +
205 + defp include(combinations, []), do: combinations
206 +
207 + defp include(combinations, inclusions) do
208 + Enum.reduce(inclusions, combinations, fn entry, acc ->
209 + entry = Map.new(entry, fn {k, v} -> {to_string(k), v} end)
210 +
211 + merged = Enum.map(acc, &merge_if_compatible(&1, entry))
212 +
213 + # Nothing took it — it describes a combination of its own.
214 + if merged == acc, do: acc ++ [entry], else: merged
215 + end)
216 + end
217 +
218 + # Compatible means every key they share already agrees. An entry that
219 + # would change an existing value describes a different combination,
220 + # not an addition to this one.
221 + defp merge_if_compatible(combination, entry) do
222 + overlapping = Map.take(entry, Map.keys(combination))
223 +
224 + if subset_of?(overlapping, combination) and entry != overlapping,
225 + do: Map.merge(combination, entry),
226 + else: combination
227 + end
228 +
229 + defp subset_of?(subset, combination) do
230 + Enum.all?(subset, fn {key, value} ->
231 + Map.has_key?(combination, key) and to_string(Map.get(combination, key)) == to_string(value)
232 + end)
233 + end
234 +end
modified lib/git_gud/workflows/workflow_job.ex
+6 −1
@@ -36,6 +36,11 @@ defmodule GitGud.Workflows.WorkflowJob do
36 36 # `build_needs/1`; avoids a racy re-query of upstream status at fetch.
37 37 # Shape: %{"<need_job_id>" => %{"status" => "...", "outputs" => %{}}}.
38 38 field :needs_snapshot, :map, default: %{}
39 + # The one `strategy.matrix` combination this row was expanded for,
40 + # e.g. %{"arch" => "arm64"}. `nil` for a job with no matrix. Legs of
41 + # one matrix share a `job_id` — `needs:` names the group, and this
42 + # is what tells them apart.
43 + field :matrix, :map
39 44
40 45 belongs_to :workflow_run, WorkflowRun
41 46 belongs_to :runner, Runner
@@ -46,7 +51,7 @@ defmodule GitGud.Workflows.WorkflowJob do
46 51
47 52 def create_changeset(job, attrs) do
48 53 job
49 − |> cast(attrs, [:job_id, :name, :runs_on, :needs, :definition])
54 + |> cast(attrs, [:job_id, :name, :runs_on, :needs, :definition, :matrix])
50 55 |> validate_required([:job_id, :definition])
51 56 end
52 57
modified lib/git_gud/workflows/yaml.ex
+9 −4
@@ -8,10 +8,13 @@ defmodule GitGud.Workflows.Yaml do
8 8 * Top-level `name`, `on`, `jobs` keys
9 9 * `on:` as a string ("push"), list (["push", "pull_request"]) or map
10 10 with per-trigger config; we keep the raw map for filtering later
11 − * `jobs.<id>.{name, runs-on, needs, steps, env, if, container}` are
12 − preserved as the job's definition; richer features (services,
13 − strategy/matrix, reusable workflows, etc.) flow through as-is and
14 − will be honored by the runner that consumes them.
11 + * `jobs.<id>.{name, runs-on, needs, strategy, steps, env, if,
12 + container}` are preserved as the job's definition. `strategy` is
13 + lifted out because the forge acts on it — see
14 + `GitGud.Workflows.Matrix`, which expands a matrix into one job
15 + row per combination so the legs can be claimed by different
16 + runners. Richer features (services, reusable workflows, …) flow
17 + through as-is for the runner that consumes them.
15 18 """
16 19
17 20 @type workflow :: %{
@@ -25,6 +28,7 @@ defmodule GitGud.Workflows.Yaml do
25 28 id: String.t(),
26 29 name: String.t() | nil,
27 30 runs_on: any(),
31 + strategy: map() | nil,
28 32 needs: [String.t()],
29 33 steps: [map()],
30 34 raw: map()
@@ -79,6 +83,7 @@ defmodule GitGud.Workflows.Yaml do
79 83 id: id,
80 84 name: Map.get(definition, "name"),
81 85 runs_on: Map.get(definition, "runs-on", "ubuntu-latest"),
86 + strategy: Map.get(definition, "strategy"),
82 87 needs: Map.get(definition, "needs", []) |> List.wrap(),
83 88 steps: Map.get(definition, "steps", []),
84 89 raw: definition
added priv/repo/migrations/20260911030000_add_workflow_job_matrix.exs
+92 −0
@@ -0,0 +1,92 @@
1 +defmodule GitGud.Repo.Migrations.AddWorkflowJobMatrix do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + Matrix builds, and the `runs-on` change they need underneath.
6 +
7 + `runs_on` was stored as `%{"value" => <whatever the YAML said>}` and
8 + matched with `runs_on->>'value' = ANY(runner labels)`. That only ever
9 + worked for the scalar form: `runs-on: [self-hosted, arm64]` stored a
10 + JSON array, `->>` rendered it as the literal text `["self-hosted",
11 + "arm64"]`, and it matched no runner at all — silently, as a job that
12 + simply never got claimed.
13 +
14 + The list form is exactly how you select an architecture, so it has to
15 + work. `runs_on` becomes `%{"labels" => [...]}` — always a list, even
16 + for a scalar — and claiming asks whether the job's labels are
17 + *contained by* the runner's, which is GitHub's rule: a runner must
18 + carry every label the job asks for.
19 +
20 + `matrix` holds the one combination a job row was expanded for
21 + (`%{"arch" => "arm64"}`). Jobs from one matrix keep their YAML
22 + `job_id`, so `needs:` still names the group rather than a synthetic
23 + per-leg id; the combination is what tells the legs apart.
24 + """
25 +
26 + def up do
27 + alter table(:workflow_jobs) do
28 + add :matrix, :map
29 + end
30 +
31 + execute """
32 + UPDATE workflow_jobs
33 + SET runs_on = jsonb_build_object('labels',
34 + CASE jsonb_typeof(runs_on -> 'value')
35 + WHEN 'array' THEN runs_on -> 'value'
36 + WHEN 'string' THEN jsonb_build_array(runs_on -> 'value')
37 + ELSE '[]'::jsonb
38 + END)
39 + WHERE runs_on ? 'value'
40 + """
41 +
42 + # Containment (`<@`) is a GIN operator; without this every claim
43 + # sequentially scans the queued jobs, and claiming happens on every
44 + # runner poll.
45 + create index(:workflow_jobs, ["(runs_on -> 'labels') jsonb_path_ops"],
46 + using: :gin,
47 + name: :workflow_jobs_runs_on_labels_index
48 + )
49 +
50 + # `(run, job_id)` was unique, which is exactly what a matrix breaks:
51 + # its legs deliberately share a `job_id` so `needs:` can name the
52 + # group. The combination is what separates them, and `COALESCE` is
53 + # load-bearing — NULLs compare as distinct in a unique index, so
54 + # without it two matrix-less jobs with the same id would slip
55 + # through the very constraint being replaced.
56 + drop unique_index(:workflow_jobs, [:workflow_run_id, :job_id])
57 +
58 + create unique_index(
59 + :workflow_jobs,
60 + [:workflow_run_id, :job_id, "(COALESCE(matrix, '{}'::jsonb))"],
61 + name: :workflow_jobs_run_job_matrix_index
62 + )
63 + end
64 +
65 + def down do
66 + # `IF EXISTS` / `IF NOT EXISTS` throughout: a teardown that only
67 + # works from the exact state the matching `up` left behind is a
68 + # teardown you can't run when you actually need it.
69 + execute "DROP INDEX IF EXISTS workflow_jobs_run_job_matrix_index"
70 +
71 + execute """
72 + CREATE UNIQUE INDEX IF NOT EXISTS workflow_jobs_workflow_run_id_job_id_index
73 + ON workflow_jobs (workflow_run_id, job_id)
74 + """
75 +
76 + execute "DROP INDEX IF EXISTS workflow_jobs_runs_on_labels_index"
77 +
78 + # Back to the scalar shape. A multi-label job can't be represented,
79 + # so it keeps its first label — which is what the old code would
80 + # have matched on had it been written as a scalar.
81 + execute """
82 + UPDATE workflow_jobs
83 + SET runs_on = jsonb_build_object('value',
84 + COALESCE(runs_on -> 'labels' -> 0, '"ubuntu-latest"'::jsonb))
85 + WHERE runs_on ? 'labels'
86 + """
87 +
88 + alter table(:workflow_jobs) do
89 + remove_if_exists :matrix, :map
90 + end
91 + end
92 +end
added test/git_gud/workflows/matrix_dispatch_test.exs
+509 −0
@@ -0,0 +1,509 @@
1 +defmodule GitGud.Workflows.MatrixDispatchTest do
2 + @moduledoc """
3 + Matrix builds end to end through the orchestration: a workflow with a
4 + matrix becomes several jobs, each routed to a runner that carries the
5 + labels that leg asked for.
6 +
7 + The motivating case is two architectures — an arm64 machine and an
8 + amd64 one, both feeding one workflow — which is also the case that
9 + exercises every moving part: expansion, `runs-on` substitution, and
10 + label-set claiming.
11 + """
12 + use GitGud.DataCase, async: false
13 +
14 + alias GitGud.Workflows
15 + alias GitGud.Workflows.Runners
16 + alias GitGud.Workflows.WorkflowJob
17 + alias GitGud.Workflows.WorkflowRun
18 + alias GitGud.Workflows.Yaml
19 +
20 + import GitGud.ForgeFixtures
21 +
22 + setup do
23 + {_user, repo} = repository_fixture()
24 + {:ok, repo: repo}
25 + end
26 +
27 + # `dispatch/2` wants a real git tree to read workflows out of, and the
28 + # tree isn't what's under test. `rerun/1` seeds jobs from a stored
29 + # `workflow_doc` through exactly the same creation path, so a seed row
30 + # plus a re-run gets the real thing without a repository on disk.
31 + defp run_workflow(repo, yaml) do
32 + {:ok, doc} = Yaml.parse(yaml)
33 +
34 + seed =
35 + %WorkflowRun{repository_id: repo.id}
36 + |> WorkflowRun.create_changeset(%{
37 + "number" => System.unique_integer([:positive]),
38 + "workflow_path" => ".forgejo/workflows/ci.yml",
39 + "workflow_name" => doc.name,
40 + "trigger" => "push",
41 + "head_ref" => "refs/heads/main",
42 + "head_sha" => :crypto.strong_rand_bytes(20),
43 + "workflow_doc" => doc.raw
44 + })
45 + |> Repo.insert!()
46 +
47 + {:ok, run} = Workflows.rerun(seed)
48 + run
49 + end
50 +
51 + defp jobs(run) do
52 + from(j in WorkflowJob, where: j.workflow_run_id == ^run.id, order_by: j.id) |> Repo.all()
53 + end
54 +
55 + defp runner(repo, labels) do
56 + {:ok, runner, _uuid, _token} =
57 + Runners.create_offline(
58 + %{name: "r-#{System.unique_integer([:positive])}", labels: %{"labels" => labels}},
59 + repo
60 + )
61 +
62 + runner
63 + end
64 +
65 + describe "expansion" do
66 + test "a two-architecture matrix becomes two jobs", %{repo: repo} do
67 + run =
68 + run_workflow(repo, """
69 + name: ci
70 + on: push
71 + jobs:
72 + build:
73 + strategy:
74 + matrix:
75 + arch: [amd64, arm64]
76 + runs-on: [self-hosted, "${{ matrix.arch }}"]
77 + steps:
78 + - run: echo building for ${{ matrix.arch }}
79 + """)
80 +
81 + assert [amd, arm] = jobs(run)
82 +
83 + # One YAML job id, two rows — `needs: build` still names the group.
84 + assert amd.job_id == "build"
85 + assert arm.job_id == "build"
86 +
87 + assert amd.name == "build (amd64)"
88 + assert arm.name == "build (arm64)"
89 +
90 + assert amd.matrix == %{"arch" => "amd64"}
91 + assert arm.matrix == %{"arch" => "arm64"}
92 + end
93 +
94 + test "`runs-on` is resolved per leg, because it decides the runner", %{repo: repo} do
95 + run =
96 + run_workflow(repo, """
97 + name: ci
98 + on: push
99 + jobs:
100 + build:
101 + strategy:
102 + matrix:
103 + arch: [amd64, arm64]
104 + runs-on: [self-hosted, "${{ matrix.arch }}"]
105 + steps:
106 + - run: true
107 + """)
108 +
109 + assert [amd, arm] = jobs(run)
110 +
111 + assert amd.runs_on == %{"labels" => ["self-hosted", "amd64"]}
112 + assert arm.runs_on == %{"labels" => ["self-hosted", "arm64"]}
113 + end
114 +
115 + test "each leg's stored definition pins the matrix to one value", %{repo: repo} do
116 + run =
117 + run_workflow(repo, """
118 + name: ci
119 + on: push
120 + jobs:
121 + build:
122 + strategy:
123 + matrix:
124 + arch: [amd64, arm64]
125 + runs-on: self-hosted
126 + steps:
127 + - run: echo ${{ matrix.arch }}
128 + """)
129 +
130 + assert [amd, _arm] = jobs(run)
131 +
132 + # One value, so the runner's own engine expands it to exactly one
133 + # job and `${{ matrix.arch }}` in the step still resolves. Left
134 + # whole, the runner would fan out again inside this one job.
135 + assert amd.definition["strategy"] == %{"matrix" => %{"arch" => ["amd64"]}}
136 + end
137 +
138 + test "a job with no matrix is still one job, and keeps its name", %{repo: repo} do
139 + run =
140 + run_workflow(repo, """
141 + name: ci
142 + on: push
143 + jobs:
144 + test:
145 + name: unit tests
146 + runs-on: ubuntu-latest
147 + steps:
148 + - run: true
149 + """)
150 +
151 + assert [job] = jobs(run)
152 + assert job.name == "unit tests"
153 + assert job.matrix == nil
154 + assert job.runs_on == %{"labels" => ["ubuntu-latest"]}
155 + refute Map.has_key?(job.definition, "strategy")
156 + end
157 +
158 + test "steps are created for every leg", %{repo: repo} do
159 + run =
160 + run_workflow(repo, """
161 + name: ci
162 + on: push
163 + jobs:
164 + build:
165 + strategy:
166 + matrix:
167 + arch: [amd64, arm64]
168 + runs-on: self-hosted
169 + steps:
170 + - run: one
171 + - run: two
172 + """)
173 +
174 + for job <- jobs(run) do
175 + assert length(Repo.preload(job, :steps).steps) == 2
176 + end
177 + end
178 + end
179 +
180 + describe "routing to the right architecture" do
181 + setup %{repo: repo} do
182 + run =
183 + run_workflow(repo, """
184 + name: ci
185 + on: push
186 + jobs:
187 + build:
188 + strategy:
189 + matrix:
190 + arch: [amd64, arm64]
191 + runs-on: [self-hosted, "${{ matrix.arch }}"]
192 + steps:
193 + - run: true
194 + """)
195 +
196 + {:ok, run: run}
197 + end
198 +
199 + test "an arm64 runner claims the arm64 leg and only that one", %{repo: repo, run: _run} do
200 + arm = runner(repo, ["self-hosted", "arm64"])
201 +
202 + assert %WorkflowJob{} = claimed = Workflows.claim_next_job(arm)
203 + assert claimed.matrix == %{"arch" => "arm64"}
204 +
205 + # Nothing else here is for arm64.
206 + assert Workflows.claim_next_job(arm) == nil
207 + end
208 +
209 + test "two runners of different architectures take one leg each", %{repo: repo} do
210 + amd = runner(repo, ["self-hosted", "amd64"])
211 + arm = runner(repo, ["self-hosted", "arm64"])
212 +
213 + assert amd_job = Workflows.claim_next_job(amd)
214 + assert arm_job = Workflows.claim_next_job(arm)
215 +
216 + assert amd_job.matrix == %{"arch" => "amd64"}
217 + assert arm_job.matrix == %{"arch" => "arm64"}
218 + refute amd_job.id == arm_job.id
219 +
220 + # Both legs are spoken for.
221 + assert Workflows.claim_next_job(amd) == nil
222 + assert Workflows.claim_next_job(arm) == nil
223 + end
224 +
225 + test "a runner missing one of the required labels claims nothing", %{repo: repo} do
226 + # Carries `self-hosted` but neither architecture.
227 + idle = runner(repo, ["self-hosted", "riscv64"])
228 +
229 + assert Workflows.claim_next_job(idle) == nil
230 + end
231 +
232 + test "a runner with extra labels still qualifies — containment, not equality", %{repo: repo} do
233 + beefy = runner(repo, ["self-hosted", "arm64", "linux", "gpu"])
234 +
235 + assert claimed = Workflows.claim_next_job(beefy)
236 + assert claimed.matrix == %{"arch" => "arm64"}
237 + end
238 + end
239 +
240 + describe "routing without a matrix" do
241 + # Its own block: the matrix setup above queues work for both
242 + # architectures, which is exactly what a "this runner should find
243 + # nothing" assertion cannot have sitting around.
244 + test "a scalar runs-on still routes, which is every pre-existing workflow", %{repo: repo} do
245 + run =
246 + run_workflow(repo, """
247 + name: ci
248 + on: push
249 + jobs:
250 + plain:
251 + runs-on: ubuntu-latest
252 + steps:
253 + - run: true
254 + """)
255 +
256 + wrong = runner(repo, ["self-hosted", "arm64"])
257 + right = runner(repo, ["ubuntu-latest"])
258 +
259 + assert Workflows.claim_next_job(wrong) == nil
260 +
261 + assert claimed = Workflows.claim_next_job(right)
262 + assert claimed.workflow_run_id == run.id
263 + end
264 +
265 + test "`runs-on: self-hosted` matches any self-registered runner", %{repo: repo} do
266 + run_workflow(repo, """
267 + name: ci
268 + on: push
269 + jobs:
270 + anywhere:
271 + runs-on: self-hosted
272 + steps:
273 + - run: true
274 + """)
275 +
276 + # Doesn't carry the `self-hosted` label itself; it's implied.
277 + any = runner(repo, ["arm64"])
278 +
279 + assert claimed = Workflows.claim_next_job(any)
280 + assert claimed.job_id == "anywhere"
281 + end
282 + end
283 +
284 + describe "dependencies across a matrix" do
285 + setup %{repo: repo} do
286 + run =
287 + run_workflow(repo, """
288 + name: ci
289 + on: push
290 + jobs:
291 + build:
292 + strategy:
293 + matrix:
294 + arch: [amd64, arm64]
295 + runs-on: [self-hosted, "${{ matrix.arch }}"]
296 + steps:
297 + - run: true
298 + release:
299 + needs: build
300 + runs-on: self-hosted
301 + steps:
302 + - run: true
303 + """)
304 +
305 + {:ok, run: run}
306 + end
307 +
308 + test "a downstream job waits for every leg, not the first", %{run: run} do
309 + [amd, arm, release] = jobs(run)
310 +
311 + assert release.status == "blocked"
312 +
313 + {:ok, _} = Workflows.update_job_status(amd, "success")
314 + assert Repo.get!(WorkflowJob, release.id).status == "blocked"
315 +
316 + {:ok, _} = Workflows.update_job_status(arm, "success")
317 + assert Repo.get!(WorkflowJob, release.id).status == "queued"
318 + end
319 +
320 + test "one failing leg cancels the downstream job", %{run: run} do
321 + [amd, arm, release] = jobs(run)
322 +
323 + {:ok, _} = Workflows.update_job_status(amd, "success")
324 + {:ok, _} = Workflows.update_job_status(arm, "failure")
325 +
326 + assert Repo.get!(WorkflowJob, release.id).status == "cancelled"
327 + end
328 +
329 + test "the snapshot collapses the legs into one name", %{run: run} do
330 + [amd, arm, release] = jobs(run)
331 +
332 + {:ok, _} = Workflows.update_job_status(amd, "success")
333 + {:ok, _} = Workflows.update_job_status(arm, "success")
334 +
335 + # Downstream `${{ needs.build }}` is one name however many
336 + # machines produced it.
337 + assert %{"build" => %{"status" => "success"}} =
338 + Repo.get!(WorkflowJob, release.id).needs_snapshot
339 + end
340 + end
341 +
342 + describe "fail-fast" do
343 + test "a failing leg cancels its queued siblings", %{repo: repo} do
344 + run =
345 + run_workflow(repo, """
346 + name: ci
347 + on: push
348 + jobs:
349 + build:
350 + strategy:
351 + matrix:
352 + arch: [amd64, arm64, riscv64]
353 + runs-on: [self-hosted, "${{ matrix.arch }}"]
354 + steps:
355 + - run: true
356 + """)
357 +
358 + [first | rest] = jobs(run)
359 +
360 + {:ok, _} = Workflows.update_job_status(first, "failure")
361 +
362 + for sibling <- rest do
363 + assert Repo.get!(WorkflowJob, sibling.id).status == "cancelled"
364 + end
365 + end
366 +
367 + test "`fail-fast: false` lets the others finish", %{repo: repo} do
368 + run =
369 + run_workflow(repo, """
370 + name: ci
371 + on: push
372 + jobs:
373 + build:
374 + strategy:
375 + fail-fast: false
376 + matrix:
377 + arch: [amd64, arm64, riscv64]
378 + runs-on: [self-hosted, "${{ matrix.arch }}"]
379 + steps:
380 + - run: true
381 + """)
382 +
383 + [first | rest] = jobs(run)
384 +
385 + {:ok, _} = Workflows.update_job_status(first, "failure")
386 +
387 + for sibling <- rest do
388 + assert Repo.get!(WorkflowJob, sibling.id).status == "queued"
389 + end
390 + end
391 +
392 + test "a leg already running is left to finish rather than killed", %{repo: repo} do
393 + run =
394 + run_workflow(repo, """
395 + name: ci
396 + on: push
397 + jobs:
398 + build:
399 + strategy:
400 + matrix:
401 + arch: [amd64, arm64]
402 + runs-on: self-hosted
403 + steps:
404 + - run: true
405 + """)
406 +
407 + [first, second] = jobs(run)
408 +
409 + {:ok, _} = Workflows.update_job_status(second, "running")
410 + {:ok, _} = Workflows.update_job_status(first, "failure")
411 +
412 + # Killing it mid-flight would lose its logs for no gain.
413 + assert Repo.get!(WorkflowJob, second.id).status == "running"
414 + end
415 +
416 + test "a job with no matrix never fail-fasts its unrelated siblings", %{repo: repo} do
417 + run =
418 + run_workflow(repo, """
419 + name: ci
420 + on: push
421 + jobs:
422 + a:
423 + runs-on: self-hosted
424 + steps:
425 + - run: true
426 + b:
427 + runs-on: self-hosted
428 + steps:
429 + - run: true
430 + """)
431 +
432 + [a, b] = jobs(run)
433 +
434 + {:ok, _} = Workflows.update_job_status(a, "failure")
435 +
436 + assert Repo.get!(WorkflowJob, b.id).status == "queued"
437 + end
438 + end
439 +
440 + describe "re-running" do
441 + test "re-running a matrix job re-runs every leg, not the first one found", %{repo: repo} do
442 + run =
443 + run_workflow(repo, """
444 + name: ci
445 + on: push
446 + jobs:
447 + build:
448 + strategy:
449 + fail-fast: false
450 + matrix:
451 + arch: [amd64, arm64]
452 + runs-on: self-hosted
453 + steps:
454 + - run: true
455 + """)
456 +
457 + [amd, arm] = jobs(run)
458 + {:ok, _} = Workflows.update_job_status(amd, "failure")
459 + {:ok, _} = Workflows.update_job_status(arm, "success")
460 +
461 + assert {:ok, _} = Workflows.rerun_job(run, "build")
462 +
463 + assert Repo.get!(WorkflowJob, amd.id).status == "queued"
464 + assert Repo.get!(WorkflowJob, arm.id).status == "queued"
465 + end
466 +
467 + test "an unknown job id is still not found", %{repo: repo} do
468 + run =
469 + run_workflow(repo, """
470 + name: ci
471 + on: push
472 + jobs:
473 + build:
474 + runs-on: self-hosted
475 + steps:
476 + - run: true
477 + """)
478 +
479 + assert {:error, :not_found} = Workflows.rerun_job(run, "nope")
480 + end
481 + end
482 +
483 + describe "the run as a whole" do
484 + test "completes only once every leg is terminal", %{repo: repo} do
485 + run =
486 + run_workflow(repo, """
487 + name: ci
488 + on: push
489 + jobs:
490 + build:
491 + strategy:
492 + fail-fast: false
493 + matrix:
494 + arch: [amd64, arm64]
495 + runs-on: self-hosted
496 + steps:
497 + - run: true
498 + """)
499 +
500 + [amd, arm] = jobs(run)
501 +
502 + {:ok, _} = Workflows.update_job_status(amd, "success")
503 + assert Repo.get!(WorkflowRun, run.id).status in ~w(queued running)
504 +
505 + {:ok, _} = Workflows.update_job_status(arm, "success")
506 + assert Repo.get!(WorkflowRun, run.id).status == "success"
507 + end
508 + end
509 +end
added test/git_gud/workflows/matrix_test.exs
+243 −0
@@ -0,0 +1,243 @@
1 +defmodule GitGud.Workflows.MatrixTest do
2 + @moduledoc """
3 + Matrix expansion, checked against the examples in GitHub's own
4 + workflow-syntax documentation — `include` and `exclude` are the parts
5 + people actually get wrong, and "it seemed reasonable" is not a
6 + specification.
7 + """
8 + use ExUnit.Case, async: true
9 +
10 + alias GitGud.Workflows.Matrix
11 +
12 + defp strategy(matrix), do: %{"matrix" => matrix}
13 +
14 + describe "expand/1" do
15 + test "no strategy is one leg with nothing in it" do
16 + assert Matrix.expand(nil) == [%{}]
17 + assert Matrix.expand(%{}) == [%{}]
18 + assert Matrix.expand(%{"fail-fast" => false}) == [%{}]
19 + end
20 +
21 + test "one axis is one job per value" do
22 + assert Matrix.expand(strategy(%{"arch" => ["amd64", "arm64"]})) == [
23 + %{"arch" => "amd64"},
24 + %{"arch" => "arm64"}
25 + ]
26 + end
27 +
28 + test "two axes are their product" do
29 + combinations =
30 + Matrix.expand(strategy(%{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"]}))
31 +
32 + assert length(combinations) == 4
33 +
34 + assert %{"arch" => "amd64", "otp" => "27"} in combinations
35 + assert %{"arch" => "arm64", "otp" => "28"} in combinations
36 + end
37 +
38 + test "a scalar value is a single-value axis" do
39 + assert Matrix.expand(strategy(%{"arch" => "arm64"})) == [%{"arch" => "arm64"}]
40 + end
41 +
42 + test "expansion is stable — map order must not decide job order" do
43 + matrix = %{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"], "os" => ["a", "b"]}
44 +
45 + assert Matrix.expand(strategy(matrix)) == Matrix.expand(strategy(matrix))
46 + end
47 + end
48 +
49 + describe "exclude" do
50 + test "drops combinations matching every key it names" do
51 + combinations =
52 + Matrix.expand(
53 + strategy(%{
54 + "arch" => ["amd64", "arm64"],
55 + "otp" => ["27", "28"],
56 + "exclude" => [%{"arch" => "arm64", "otp" => "27"}]
57 + })
58 + )
59 +
60 + assert length(combinations) == 3
61 + refute %{"arch" => "arm64", "otp" => "27"} in combinations
62 + end
63 +
64 + test "a partial exclude drops every combination it matches" do
65 + combinations =
66 + Matrix.expand(
67 + strategy(%{
68 + "arch" => ["amd64", "arm64"],
69 + "otp" => ["27", "28"],
70 + "exclude" => [%{"arch" => "arm64"}]
71 + })
72 + )
73 +
74 + assert combinations == [
75 + %{"arch" => "amd64", "otp" => "27"},
76 + %{"arch" => "amd64", "otp" => "28"}
77 + ]
78 + end
79 + end
80 +
81 + describe "include" do
82 + test "adds keys to the combinations it matches rather than appending a job" do
83 + combinations =
84 + Matrix.expand(
85 + strategy(%{
86 + "arch" => ["amd64", "arm64"],
87 + "include" => [%{"arch" => "arm64", "coverage" => true}]
88 + })
89 + )
90 +
91 + # Still two jobs — the arm64 one just gained a key.
92 + assert combinations == [
93 + %{"arch" => "amd64"},
94 + %{"arch" => "arm64", "coverage" => true}
95 + ]
96 + end
97 +
98 + test "an entry matching nothing becomes a combination of its own" do
99 + combinations =
100 + Matrix.expand(
101 + strategy(%{
102 + "arch" => ["amd64", "arm64"],
103 + "include" => [%{"arch" => "riscv64"}]
104 + })
105 + )
106 +
107 + assert combinations == [
108 + %{"arch" => "amd64"},
109 + %{"arch" => "arm64"},
110 + %{"arch" => "riscv64"}
111 + ]
112 + end
113 +
114 + test "an entry with no overlapping keys applies to every combination" do
115 + combinations =
116 + Matrix.expand(
117 + strategy(%{
118 + "arch" => ["amd64", "arm64"],
119 + "include" => [%{"otp" => "28"}]
120 + })
121 + )
122 +
123 + assert combinations == [
124 + %{"arch" => "amd64", "otp" => "28"},
125 + %{"arch" => "arm64", "otp" => "28"}
126 + ]
127 + end
128 +
129 + test "include runs after exclude, so it can add a combination back" do
130 + combinations =
131 + Matrix.expand(
132 + strategy(%{
133 + "arch" => ["amd64", "arm64"],
134 + "exclude" => [%{"arch" => "arm64"}],
135 + "include" => [%{"arch" => "arm64", "experimental" => true}]
136 + })
137 + )
138 +
139 + assert combinations == [
140 + %{"arch" => "amd64"},
141 + %{"arch" => "arm64", "experimental" => true}
142 + ]
143 + end
144 +
145 + test "an include-only matrix is just its entries" do
146 + combinations =
147 + Matrix.expand(
148 + strategy(%{
149 + "include" => [
150 + %{"arch" => "amd64", "runner" => "big"},
151 + %{"arch" => "arm64", "runner" => "small"}
152 + ]
153 + })
154 + )
155 +
156 + assert combinations == [
157 + %{"arch" => "amd64", "runner" => "big"},
158 + %{"arch" => "arm64", "runner" => "small"}
159 + ]
160 + end
161 + end
162 +
163 + describe "substitute/2" do
164 + test "resolves matrix references in a runs-on" do
165 + assert Matrix.substitute("${{ matrix.arch }}", %{"arch" => "arm64"}) == "arm64"
166 + assert Matrix.substitute("${{matrix.arch}}", %{"arch" => "arm64"}) == "arm64"
167 + assert Matrix.substitute("linux-${{ matrix.arch }}", %{"arch" => "arm64"}) == "linux-arm64"
168 + end
169 +
170 + test "resolves inside a list, which is how an architecture is usually selected" do
171 + assert Matrix.substitute(["self-hosted", "${{ matrix.arch }}"], %{"arch" => "arm64"}) ==
172 + ["self-hosted", "arm64"]
173 + end
174 +
175 + test "an unknown key resolves to empty, as it does in Actions" do
176 + assert Matrix.substitute("${{ matrix.nope }}", %{"arch" => "arm64"}) == ""
177 + end
178 +
179 + test "leaves other expressions alone — the runner evaluates those" do
180 + assert Matrix.substitute("${{ github.sha }}", %{"arch" => "arm64"}) == "${{ github.sha }}"
181 + end
182 +
183 + test "non-strings pass through" do
184 + assert Matrix.substitute(42, %{}) == 42
185 + assert Matrix.substitute(nil, %{}) == nil
186 + end
187 + end
188 +
189 + describe "labels/1" do
190 + test "a scalar and a one-element list are the same thing" do
191 + assert Matrix.labels("ubuntu-latest") == ["ubuntu-latest"]
192 + assert Matrix.labels(["ubuntu-latest"]) == ["ubuntu-latest"]
193 + end
194 +
195 + test "keeps every label in a list — all of them must be satisfied" do
196 + assert Matrix.labels(["self-hosted", "arm64"]) == ["self-hosted", "arm64"]
197 + end
198 +
199 + test "drops blanks and duplicates" do
200 + assert Matrix.labels(["self-hosted", "", "self-hosted"]) == ["self-hosted"]
201 + end
202 + end
203 +
204 + describe "pin_definition/2" do
205 + test "pins the matrix to this leg so the runner expands it to exactly one job" do
206 + definition = %{
207 + "runs-on" => "self-hosted",
208 + "strategy" => %{"matrix" => %{"arch" => ["amd64", "arm64"]}, "fail-fast" => false},
209 + "steps" => [%{"run" => "echo ${{ matrix.arch }}"}]
210 + }
211 +
212 + pinned = Matrix.pin_definition(definition, %{"arch" => "arm64"})
213 +
214 + # One value, so act fans out to one job and the `matrix` context
215 + # still resolves for the step.
216 + # `fail-fast` rides along: the forge reads it back off the stored
217 + # definition, and this is the only copy that survives.
218 + assert pinned["strategy"] == %{"matrix" => %{"arch" => ["arm64"]}, "fail-fast" => false}
219 + assert pinned["steps"] == definition["steps"]
220 + end
221 +
222 + test "a job with no matrix loses its strategy entirely" do
223 + definition = %{"strategy" => %{"fail-fast" => false}, "runs-on" => "x"}
224 +
225 + assert Matrix.pin_definition(definition, %{}) == %{"runs-on" => "x"}
226 + end
227 + end
228 +
229 + describe "suffix/1 and fail_fast?/1" do
230 + test "names a leg the way Actions does" do
231 + assert Matrix.suffix(%{}) == nil
232 + assert Matrix.suffix(%{"arch" => "arm64"}) == "arm64"
233 + assert Matrix.suffix(%{"arch" => "arm64", "otp" => "28"}) == "arm64, 28"
234 + end
235 +
236 + test "fail-fast defaults on, and only an explicit false turns it off" do
237 + assert Matrix.fail_fast?(nil)
238 + assert Matrix.fail_fast?(%{"matrix" => %{}})
239 + refute Matrix.fail_fast?(%{"fail-fast" => false})
240 + refute Matrix.fail_fast?(%{"fail-fast" => "false"})
241 + end
242 + end
243 +end

Parents: 279c2d1