neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: matrices computed by earlier jobs, max-parallel, and an empty matrix fails
6b0b893 · Gabriel Morell · 2026-10-17 17:28
Message
{commit_body(@commit)}
Files changed
modified
README.md
+2
−2
@@ -86,7 +86,7 @@ reinvent them.
| 86 | 86 | - **CI** |
| 87 | 87 | - Speaks the **Forgejo Actions runner protocol** (Twirp) — point any modern `forgejo-runner` at it; protocol path autodetected (`ping.v1`, `runner.v1`, with/without `_apis/`) |
| 88 | 88 | - Repo-scoped + org-scoped runner registration with one-shot tokens minted from the settings UI |
| 89 | − - **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 | |
| 89 | + - **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, `max-parallel` caps how many legs of the job run at once, and `needs:` names the group rather than a leg. The matrix, an axis, or `include`/`exclude` can be computed — `matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}` — with the expression subset below plus `fromJSON()`/`toJSON()`; one that reads `needs` shows as a single job until those needs finish, then becomes its legs. A matrix with no combinations left (everything excluded, an empty axis) or an expression that doesn't evaluate to the right shape fails the job, saying why in its log | |
| 90 | 90 | - **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 |
| 91 | 91 | - **Triggers**: `push` (with `branches:` / `branches-ignore:` / `tags:` / `tags-ignore:`, GitHub's rule that naming one kind excludes the other kind of ref, and a list beating its `-ignore` if both are set), `pull_request` and `workflow_dispatch` |
| 92 | 92 | - **`on: pull_request`** for pull requests within one repository: `types:` `opened`, `synchronize` (the head moved) and `reopened` (the default; other activity types never fire). `branches:` / `branches-ignore:` filter the **target** branch, and `paths:` the PR's diff from its merge-base. Workflows are read from the PR's head commit, so a PR can change its own CI, and the job tests the head itself at `refs/pull/<N>/head` — not a merge with the target, as GitHub's `refs/pull/<N>/merge` does. The job gets `github.event_name`, `head_ref` and `base_ref` (the source and target branches), `ref_name` `<N>/head` and an empty `ref_type`. **Fork and federated pull requests don't run yet**: their head is code the target's maintainers haven't reviewed, and a job is handed the target repository's secrets and a token that can publish its packages; GitHub runs them without secrets and with a read-only token, and until that exists here they don't run at all. `pull_request_target` isn't supported |
@@ -95,7 +95,7 @@ reinvent them.
| 95 | 95 | - **The job's context** follows Forgejo's: `github.event_name`, `ref`, `ref_name`, `ref_type`, `sha`, `repository` (`owner/name`), `repository_id`, `repository_owner(_id)`, `actor(_id)`, `run_id`, `run_number`, `run_attempt` (1, bumped by re-running the job; re-running the whole run makes a new run and a new `run_id`), `workflow`, `workflow_ref` (`owner/name/<path>@<ref>`), `job`, `server_url`, `api_url`, `token`. **`github.event`** is GitHub's webhook payload, trimmed: every event has `repository` and `sender`; `push` has `ref`, `before` (40 zeros for a new ref), `after`, `created`, `deleted`, `forced` (always `false`: not tracked), `compare`, `commits` (at most 20) and `head_commit`, `pusher`; `pull_request` has `action`, `number` and `pull_request` (`title`, `body`, `html_url`, `user`, `head`/`base` with `ref`, `sha`, `label`, `repo`); `workflow_dispatch` has `inputs`, `ref`, `workflow`. **`secrets.GITHUB_TOKEN`** (and `GITEA_TOKEN`, `FORGEJO_TOKEN`) is the job token. The workflow's top-level `env:`, `defaults:`, `permissions:`, `run-name:` and `on:` reach the runner with the job |
| 96 | 96 | - **Jobs that depend on jobs**, as on GitHub: `${{ needs.<id>.outputs.* }}` and `needs.<id>.result` (`success`, `failure`, `cancelled`, `skipped`; for a matrix, the worst leg's, with the legs' outputs merged) reach the dependent job. A job waits until all its needs finish; if any didn't succeed it is **skipped** — and its own dependents with it — unless its `if:` calls `always()`, `failure()` or `cancelled()`, so cleanup and "report the failure" jobs run. A job its own `if:` skipped shows as skipped, not green. Job-level `continue-on-error: true` (or `${{ matrix.* }}` that comes out `true`) keeps a failure from skipping its dependents, failing the run or fail-fasting its matrix siblings, though the job still shows as failed; any other expression there counts as false. Skipped jobs don't fail a run. Unlike GitHub, **cancelling a run cancels its waiting jobs too**, `always()` or not |
| 97 | 97 | - Re-run / cancel buttons on the workflow show page. **Cancelling stops running jobs**: the runner hears it in the reply to its next progress report and stops the job, still uploading its log; a cancelled job stays cancelled whatever its runner reports afterwards |
| 98 | − - **`concurrency:`**, workflow and job level, as a group name or `{group:, cancel-in-progress:}`: within a group (per repository) one run or job is in progress and at most one waits (shown as "Waiting for <group>"); a newer arrival cancels the waiting one, or with `cancel-in-progress` the one in progress too, and when the one in progress finishes the waiting one starts. A run (or job) cancelled only by a newer one in its group concludes `cancelled`, not failed. A workflow's group is decided when the run is created, a job's when it would be queued, so it can read `matrix` and `needs`. Group and `cancel-in-progress` may use `${{ }}` with the forge's own expression subset: property paths into `github`, `inputs`, `vars`, `matrix`, `needs` (`a.b`, `a['b']`), string/number/`true`/`false`/`null` literals, `||`, `&&`, `==`, `!=`, `!`, parentheses and `format()`. A group outside that subset (any other function, say) is ignored with a warning, shown on the job — the run goes ahead **without** concurrency control rather than being held on a guess. Re-running a single job puts it back through its job-level group, not its run's | |
| 98 | + - **`concurrency:`**, workflow and job level, as a group name or `{group:, cancel-in-progress:}`: within a group (per repository) one run or job is in progress and at most one waits (shown as "Waiting for <group>"); a newer arrival cancels the waiting one, or with `cancel-in-progress` the one in progress too, and when the one in progress finishes the waiting one starts. A run (or job) cancelled only by a newer one in its group concludes `cancelled`, not failed. A workflow's group is decided when the run is created, a job's when it would be queued, so it can read `matrix` and `needs`. Group and `cancel-in-progress` may use `${{ }}` with the forge's own expression subset: property paths into `github`, `inputs`, `vars`, `matrix`, `needs` (`a.b`, `a['b']`), string/number/`true`/`false`/`null` literals, `||`, `&&`, `==`, `!=`, `!`, parentheses, `format()`, `fromJSON()` and `toJSON()`. A group outside that subset (any other function, say) is ignored with a warning, shown on the job — the run goes ahead **without** concurrency control rather than being held on a guess. Re-running a single job puts it back through its job-level group, not its run's | |
| 99 | 99 | - **A commit status per job**, named as Forgejo names them — `<workflow> / <job> (<event>)`, e.g. `ci / test (push)` or `ci / build (arm64, 28) (pull_request)` for a matrix leg — so a pull request's `push` run and `pull_request` run on the same head (or two jobs of one workflow) don't overwrite each other. Queued, blocked, waiting and running jobs are pending; success and skipped are success; failure and cancelled are failure, except a job cancelled by a newer one in its concurrency group, which shows as cancelled. Each pill links to its job on the run page, and the pull request page updates them live |
| 100 | 100 | - **Required status checks** in branch protection: with "Require status checks to pass before merging", a pull request merges into the branch only when every required check (one per line; `*` matches anything, `/` and spaces included, so `ci / test *` covers both events) matches at least one status on its head and every status it matches is success. An empty list means every status on the head, and there must be one. The merge box lists what's missing or not passing and disables the merge; an admin's override merges past them, recorded like a review-rule override. Direct pushes aren't checked — there's no head to have checks on, as on Forgejo |
| 101 | 101 | - **The job token** (`${{ github.token }}`, an HS256 JWT minted per job on `FetchTask`) works only while its job runs and for five minutes after (for the runner's post-job cache save and artifact upload), and only for that attempt of the job — a leaked token no longer stays valid for a day. It clones and fetches its own repository, reads and publishes its packages, reads its metadata over the API, dispatches workflows (its own repository's, or one that granted it), and reaches the artifact + cache endpoints of its run. **`permissions:`** narrows it as on GitHub: workflow level, replaced by a job's own; `read-all`, `write-all`, `{}` or a map in which unnamed scopes are `none`. `contents` (`read` fetch, `write` also push — to its own repository, subject to branch protection like any direct push, attributed to whoever started the run), `packages` (`read` / `write`) and `actions` (`write` to dispatch) mean something; `metadata` is always read; the others (`issues`, `pull-requests`, `statuses`, `checks`, `deployments`, `id-token`, …) are accepted and ignored, since the token can't do those things here anyway. With no `permissions:` the token keeps `contents: read, packages: write, actions: write` — GitHub's permissive default would also let it push. **A push made with the job token starts no workflows** (no `push` run, no pull request `synchronize`), as on GitHub, so a workflow that pushes can't trigger itself forever; webhooks and pull request heads still update |
modified
lib/git_gud/workflows.ex
+220
−15
@@ -456,6 +456,9 @@ defmodule GitGud.Workflows do
| 456 | 456 | # concurrency group cancelled it (`run_concurrency/2`). |
| 457 | 457 | @run_superseded "Cancelled by run #" |
| 458 | 458 | |
| 459 | + # The `hold` of a job created outside `create_run/2`: no run group. | |
| 460 | + @no_hold %{waiting: nil, note: nil} | |
| 461 | + | |
| 459 | 462 | defp create_run(%Repository{id: rid} = repo, %{jobs: jobs} = attrs) do |
| 460 | 463 | Repo.transaction(fn -> |
| 461 | 464 | number = next_run_number(rid) |
@@ -475,7 +478,10 @@ defmodule GitGud.Workflows do
| 475 | 478 | # Every leg exists before any is queued: queueing one can cancel |
| 476 | 479 | # another in its job-level group, and a run whose only jobs so far |
| 477 | 480 | # are cancelled would be settled as finished half-built. |
| 478 | − legs = Enum.flat_map(jobs, &create_job(run, &1, hold)) | |
| 481 | + contexts = fn -> Context.for_run(run, repo) end | |
| 482 | + created = Enum.map(jobs, &create_job(run, &1, hold, contexts)) | |
| 483 | + legs = Enum.flat_map(created, &elem(&1, 0)) | |
| 484 | + bad_matrices = for {_legs, {:error, row, reason}} <- created, do: {row, reason} | |
| 479 | 485 | |
| 480 | 486 | # A pending commit status per job, so the SCM views show the run is |
| 481 | 487 | # in flight before the first claim lands. |
@@ -485,12 +491,14 @@ defmodule GitGud.Workflows do
| 485 | 491 | |
| 486 | 492 | for %WorkflowJob{status: "queued"} = leg <- legs, do: enqueue_job(leg) |
| 487 | 493 | |
| 488 | − {run, hold} | |
| 494 | + {run, hold, bad_matrices} | |
| 489 | 495 | end) |
| 490 | 496 | |> case do |
| 491 | − {:ok, {run, hold}} -> | |
| 497 | + {:ok, {run, hold, bad_matrices}} -> | |
| 492 | 498 | broadcast_ci_change(run.id) |
| 493 | 499 | |
| 500 | + for {row, reason} <- bad_matrices, do: fail_matrix(row, reason) | |
| 501 | + | |
| 494 | 502 | # Cancelled once this run is committed, rather than from inside its |
| 495 | 503 | # transaction: `cancel/2` releases each one's group, and that |
| 496 | 504 | # should see this run. |
@@ -630,13 +638,59 @@ defmodule GitGud.Workflows do
| 630 | 638 | # matrix support on this side. Separate rows are what let two legs be |
| 631 | 639 | # claimed by two runners, which is the point when the axis is the |
| 632 | 640 | # machine's architecture. |
| 633 | − defp create_job(%WorkflowRun{} = run, %{} = job, hold) do | |
| 634 | − for combination <- Matrix.expand(job[:strategy] || job.raw["strategy"]) do | |
| 635 | − create_job_leg(run, job, combination, hold) | |
| 641 | + # | |
| 642 | + # A matrix that is (or holds) an expression is expanded now, against the | |
| 643 | + # run's contexts, unless the job has `needs` — it may read them, so it | |
| 644 | + # gets one `blocked` placeholder row (`placeholder?/1`) that | |
| 645 | + # `resolve_dependents/1` expands once they finish. A matrix that won't | |
| 646 | + # expand gets one row too, `{:error, row, reason}`, which `create_run/2` | |
| 647 | + # fails once the run is committed. | |
| 648 | + defp create_job(%WorkflowRun{} = run, %{} = job, hold, contexts) do | |
| 649 | + strategy = job[:strategy] || job.raw["strategy"] | |
| 650 | + dynamic? = Matrix.dynamic?(strategy) | |
| 651 | + | |
| 652 | + if dynamic? and job.needs != [] do | |
| 653 | + {[create_placeholder(run, job, hold)], :ok} | |
| 654 | + else | |
| 655 | + case Matrix.expand(strategy, if(dynamic?, do: contexts.(), else: %{})) do | |
| 656 | + {:ok, combinations} -> | |
| 657 | + {Enum.map(combinations, &create_job_leg(run, job, &1, hold)), :ok} | |
| 658 | + | |
| 659 | + {:error, reason} -> | |
| 660 | + row = create_placeholder(run, job, hold) | |
| 661 | + {[row], {:error, row, reason}} | |
| 662 | + end | |
| 636 | 663 | end |
| 637 | 664 | end |
| 638 | 665 | |
| 639 | − defp create_job_leg(%WorkflowRun{id: rid}, %{} = job, combination, hold) do | |
| 666 | + # The job as one `blocked` row, its definition (and so its matrix | |
| 667 | + # expression) whole. | |
| 668 | + defp create_placeholder(run, job, hold), | |
| 669 | + do: create_job_leg(run, job, %{}, hold, status: "blocked", definition: job.raw) | |
| 670 | + | |
| 671 | + # A row standing for a matrix not yet expanded (or that failed to): | |
| 672 | + # no combination of its own, an expression for a matrix. | |
| 673 | + defp placeholder?(%WorkflowJob{matrix: nil, definition: definition}), | |
| 674 | + do: Matrix.dynamic?(definition["strategy"]) | |
| 675 | + | |
| 676 | + defp placeholder?(_job), do: false | |
| 677 | + | |
| 678 | + # The `Yaml` job a stored row was created from, to create its legs. | |
| 679 | + defp job_spec(%WorkflowJob{} = row) do | |
| 680 | + definition = row.definition || %{} | |
| 681 | + | |
| 682 | + %{ | |
| 683 | + id: row.job_id, | |
| 684 | + name: definition["name"], | |
| 685 | + runs_on: Map.get(definition, "runs-on", "ubuntu-latest"), | |
| 686 | + strategy: definition["strategy"], | |
| 687 | + needs: row.needs, | |
| 688 | + steps: Map.get(definition, "steps", []), | |
| 689 | + raw: definition | |
| 690 | + } | |
| 691 | + end | |
| 692 | + | |
| 693 | + defp create_job_leg(%WorkflowRun{id: rid}, %{} = job, combination, hold, opts \\ []) do | |
| 640 | 694 | # Jobs with unresolved `needs:` start in `blocked` so claim_next_job |
| 641 | 695 | # can never pick them up before their prerequisites complete. |
| 642 | 696 | # Promotion to `queued` happens in `resolve_dependents/1`. The others |
@@ -644,6 +698,7 @@ defmodule GitGud.Workflows do
| 644 | 698 | # `queued` (and `create_run/2` then decides their own group). |
| 645 | 699 | {initial_status, reason} = |
| 646 | 700 | cond do |
| 701 | + opts[:status] -> {opts[:status], hold.note} | |
| 647 | 702 | job.needs != [] -> {"blocked", hold.note} |
| 648 | 703 | hold.waiting -> {"waiting", hold.waiting} |
| 649 | 704 | true -> {"queued", hold.note} |
@@ -664,7 +719,7 @@ defmodule GitGud.Workflows do
| 664 | 719 | runs_on: %{"labels" => labels}, |
| 665 | 720 | needs: job.needs, |
| 666 | 721 | matrix: if(combination == %{}, do: nil, else: combination), |
| 667 | − definition: Matrix.pin_definition(job.raw, combination) | |
| 722 | + definition: opts[:definition] || Matrix.pin_definition(job.raw, combination) | |
| 668 | 723 | }) |
| 669 | 724 | |> Ecto.Changeset.change(status: initial_status, status_reason: reason) |
| 670 | 725 | |> Repo.insert!() |
@@ -1017,14 +1072,18 @@ defmodule GitGud.Workflows do
| 1017 | 1072 | [j], |
| 1018 | 1073 | fragment("(? -> 'labels') <@ to_jsonb(?::text[])", j.runs_on, ^match_labels) |
| 1019 | 1074 | ) |
| 1075 | + |> under_max_parallel() | |
| 1020 | 1076 | |> lock("FOR UPDATE SKIP LOCKED") |
| 1021 | 1077 | |> Repo.one() |
| 1022 | 1078 | |
| 1023 | − case job do | |
| 1079 | + case job && max_parallel_slot?(job) do | |
| 1024 | 1080 | nil -> |
| 1025 | 1081 | nil |
| 1026 | 1082 | |
| 1027 | − job -> | |
| 1083 | + false -> | |
| 1084 | + nil | |
| 1085 | + | |
| 1086 | + true -> | |
| 1028 | 1087 | {:ok, claimed} = job |> WorkflowJob.claim_changeset(rid) |> Repo.update() |
| 1029 | 1088 | run = Repo.get!(WorkflowRun, claimed.workflow_run_id) |
| 1030 | 1089 |
@@ -1043,7 +1102,9 @@ defmodule GitGud.Workflows do
| 1043 | 1102 | end |
| 1044 | 1103 | end |
| 1045 | 1104 | |
| 1046 | − defp do_claim_next_job(%Runner{id: rid} = runner, labels) do | |
| 1105 | + # `passed_over` are jobs found at their `max-parallel` once locked | |
| 1106 | + # (`max_parallel_slot?/1`), which the next attempt looks past. | |
| 1107 | + defp do_claim_next_job(%Runner{id: rid} = runner, labels, passed_over \\ []) do | |
| 1047 | 1108 | match_labels = match_labels(labels) |
| 1048 | 1109 | |
| 1049 | 1110 | Repo.transaction(fn -> |
@@ -1054,16 +1115,21 @@ defmodule GitGud.Workflows do
| 1054 | 1115 | [j], |
| 1055 | 1116 | fragment("(? -> 'labels') <@ to_jsonb(?::text[])", j.runs_on, ^match_labels) |
| 1056 | 1117 | ) |
| 1118 | + |> where([j], j.id not in ^passed_over) | |
| 1119 | + |> under_max_parallel() | |
| 1057 | 1120 | |> order_by([j], asc: j.id) |
| 1058 | 1121 | |> limit(1) |
| 1059 | 1122 | |> lock("FOR UPDATE SKIP LOCKED") |
| 1060 | 1123 | |> Repo.one() |
| 1061 | 1124 | |
| 1062 | − case job do | |
| 1125 | + case job && max_parallel_slot?(job) do | |
| 1063 | 1126 | nil -> |
| 1064 | 1127 | nil |
| 1065 | 1128 | |
| 1066 | − job -> | |
| 1129 | + false -> | |
| 1130 | + Repo.rollback({:passed_over, job.id}) | |
| 1131 | + | |
| 1132 | + true -> | |
| 1067 | 1133 | {:ok, claimed} = job |> WorkflowJob.claim_changeset(rid) |> Repo.update() |
| 1068 | 1134 | # Promote the parent run to "running" on first claim. |
| 1069 | 1135 | run = Repo.get!(WorkflowRun, claimed.workflow_run_id) |
@@ -1079,10 +1145,61 @@ defmodule GitGud.Workflows do
| 1079 | 1145 | end) |
| 1080 | 1146 | |> case do |
| 1081 | 1147 | {:ok, result} -> result |
| 1148 | + {:error, {:passed_over, id}} -> do_claim_next_job(runner, labels, [id | passed_over]) | |
| 1082 | 1149 | err -> err |
| 1083 | 1150 | end |
| 1084 | 1151 | end |
| 1085 | 1152 | |
| 1153 | + # `strategy.max-parallel` (`Matrix.max_parallel/1`, pinned onto every | |
| 1154 | + # leg): a queued leg isn't claimable while that many legs of its job are | |
| 1155 | + # running in its run. | |
| 1156 | + defp under_max_parallel(query) do | |
| 1157 | + where( | |
| 1158 | + query, | |
| 1159 | + [j], | |
| 1160 | + fragment( | |
| 1161 | + """ | |
| 1162 | + CASE WHEN (? #>> '{strategy,max-parallel}') ~ '^[1-9][0-9]{0,8}$' | |
| 1163 | + THEN (SELECT count(*) FROM workflow_jobs AS r | |
| 1164 | + WHERE r.workflow_run_id = ? AND r.job_id = ? AND r.status = 'running') | |
| 1165 | + < (? #>> '{strategy,max-parallel}')::integer | |
| 1166 | + ELSE true END | |
| 1167 | + """, | |
| 1168 | + j.definition, | |
| 1169 | + j.workflow_run_id, | |
| 1170 | + j.job_id, | |
| 1171 | + j.definition | |
| 1172 | + ) | |
| 1173 | + ) | |
| 1174 | + end | |
| 1175 | + | |
| 1176 | + # The query's count is from before the row lock, so two runners can | |
| 1177 | + # both see the last free slot. Legs with a limit are claimed one at a | |
| 1178 | + # time per job (an advisory lock until the transaction ends), and the | |
| 1179 | + # count re-read under it sees any claim committed meanwhile. | |
| 1180 | + defp max_parallel_slot?(%WorkflowJob{} = job) do | |
| 1181 | + case Matrix.max_parallel(strategy_of(job)) do | |
| 1182 | + nil -> | |
| 1183 | + true | |
| 1184 | + | |
| 1185 | + limit -> | |
| 1186 | + Repo.query!("SELECT pg_advisory_xact_lock(hashtext($1))", [ | |
| 1187 | + "workflow_job_max_parallel:#{job.workflow_run_id}:#{job.job_id}" | |
| 1188 | + ]) | |
| 1189 | + | |
| 1190 | + running = | |
| 1191 | + from(j in WorkflowJob, | |
| 1192 | + where: | |
| 1193 | + j.workflow_run_id == ^job.workflow_run_id and j.job_id == ^job.job_id and | |
| 1194 | + j.status == "running", | |
| 1195 | + select: count() | |
| 1196 | + ) | |
| 1197 | + |> Repo.one() | |
| 1198 | + | |
| 1199 | + running < limit | |
| 1200 | + end | |
| 1201 | + end | |
| 1202 | + | |
| 1086 | 1203 | # The label set a runner can satisfy, as JSON for the containment |
| 1087 | 1204 | # test. A job is claimable when *every* label its `runs-on` lists is |
| 1088 | 1205 | # one this runner carries — GitHub's rule, and the reason |
@@ -1757,7 +1874,7 @@ defmodule GitGud.Workflows do
| 1757 | 1874 | {:ok, _} = update_job_status(dep, "skipped") |
| 1758 | 1875 | |
| 1759 | 1876 | {:queue, snapshot} -> |
| 1760 | − enqueue_job(dep, needs_snapshot: snapshot) | |
| 1877 | + promote(dep, snapshot) | |
| 1761 | 1878 | |
| 1762 | 1879 | _blocked_or_decided -> |
| 1763 | 1880 | :ok |
@@ -1767,6 +1884,74 @@ defmodule GitGud.Workflows do
| 1767 | 1884 | :ok |
| 1768 | 1885 | end |
| 1769 | 1886 | |
| 1887 | + # Queue a job whose needs let it run — or, for a matrix placeholder | |
| 1888 | + # (`create_job/4`), expand it now that `needs` can be read and queue its | |
| 1889 | + # legs in its place. | |
| 1890 | + defp promote(%WorkflowJob{} = job, snapshot) do | |
| 1891 | + if placeholder?(job), | |
| 1892 | + do: expand_placeholder(job, snapshot), | |
| 1893 | + else: enqueue_job(job, needs_snapshot: snapshot) | |
| 1894 | + end | |
| 1895 | + | |
| 1896 | + # The legs replace the placeholder in one transaction: they keep its | |
| 1897 | + # `job_id` (so `needs: build` downstream finds them) and get the same | |
| 1898 | + # `needs_snapshot`. Its pending commit status goes with it, since the | |
| 1899 | + # legs report under their own names. Re-read under a lock, so two needs | |
| 1900 | + # finishing at once expand it once. | |
| 1901 | + defp expand_placeholder(%WorkflowJob{} = placeholder, snapshot) do | |
| 1902 | + run = Repo.get!(WorkflowRun, placeholder.workflow_run_id) | |
| 1903 | + repo = Repositories.get_repository!(run.repository_id) | |
| 1904 | + contexts = Context.for_job(%{placeholder | needs_snapshot: snapshot}, run, repo) | |
| 1905 | + spec = job_spec(placeholder) | |
| 1906 | + | |
| 1907 | + case Matrix.expand(spec.strategy, contexts) do | |
| 1908 | + {:ok, combinations} -> | |
| 1909 | + Repo.transaction(fn -> | |
| 1910 | + current = | |
| 1911 | + from(j in WorkflowJob, where: j.id == ^placeholder.id and j.status == "blocked") | |
| 1912 | + |> lock("FOR UPDATE") | |
| 1913 | + |> Repo.one() | |
| 1914 | + | |
| 1915 | + if is_nil(current), do: Repo.rollback(:already_expanded) | |
| 1916 | + | |
| 1917 | + legs = | |
| 1918 | + for combination <- combinations, | |
| 1919 | + do: create_job_leg(run, spec, combination, @no_hold, status: "blocked") | |
| 1920 | + | |
| 1921 | + drop_job_commit_status(placeholder, run) | |
| 1922 | + | |
| 1923 | + # Its steps (and their logs) go with it, by foreign key. | |
| 1924 | + Repo.delete_all(from j in WorkflowJob, where: j.id == ^placeholder.id) | |
| 1925 | + | |
| 1926 | + Enum.map(legs, &enqueue_job(&1, needs_snapshot: snapshot)) | |
| 1927 | + end) | |
| 1928 | + |> case do | |
| 1929 | + {:ok, legs} -> | |
| 1930 | + # Again now they're committed: the page re-reads the run on | |
| 1931 | + # each, and one sent from inside the transaction can read it | |
| 1932 | + # before the placeholder is gone. | |
| 1933 | + for leg <- legs, do: broadcast({:job_status, run.id, leg}) | |
| 1934 | + broadcast_ci_change(run.id) | |
| 1935 | + legs | |
| 1936 | + | |
| 1937 | + {:error, :already_expanded} -> | |
| 1938 | + [] | |
| 1939 | + end | |
| 1940 | + | |
| 1941 | + {:error, reason} -> | |
| 1942 | + fail_matrix(placeholder, reason) | |
| 1943 | + end | |
| 1944 | + end | |
| 1945 | + | |
| 1946 | + # A matrix that wouldn't expand fails its job, saying why in its log, | |
| 1947 | + # and so the run and the job's dependents, the usual way. | |
| 1948 | + defp fail_matrix(%WorkflowJob{} = row, reason) do | |
| 1949 | + message = Matrix.describe_error(reason) | |
| 1950 | + Logger.warning("workflow job #{row.id} (#{row.name}): #{message}") | |
| 1951 | + :ok = append_log(ensure_log_step(row), "Error: " <> message) | |
| 1952 | + update_job_status(row, "failure", reason: message) | |
| 1953 | + end | |
| 1954 | + | |
| 1770 | 1955 | # What `dep`'s needs, as they stand in `by_jid`, make of it: `:blocked` |
| 1771 | 1956 | # while one is in flight, then `{:queue, snapshot}`, `:skip` or |
| 1772 | 1957 | # `:cancel` (see `resolve_dependents/1`). |
@@ -2013,6 +2198,22 @@ defmodule GitGud.Workflows do
| 2013 | 2198 | ) |
| 2014 | 2199 | end |
| 2015 | 2200 | |
| 2201 | + # A job that no longer exists — a matrix placeholder its legs replaced. | |
| 2202 | + defp drop_job_commit_status(_job, %WorkflowRun{head_sha: nil}), do: :ok | |
| 2203 | + | |
| 2204 | + defp drop_job_commit_status(%WorkflowJob{} = job, %WorkflowRun{} = run) do | |
| 2205 | + context = job_status_context(job, run) | |
| 2206 | + | |
| 2207 | + from(c in GitGud.Workflows.CommitStatus, | |
| 2208 | + where: | |
| 2209 | + c.repository_id == ^run.repository_id and c.sha == ^run.head_sha and | |
| 2210 | + c.context == ^context | |
| 2211 | + ) | |
| 2212 | + |> Repo.delete_all() | |
| 2213 | + | |
| 2214 | + :ok | |
| 2215 | + end | |
| 2216 | + | |
| 2016 | 2217 | # The one status per workflow written before statuses were per job, so |
| 2017 | 2218 | # a re-run on such a commit doesn't leave it beside the jobs' (where a |
| 2018 | 2219 | # rule requiring every status to pass would never get past it). |
@@ -2199,7 +2400,10 @@ defmodule GitGud.Workflows do
| 2199 | 2400 | ) |
| 2200 | 2401 | |> Repo.update() |
| 2201 | 2402 | |
| 2202 | − case verdict.(job) do | |
| 2403 | + # A matrix placeholder is expanded once this is committed, | |
| 2404 | + # below: expanding inside it could fail the job, and settle | |
| 2405 | + # the run, before the run is flipped back to `running`. | |
| 2406 | + case not placeholder?(row) && verdict.(job) do | |
| 2203 | 2407 | {:queue, snapshot} -> enqueue_job(row, needs_snapshot: snapshot) |
| 2204 | 2408 | _ -> row |
| 2205 | 2409 | end |
@@ -2225,6 +2429,7 @@ defmodule GitGud.Workflows do
| 2225 | 2429 | case verdict.(row) do |
| 2226 | 2430 | :skip -> {:ok, _} = update_job_status(row, "skipped") |
| 2227 | 2431 | :cancel -> {:ok, _} = update_job_status(row, "cancelled") |
| 2432 | + {:queue, snapshot} -> promote(row, snapshot) | |
| 2228 | 2433 | _ -> :ok |
| 2229 | 2434 | end |
| 2230 | 2435 |
modified
lib/git_gud/workflows/expr.ex
+35
−2
@@ -7,7 +7,9 @@ defmodule GitGud.Workflows.Expr do
| 7 | 7 | `concurrency:` groups — and those are nearly always built from a few |
| 8 | 8 | context values: `${{ github.workflow }}-${{ github.ref }}`, |
| 9 | 9 | `${{ github.event.pull_request.number || github.ref }}`, |
| 10 | − `${{ matrix.target }}`. So this covers that and no more: | |
| 10 | + `${{ matrix.target }}` — and a `strategy.matrix` computed by an earlier | |
| 11 | + job, `${{ fromJSON(needs.setup.outputs.matrix) }}`. So this covers that | |
| 12 | + and no more: | |
| 11 | 13 | |
| 12 | 14 | * property paths into `contexts` — `github.ref`, `inputs['env']`, |
| 13 | 15 | `needs.build.outputs.x` — case-insensitively, as GitHub does; a path |
@@ -15,7 +17,7 @@ defmodule GitGud.Workflows.Expr do
| 15 | 17 | * `'string'` (`''` escapes a quote), number, `true`, `false`, `null` |
| 16 | 18 | * `||`, `&&` (returning an operand, not a boolean), `==`, `!=`, `!`, |
| 17 | 19 | parentheses |
| 18 | − * `format('{0}-{1}', …)` | |
| 20 | + * `format('{0}-{1}', …)`, `fromJSON(string)`, `toJSON(value)` | |
| 19 | 21 | |
| 20 | 22 | Anything else — another function, a comparison, `*` filters, a context |
| 21 | 23 | that wasn't supplied — is `:error`, and the caller acts as though the |
@@ -59,6 +61,28 @@ defmodule GitGud.Workflows.Expr do
| 59 | 61 | :error -> :error |
| 60 | 62 | end |
| 61 | 63 | |
| 64 | + @doc """ | |
| 65 | + The value of a string that is exactly one `${{ … }}` — not stringified, | |
| 66 | + so `${{ fromJSON('[1, 2]') }}` is a list — else `interpolate/2`'s | |
| 67 | + string. What a workflow field that may hold an expression means. | |
| 68 | + """ | |
| 69 | + @spec resolve(String.t(), map()) :: {:ok, value()} | :error | |
| 70 | + def resolve(string, contexts) when is_binary(string) do | |
| 71 | + with "${{" <> after_open <- String.trim(string), | |
| 72 | + {expression, rest} <- take_whole_expression(after_open), | |
| 73 | + "" <- String.trim(rest) do | |
| 74 | + evaluate(expression, contexts) | |
| 75 | + else | |
| 76 | + _ -> interpolate(string, contexts) | |
| 77 | + end | |
| 78 | + end | |
| 79 | + | |
| 80 | + defp take_whole_expression(after_open) do | |
| 81 | + take_expression(after_open, []) | |
| 82 | + catch | |
| 83 | + :error -> :error | |
| 84 | + end | |
| 85 | + | |
| 62 | 86 | @doc "GitHub's truthiness: `false`, `0`, `\"\"` and `null` are false." |
| 63 | 87 | def truthy?(value), do: value not in [false, nil, 0, +0.0, -0.0, ""] |
| 64 | 88 |
@@ -280,6 +304,15 @@ defmodule GitGud.Workflows.Expr do
| 280 | 304 | defp call("format", [template | args]) when is_binary(template), |
| 281 | 305 | do: format(template, Enum.map(args, &stringify/1), []) |
| 282 | 306 | |
| 307 | + defp call("fromjson", [json]) when is_binary(json) do | |
| 308 | + case Jason.decode(json) do | |
| 309 | + {:ok, value} -> value | |
| 310 | + {:error, _} -> throw(:error) | |
| 311 | + end | |
| 312 | + end | |
| 313 | + | |
| 314 | + defp call("tojson", [value]), do: Jason.encode!(value, pretty: true) | |
| 315 | + | |
| 283 | 316 | defp call(_function, _args), do: throw(:error) |
| 284 | 317 | |
| 285 | 318 | defp format("", _args, acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary() |
modified
lib/git_gud/workflows/matrix.ex
+172
−35
@@ -40,52 +40,106 @@ defmodule GitGud.Workflows.Matrix do
| 40 | 40 | know beats an arbitrary choice. |
| 41 | 41 | """ |
| 42 | 42 | |
| 43 | + alias GitGud.Workflows.Expr | |
| 44 | + | |
| 43 | 45 | @reserved ~w(include exclude) |
| 44 | 46 | |
| 45 | 47 | @typedoc "One combination: matrix key to the value this leg was expanded for." |
| 46 | 48 | @type combination :: %{String.t() => term()} |
| 47 | 49 | |
| 48 | 50 | @doc """ |
| 49 | − Every combination `strategy` expands to. | |
| 51 | + Every combination `strategy` expands to, as `{:ok, combinations}`. | |
| 52 | + | |
| 53 | + Returns `{:ok, [%{}]}` — a single empty combination — when there is no | |
| 54 | + matrix (or one with no axes and no `include`), so callers can treat | |
| 55 | + "no matrix" as "one leg with nothing in it" rather than branching. | |
| 50 | 56 | |
| 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. | |
| 57 | + Expressions are resolved first, against `contexts` (`GitGud.Workflows.Expr`): | |
| 58 | + the whole matrix (`matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}`, | |
| 59 | + which must yield an object), an axis or an `include` / `exclude` | |
| 60 | + (which must yield an array), an entry of those (an object), or a value. | |
| 61 | + | |
| 62 | + `{:error, reason}` when an expression can't be evaluated or yields the | |
| 63 | + wrong shape, or — as on GitHub, where it fails the workflow — when | |
| 64 | + `exclude` leaves nothing (`:empty_matrix`). `describe_error/1` words it. | |
| 54 | 65 | """ |
| 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 | |
| 66 | + @spec expand(map() | nil, map()) :: {:ok, [combination()]} | {:error, term()} | |
| 67 | + def expand(strategy, contexts \\ %{}) | |
| 68 | + | |
| 69 | + def expand(%{"matrix" => matrix}, contexts) when is_binary(matrix) or is_map(matrix) do | |
| 70 | + with {:ok, matrix} <- resolve_matrix(matrix, contexts), | |
| 71 | + {:ok, axes} <- resolve_axes(matrix, contexts), | |
| 72 | + {:ok, exclusions} <- resolve_entries(matrix, "exclude", contexts), | |
| 73 | + {:ok, inclusions} <- resolve_entries(matrix, "include", contexts) do | |
| 74 | + if axes == [] and inclusions == [] do | |
| 75 | + {:ok, [%{}]} | |
| 76 | + else | |
| 77 | + axes | |
| 78 | + |> product() | |
| 79 | + |> exclude(exclusions) | |
| 80 | + |> include(inclusions) | |
| 81 | + |> case do | |
| 82 | + [] -> {:error, :empty_matrix} | |
| 83 | + combinations -> {:ok, combinations} | |
| 84 | + end | |
| 85 | + end | |
| 74 | 86 | end |
| 75 | 87 | end |
| 76 | 88 | |
| 77 | − def expand(_strategy), do: [%{}] | |
| 89 | + def expand(_strategy, _contexts), do: {:ok, [%{}]} | |
| 90 | + | |
| 91 | + @doc "The log line for an `expand/2` error." | |
| 92 | + @spec describe_error(term()) :: String.t() | |
| 93 | + def describe_error(:empty_matrix), | |
| 94 | + do: "The matrix has no combinations: exclude removed every one, or an axis is empty." | |
| 95 | + | |
| 96 | + def describe_error({:expression, text}), | |
| 97 | + do: "The matrix expression #{text} could not be evaluated." | |
| 98 | + | |
| 99 | + def describe_error({:not_object, what}), do: "The matrix #{what} is not an object." | |
| 100 | + def describe_error({:not_list, what}), do: "The matrix #{what} is not an array." | |
| 78 | 101 | |
| 79 | 102 | @doc """ |
| 80 | 103 | Whether `strategy` asks for a matrix at all. |
| 81 | 104 | |
| 82 | 105 | 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. | |
| 106 | + job should not be renamed as though it were one leg of something. A | |
| 107 | + matrix that is an expression is one, whatever it turns out to hold. | |
| 84 | 108 | """ |
| 85 | 109 | @spec matrix?(map() | nil) :: boolean() |
| 86 | 110 | def matrix?(%{"matrix" => %{} = matrix}), do: matrix != %{} |
| 111 | + def matrix?(%{"matrix" => matrix}) when is_binary(matrix), do: true | |
| 87 | 112 | def matrix?(_), do: false |
| 88 | 113 | |
| 114 | + @doc """ | |
| 115 | + Whether the matrix holds an expression anywhere — so it can only be | |
| 116 | + expanded once its contexts exist (`needs`, when it reads them). | |
| 117 | + """ | |
| 118 | + @spec dynamic?(map() | nil) :: boolean() | |
| 119 | + def dynamic?(%{"matrix" => matrix}), do: expression?(matrix) | |
| 120 | + def dynamic?(_), do: false | |
| 121 | + | |
| 122 | + defp expression?(value) when is_binary(value), do: String.contains?(value, "${{") | |
| 123 | + defp expression?(%{} = map), do: Enum.any?(map, fn {_key, value} -> expression?(value) end) | |
| 124 | + defp expression?(list) when is_list(list), do: Enum.any?(list, &expression?/1) | |
| 125 | + defp expression?(_value), do: false | |
| 126 | + | |
| 127 | + @doc """ | |
| 128 | + `strategy.max-parallel`: how many legs of the job may run at once, or | |
| 129 | + nil for no limit. A positive integer (or its digits as a string, which | |
| 130 | + is how a quoted YAML value arrives); anything else, an expression | |
| 131 | + included, is no limit. Claiming reads the same rule in SQL | |
| 132 | + (`Workflows.do_claim_next_job/3`). | |
| 133 | + """ | |
| 134 | + @spec max_parallel(map() | nil) :: pos_integer() | nil | |
| 135 | + def max_parallel(%{"max-parallel" => n}) when is_integer(n) and n > 0, do: n | |
| 136 | + | |
| 137 | + def max_parallel(%{"max-parallel" => n}) when is_binary(n) do | |
| 138 | + if Regex.match?(~r/\A[1-9][0-9]{0,8}\z/, n), do: String.to_integer(n) | |
| 139 | + end | |
| 140 | + | |
| 141 | + def max_parallel(_strategy), do: nil | |
| 142 | + | |
| 89 | 143 | @doc """ |
| 90 | 144 | Whether a failing leg should cancel its siblings. GitHub's default is |
| 91 | 145 | `true`, and so is ours. |
@@ -169,21 +223,106 @@ defmodule GitGud.Workflows.Matrix do
| 169 | 223 | pinned = Map.new(combination, fn {key, value} -> {key, [value]} end) |
| 170 | 224 | strategy = Map.get(definition, "strategy") || %{} |
| 171 | 225 | |
| 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. | |
| 226 | + # `fail-fast` and `max-parallel` ride along even though they mean | |
| 227 | + # nothing to a one-value matrix: the forge reads them back off the | |
| 228 | + # stored definition — to decide whether a failing leg cancels its | |
| 229 | + # siblings, and how many legs may be claimed at once — and the | |
| 230 | + # pinned strategy is the only copy that survives. | |
| 176 | 231 | kept = |
| 177 | − case Map.fetch(strategy, "fail-fast") do | |
| 178 | − {:ok, value} -> %{"matrix" => pinned, "fail-fast" => value} | |
| 179 | − :error -> %{"matrix" => pinned} | |
| 180 | − end | |
| 232 | + strategy | |
| 233 | + |> Map.take(["fail-fast", "max-parallel"]) | |
| 234 | + |> Map.put("matrix", pinned) | |
| 181 | 235 | |
| 182 | 236 | Map.put(definition, "strategy", kept) |
| 183 | 237 | end |
| 184 | 238 | |
| 185 | 239 | # ── expansion internals ───────────────────────────────────────────── |
| 186 | 240 | |
| 241 | + # The matrix itself as a map: an expression must yield one. | |
| 242 | + defp resolve_matrix(matrix, contexts) when is_binary(matrix) do | |
| 243 | + case evaluate(matrix, contexts) do | |
| 244 | + {:ok, %{} = resolved} -> {:ok, resolved} | |
| 245 | + {:ok, _other} -> {:error, {:not_object, matrix}} | |
| 246 | + error -> error | |
| 247 | + end | |
| 248 | + end | |
| 249 | + | |
| 250 | + defp resolve_matrix(matrix, _contexts), do: {:ok, matrix} | |
| 251 | + | |
| 252 | + # `[{key, values}]`, sorted. An axis that is an expression must yield | |
| 253 | + # an array; a literal scalar is a one-value axis, as it always was. | |
| 254 | + defp resolve_axes(matrix, contexts) do | |
| 255 | + matrix | |
| 256 | + |> Map.drop(@reserved) | |
| 257 | + # Map iteration order is undefined; sort so a given workflow always | |
| 258 | + # expands the same way rather than shuffling between deploys. | |
| 259 | + |> Enum.sort_by(&elem(&1, 0)) | |
| 260 | + |> map_ok(fn {key, values} -> | |
| 261 | + case resolve_value(values, contexts) do | |
| 262 | + {:ok, list} when is_list(list) -> {:ok, {key, list}} | |
| 263 | + {:ok, _other} when is_binary(values) -> not_list(values, key) | |
| 264 | + {:ok, other} -> {:ok, {key, List.wrap(other)}} | |
| 265 | + error -> error | |
| 266 | + end | |
| 267 | + end) | |
| 268 | + end | |
| 269 | + | |
| 270 | + defp not_list(values, key) do | |
| 271 | + if expression?(values), | |
| 272 | + do: {:error, {:not_list, "axis #{key}"}}, | |
| 273 | + else: {:ok, {key, [values]}} | |
| 274 | + end | |
| 275 | + | |
| 276 | + # `include` / `exclude`: a list of objects, each of which (or the whole | |
| 277 | + # list) may be an expression. | |
| 278 | + defp resolve_entries(matrix, key, contexts) do | |
| 279 | + with {:ok, entries} <- resolve_value(Map.get(matrix, key), contexts) do | |
| 280 | + entries | |
| 281 | + |> List.wrap() | |
| 282 | + |> map_ok(fn | |
| 283 | + %{} = entry -> {:ok, Map.new(entry, fn {k, v} -> {to_string(k), v} end)} | |
| 284 | + _other -> {:error, {:not_object, "#{key} entry"}} | |
| 285 | + end) | |
| 286 | + end | |
| 287 | + end | |
| 288 | + | |
| 289 | + defp resolve_value(value, contexts) when is_binary(value) do | |
| 290 | + if String.contains?(value, "${{"), do: evaluate(value, contexts), else: {:ok, value} | |
| 291 | + end | |
| 292 | + | |
| 293 | + defp resolve_value(%{} = map, contexts) do | |
| 294 | + with {:ok, pairs} <- | |
| 295 | + map_ok(map, fn {key, value} -> | |
| 296 | + with {:ok, value} <- resolve_value(value, contexts), do: {:ok, {key, value}} | |
| 297 | + end), | |
| 298 | + do: {:ok, Map.new(pairs)} | |
| 299 | + end | |
| 300 | + | |
| 301 | + defp resolve_value(list, contexts) when is_list(list), | |
| 302 | + do: map_ok(list, &resolve_value(&1, contexts)) | |
| 303 | + | |
| 304 | + defp resolve_value(value, _contexts), do: {:ok, value} | |
| 305 | + | |
| 306 | + defp evaluate(text, contexts) do | |
| 307 | + case Expr.resolve(text, contexts) do | |
| 308 | + {:ok, value} -> {:ok, value} | |
| 309 | + :error -> {:error, {:expression, text}} | |
| 310 | + end | |
| 311 | + end | |
| 312 | + | |
| 313 | + defp map_ok(enumerable, fun) do | |
| 314 | + Enum.reduce_while(enumerable, {:ok, []}, fn item, {:ok, acc} -> | |
| 315 | + case fun.(item) do | |
| 316 | + {:ok, value} -> {:cont, {:ok, [value | acc]}} | |
| 317 | + error -> {:halt, error} | |
| 318 | + end | |
| 319 | + end) | |
| 320 | + |> case do | |
| 321 | + {:ok, acc} -> {:ok, Enum.reverse(acc)} | |
| 322 | + error -> error | |
| 323 | + end | |
| 324 | + end | |
| 325 | + | |
| 187 | 326 | # Cartesian product with the last axis varying fastest, matching the |
| 188 | 327 | # order Actions lists a matrix's jobs in. |
| 189 | 328 | defp product([]), do: [] |
@@ -206,8 +345,6 @@ defmodule GitGud.Workflows.Matrix do
| 206 | 345 | |
| 207 | 346 | defp include(combinations, inclusions) do |
| 208 | 347 | Enum.reduce(inclusions, combinations, fn entry, acc -> |
| 209 | − entry = Map.new(entry, fn {k, v} -> {to_string(k), v} end) | |
| 210 | − | |
| 211 | 348 | merged = Enum.map(acc, &merge_if_compatible(&1, entry)) |
| 212 | 349 | |
| 213 | 350 | # Nothing took it — it describes a combination of its own. |
modified
lib/git_gud_web/live/workflow_live/show.ex
+8
−3
@@ -62,10 +62,14 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 62 | 62 | |
| 63 | 63 | defp admin?(_, _), do: false |
| 64 | 64 | |
| 65 | + # Also for jobs that appear later — the legs a matrix expands into once | |
| 66 | + # its `needs` finish — so their logs have a stream to land in. | |
| 65 | 67 | defp seed_step_streams(socket, run) do |
| 66 | 68 | Enum.reduce(run.jobs, socket, fn job, acc -> |
| 67 | 69 | Enum.reduce(job.steps, acc, fn step, acc2 -> |
| 68 | − stream(acc2, stream_name(step.id), [], dom_id: &"log-#{&1.seq}") | |
| 70 | + if Map.has_key?(Map.get(acc2.assigns, :streams, %{}), stream_name(step.id)), | |
| 71 | + do: acc2, | |
| 72 | + else: stream(acc2, stream_name(step.id), [], dom_id: &"log-#{&1.seq}") | |
| 69 | 73 | end) |
| 70 | 74 | end) |
| 71 | 75 | end |
@@ -119,6 +123,7 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 119 | 123 | {:noreply, |
| 120 | 124 | socket |
| 121 | 125 | |> assign(:run, run) |
| 126 | + |> seed_step_streams(run) | |
| 122 | 127 | |> assign(:artifacts, Workflows.list_artifacts(run)) |
| 123 | 128 | |> ensure_ticking()} |
| 124 | 129 | end |
@@ -406,8 +411,8 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 406 | 411 | <span class="font-mono">{a.name}</span> |
| 407 | 412 | <span class="text-xs opacity-60"> |
| 408 | 413 | {format_size(a.size)} |
| 409 | − <span :if={a.content_type}> · {a.content_type}</span> | |
| 410 | − <span :if={a.inserted_at}> · {format_dt(a.inserted_at)}</span> | |
| 414 | + <span :if={a.content_type}> · {a.content_type}</span> | |
| 415 | + <span :if={a.inserted_at}> · {format_dt(a.inserted_at)}</span> | |
| 411 | 416 | </span> |
| 412 | 417 | </div> |
| 413 | 418 | <a |
modified
test/git_gud/workflows/expr_test.exs
+30
−1
@@ -94,7 +94,7 @@ defmodule GitGud.Workflows.ExprTest do
| 94 | 94 | |
| 95 | 95 | describe "what isn't supported is an error, not a guess" do |
| 96 | 96 | test "other functions" do |
| 97 | − assert i("${{ toJSON(github) }}") == :error | |
| 97 | + assert i("${{ startsWith(github.ref, 'refs/') }}") == :error | |
| 98 | 98 | assert i("${{ contains(github.ref, 'main') }}") == :error |
| 99 | 99 | end |
| 100 | 100 |
@@ -113,4 +113,33 @@ defmodule GitGud.Workflows.ExprTest do
| 113 | 113 | assert i("${{ github.event }}") == :error |
| 114 | 114 | end |
| 115 | 115 | end |
| 116 | + | |
| 117 | + describe "a matrix computed by an earlier job" do | |
| 118 | + defp r(string, contexts \\ @contexts), do: Expr.resolve(string, contexts) | |
| 119 | + | |
| 120 | + test "fromJSON yields the value, not its text" do | |
| 121 | + contexts = put_in(@contexts, ["needs", "build", "outputs", "m"], ~s({"os":["a","b"]})) | |
| 122 | + | |
| 123 | + assert r("${{ fromJSON(needs.build.outputs.m) }}", contexts) == | |
| 124 | + {:ok, %{"os" => ["a", "b"]}} | |
| 125 | + | |
| 126 | + assert r(" ${{ fromJSON('[1, 2]') }} ") == {:ok, [1, 2]} | |
| 127 | + end | |
| 128 | + | |
| 129 | + test "bad JSON is an error" do | |
| 130 | + assert r("${{ fromJSON('nope') }}") == :error | |
| 131 | + assert r("${{ fromJSON(needs.build.outputs.missing) }}") == :error | |
| 132 | + end | |
| 133 | + | |
| 134 | + test "toJSON is the value's JSON" do | |
| 135 | + assert {:ok, json} = r("${{ toJSON(matrix) }}") | |
| 136 | + assert Jason.decode!(json) == %{"target" => "arm64", "n" => 2} | |
| 137 | + end | |
| 138 | + | |
| 139 | + test "anything but one whole expression is a string" do | |
| 140 | + assert r("${{ matrix.target }}-${{ matrix.n }}") == {:ok, "arm64-2"} | |
| 141 | + assert r("plain") == {:ok, "plain"} | |
| 142 | + assert r("${{ format('{{0}}') }}") == {:ok, "{0}"} | |
| 143 | + end | |
| 144 | + end | |
| 116 | 145 | end |
modified
test/git_gud/workflows/matrix_dispatch_test.exs
+220
−0
@@ -507,4 +507,224 @@ defmodule GitGud.Workflows.MatrixDispatchTest do
| 507 | 507 | assert Repo.get!(WorkflowRun, run.id).status == "success" |
| 508 | 508 | end |
| 509 | 509 | end |
| 510 | + | |
| 511 | + describe "a matrix computed by an earlier job" do | |
| 512 | + # The failures log a warning, as they should. | |
| 513 | + @describetag :capture_log | |
| 514 | + | |
| 515 | + setup %{repo: repo} do | |
| 516 | + run = | |
| 517 | + run_workflow(repo, """ | |
| 518 | + name: ci | |
| 519 | + on: push | |
| 520 | + jobs: | |
| 521 | + setup: | |
| 522 | + runs-on: self-hosted | |
| 523 | + steps: | |
| 524 | + - run: true | |
| 525 | + build: | |
| 526 | + needs: setup | |
| 527 | + strategy: | |
| 528 | + matrix: ${{ fromJSON(needs.setup.outputs.m) }} | |
| 529 | + runs-on: self-hosted | |
| 530 | + steps: | |
| 531 | + - run: echo ${{ matrix.os }} | |
| 532 | + deploy: | |
| 533 | + needs: build | |
| 534 | + runs-on: self-hosted | |
| 535 | + steps: | |
| 536 | + - run: true | |
| 537 | + """) | |
| 538 | + | |
| 539 | + {:ok, run: run} | |
| 540 | + end | |
| 541 | + | |
| 542 | + defp finish_setup(run, outputs) do | |
| 543 | + setup = Enum.find(jobs(run), &(&1.job_id == "setup")) | |
| 544 | + {:ok, setup} = Workflows.set_outputs(setup, outputs) | |
| 545 | + {:ok, _} = Workflows.update_job_status(setup, "success") | |
| 546 | + end | |
| 547 | + | |
| 548 | + test "waits as one placeholder, then becomes a leg per combination", %{run: run} do | |
| 549 | + assert [placeholder] = Enum.filter(jobs(run), &(&1.job_id == "build")) | |
| 550 | + assert placeholder.name == "build" | |
| 551 | + assert placeholder.status == "blocked" | |
| 552 | + assert placeholder.matrix == nil | |
| 553 | + | |
| 554 | + finish_setup(run, %{"m" => ~s({"os":["a","b"]})}) | |
| 555 | + | |
| 556 | + refute Repo.get(WorkflowJob, placeholder.id) | |
| 557 | + | |
| 558 | + legs = Enum.filter(jobs(run), &(&1.job_id == "build")) | |
| 559 | + assert Enum.map(legs, & &1.matrix) == [%{"os" => "a"}, %{"os" => "b"}] | |
| 560 | + assert Enum.map(legs, & &1.name) == ["build (a)", "build (b)"] | |
| 561 | + assert Enum.all?(legs, &(&1.status == "queued")) | |
| 562 | + assert Enum.all?(legs, &(&1.needs_snapshot["setup"]["status"] == "success")) | |
| 563 | + | |
| 564 | + assert Enum.all?( | |
| 565 | + legs, | |
| 566 | + &(&1.definition["strategy"]["matrix"] == %{"os" => [&1.matrix["os"]]}) | |
| 567 | + ) | |
| 568 | + | |
| 569 | + assert Enum.all?(legs, &(length(Repo.preload(&1, :steps).steps) == 1)) | |
| 570 | + | |
| 571 | + # The placeholder's pending status went with it; each leg has its own. | |
| 572 | + contexts = | |
| 573 | + run.repository_id | |
| 574 | + |> then( | |
| 575 | + &Workflows.list_commit_statuses(%GitGud.Repositories.Repository{id: &1}, run.head_sha) | |
| 576 | + ) | |
| 577 | + |> Enum.map(& &1.context) | |
| 578 | + | |
| 579 | + refute "ci / build (push)" in contexts | |
| 580 | + assert "ci / build (a) (push)" in contexts | |
| 581 | + end | |
| 582 | + | |
| 583 | + test "a job needing it waits for every leg", %{run: run} do | |
| 584 | + finish_setup(run, %{"m" => ~s({"os":["a","b"]})}) | |
| 585 | + | |
| 586 | + [a, b] = Enum.filter(jobs(run), &(&1.job_id == "build")) | |
| 587 | + deploy = Enum.find(jobs(run), &(&1.job_id == "deploy")) | |
| 588 | + | |
| 589 | + {:ok, _} = Workflows.update_job_status(a, "success") | |
| 590 | + assert Repo.get!(WorkflowJob, deploy.id).status == "blocked" | |
| 591 | + | |
| 592 | + {:ok, _} = Workflows.update_job_status(b, "success") | |
| 593 | + assert Repo.get!(WorkflowJob, deploy.id).status == "queued" | |
| 594 | + end | |
| 595 | + | |
| 596 | + test "an output that isn't a matrix fails the job, saying why", %{run: run} do | |
| 597 | + finish_setup(run, %{"m" => "nope"}) | |
| 598 | + | |
| 599 | + [build] = Enum.filter(jobs(run), &(&1.job_id == "build")) | |
| 600 | + assert build.status == "failure" | |
| 601 | + assert build.status_reason =~ "could not be evaluated" | |
| 602 | + | |
| 603 | + [step | _] = | |
| 604 | + build |> Repo.preload(:steps) |> Map.fetch!(:steps) |> Enum.sort_by(& &1.number) | |
| 605 | + | |
| 606 | + assert [%{body: "Error: " <> _}] = Workflows.list_step_logs(step) | |
| 607 | + | |
| 608 | + assert Enum.find(jobs(run), &(&1.job_id == "deploy")).status == "skipped" | |
| 609 | + assert Repo.get!(WorkflowRun, run.id).status == "failure" | |
| 610 | + end | |
| 611 | + | |
| 612 | + test "re-running the failed job expands it again", %{run: run} do | |
| 613 | + finish_setup(run, %{"m" => "nope"}) | |
| 614 | + | |
| 615 | + setup = Enum.find(jobs(run), &(&1.job_id == "setup")) | |
| 616 | + {:ok, _} = Workflows.set_outputs(setup, %{"m" => ~s({"os":["a"]})}) | |
| 617 | + {:ok, _} = Workflows.rerun_job(Repo.get!(WorkflowRun, run.id), "build") | |
| 618 | + | |
| 619 | + assert [%{matrix: %{"os" => "a"}, status: "queued"}] = | |
| 620 | + Enum.filter(jobs(run), &(&1.job_id == "build")) | |
| 621 | + | |
| 622 | + assert Repo.get!(WorkflowRun, run.id).status == "running" | |
| 623 | + end | |
| 624 | + end | |
| 625 | + | |
| 626 | + describe "a matrix computed without needs" do | |
| 627 | + test "is expanded as the run is created", %{repo: repo} do | |
| 628 | + run = | |
| 629 | + run_workflow(repo, """ | |
| 630 | + name: ci | |
| 631 | + on: push | |
| 632 | + jobs: | |
| 633 | + build: | |
| 634 | + strategy: | |
| 635 | + matrix: | |
| 636 | + os: ${{ fromJSON('["a", "b"]') }} | |
| 637 | + ref: ["${{ github.ref_name }}"] | |
| 638 | + runs-on: self-hosted | |
| 639 | + steps: | |
| 640 | + - run: true | |
| 641 | + """) | |
| 642 | + | |
| 643 | + assert Enum.map(jobs(run), & &1.matrix) == [ | |
| 644 | + %{"os" => "a", "ref" => "main"}, | |
| 645 | + %{"os" => "b", "ref" => "main"} | |
| 646 | + ] | |
| 647 | + end | |
| 648 | + | |
| 649 | + @tag :capture_log | |
| 650 | + test "one that excludes everything fails the run", %{repo: repo} do | |
| 651 | + run = | |
| 652 | + run_workflow(repo, """ | |
| 653 | + name: ci | |
| 654 | + on: push | |
| 655 | + jobs: | |
| 656 | + build: | |
| 657 | + strategy: | |
| 658 | + matrix: | |
| 659 | + os: [a] | |
| 660 | + exclude: | |
| 661 | + - os: a | |
| 662 | + runs-on: self-hosted | |
| 663 | + steps: | |
| 664 | + - run: true | |
| 665 | + """) | |
| 666 | + | |
| 667 | + assert [%{status: "failure", status_reason: reason}] = jobs(run) | |
| 668 | + assert reason =~ "no combinations" | |
| 669 | + assert Repo.get!(WorkflowRun, run.id).status == "failure" | |
| 670 | + end | |
| 671 | + end | |
| 672 | + | |
| 673 | + describe "max-parallel" do | |
| 674 | + test "caps the legs of a job running at once", %{repo: repo} do | |
| 675 | + run = | |
| 676 | + run_workflow(repo, """ | |
| 677 | + name: ci | |
| 678 | + on: push | |
| 679 | + jobs: | |
| 680 | + deploy: | |
| 681 | + strategy: | |
| 682 | + max-parallel: 1 | |
| 683 | + matrix: | |
| 684 | + env: [a, b, c] | |
| 685 | + runs-on: self-hosted | |
| 686 | + steps: | |
| 687 | + - run: true | |
| 688 | + """) | |
| 689 | + | |
| 690 | + [r1, r2, r3] = for _ <- 1..3, do: runner(repo, ["self-hosted"]) | |
| 691 | + | |
| 692 | + assert %WorkflowJob{} = first = Workflows.claim_next_job(r1) | |
| 693 | + assert Workflows.claim_next_job(r2) == nil | |
| 694 | + assert Workflows.claim_jobs_for(r3, 3) == [] | |
| 695 | + | |
| 696 | + [_, second, _] = jobs(run) | |
| 697 | + assert Workflows.claim_job_by_handle(r2, Integer.to_string(second.id)) == nil | |
| 698 | + | |
| 699 | + {:ok, _} = Workflows.update_job_status(first, "success") | |
| 700 | + | |
| 701 | + assert %WorkflowJob{} = Workflows.claim_next_job(r2) | |
| 702 | + assert Workflows.claim_next_job(r3) == nil | |
| 703 | + end | |
| 704 | + | |
| 705 | + test "another job's legs are still claimable meanwhile", %{repo: repo} do | |
| 706 | + run_workflow(repo, """ | |
| 707 | + name: ci | |
| 708 | + on: push | |
| 709 | + jobs: | |
| 710 | + deploy: | |
| 711 | + strategy: | |
| 712 | + max-parallel: 1 | |
| 713 | + matrix: | |
| 714 | + env: [a, b] | |
| 715 | + runs-on: self-hosted | |
| 716 | + steps: | |
| 717 | + - run: true | |
| 718 | + test: | |
| 719 | + runs-on: self-hosted | |
| 720 | + steps: | |
| 721 | + - run: true | |
| 722 | + """) | |
| 723 | + | |
| 724 | + r = runner(repo, ["self-hosted"]) | |
| 725 | + claimed = Workflows.claim_jobs_for(r, 3) | |
| 726 | + | |
| 727 | + assert claimed |> Enum.map(& &1.job_id) |> Enum.sort() == ["deploy", "test"] | |
| 728 | + end | |
| 729 | + end | |
| 510 | 730 | end |
modified
test/git_gud/workflows/matrix_test.exs
+132
−14
@@ -11,15 +11,21 @@ defmodule GitGud.Workflows.MatrixTest do
| 11 | 11 | |
| 12 | 12 | defp strategy(matrix), do: %{"matrix" => matrix} |
| 13 | 13 | |
| 14 | + defp expand!(strategy, contexts \\ %{}) do | |
| 15 | + {:ok, combinations} = Matrix.expand(strategy, contexts) | |
| 16 | + combinations | |
| 17 | + end | |
| 18 | + | |
| 14 | 19 | describe "expand/1" do |
| 15 | 20 | 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}) == [%{}] | |
| 21 | + assert Matrix.expand(nil) == {:ok, [%{}]} | |
| 22 | + assert Matrix.expand(%{}) == {:ok, [%{}]} | |
| 23 | + assert Matrix.expand(%{"fail-fast" => false}) == {:ok, [%{}]} | |
| 24 | + assert Matrix.expand(strategy(%{})) == {:ok, [%{}]} | |
| 19 | 25 | end |
| 20 | 26 | |
| 21 | 27 | test "one axis is one job per value" do |
| 22 | − assert Matrix.expand(strategy(%{"arch" => ["amd64", "arm64"]})) == [ | |
| 28 | + assert expand!(strategy(%{"arch" => ["amd64", "arm64"]})) == [ | |
| 23 | 29 | %{"arch" => "amd64"}, |
| 24 | 30 | %{"arch" => "arm64"} |
| 25 | 31 | ] |
@@ -27,7 +33,7 @@ defmodule GitGud.Workflows.MatrixTest do
| 27 | 33 | |
| 28 | 34 | test "two axes are their product" do |
| 29 | 35 | combinations = |
| 30 | − Matrix.expand(strategy(%{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"]})) | |
| 36 | + expand!(strategy(%{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"]})) | |
| 31 | 37 | |
| 32 | 38 | assert length(combinations) == 4 |
| 33 | 39 |
@@ -36,20 +42,20 @@ defmodule GitGud.Workflows.MatrixTest do
| 36 | 42 | end |
| 37 | 43 | |
| 38 | 44 | test "a scalar value is a single-value axis" do |
| 39 | − assert Matrix.expand(strategy(%{"arch" => "arm64"})) == [%{"arch" => "arm64"}] | |
| 45 | + assert expand!(strategy(%{"arch" => "arm64"})) == [%{"arch" => "arm64"}] | |
| 40 | 46 | end |
| 41 | 47 | |
| 42 | 48 | test "expansion is stable — map order must not decide job order" do |
| 43 | 49 | matrix = %{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"], "os" => ["a", "b"]} |
| 44 | 50 | |
| 45 | − assert Matrix.expand(strategy(matrix)) == Matrix.expand(strategy(matrix)) | |
| 51 | + assert expand!(strategy(matrix)) == expand!(strategy(matrix)) | |
| 46 | 52 | end |
| 47 | 53 | end |
| 48 | 54 | |
| 49 | 55 | describe "exclude" do |
| 50 | 56 | test "drops combinations matching every key it names" do |
| 51 | 57 | combinations = |
| 52 | − Matrix.expand( | |
| 58 | + expand!( | |
| 53 | 59 | strategy(%{ |
| 54 | 60 | "arch" => ["amd64", "arm64"], |
| 55 | 61 | "otp" => ["27", "28"], |
@@ -63,7 +69,7 @@ defmodule GitGud.Workflows.MatrixTest do
| 63 | 69 | |
| 64 | 70 | test "a partial exclude drops every combination it matches" do |
| 65 | 71 | combinations = |
| 66 | − Matrix.expand( | |
| 72 | + expand!( | |
| 67 | 73 | strategy(%{ |
| 68 | 74 | "arch" => ["amd64", "arm64"], |
| 69 | 75 | "otp" => ["27", "28"], |
@@ -81,7 +87,7 @@ defmodule GitGud.Workflows.MatrixTest do
| 81 | 87 | describe "include" do |
| 82 | 88 | test "adds keys to the combinations it matches rather than appending a job" do |
| 83 | 89 | combinations = |
| 84 | − Matrix.expand( | |
| 90 | + expand!( | |
| 85 | 91 | strategy(%{ |
| 86 | 92 | "arch" => ["amd64", "arm64"], |
| 87 | 93 | "include" => [%{"arch" => "arm64", "coverage" => true}] |
@@ -97,7 +103,7 @@ defmodule GitGud.Workflows.MatrixTest do
| 97 | 103 | |
| 98 | 104 | test "an entry matching nothing becomes a combination of its own" do |
| 99 | 105 | combinations = |
| 100 | − Matrix.expand( | |
| 106 | + expand!( | |
| 101 | 107 | strategy(%{ |
| 102 | 108 | "arch" => ["amd64", "arm64"], |
| 103 | 109 | "include" => [%{"arch" => "riscv64"}] |
@@ -113,7 +119,7 @@ defmodule GitGud.Workflows.MatrixTest do
| 113 | 119 | |
| 114 | 120 | test "an entry with no overlapping keys applies to every combination" do |
| 115 | 121 | combinations = |
| 116 | − Matrix.expand( | |
| 122 | + expand!( | |
| 117 | 123 | strategy(%{ |
| 118 | 124 | "arch" => ["amd64", "arm64"], |
| 119 | 125 | "include" => [%{"otp" => "28"}] |
@@ -128,7 +134,7 @@ defmodule GitGud.Workflows.MatrixTest do
| 128 | 134 | |
| 129 | 135 | test "include runs after exclude, so it can add a combination back" do |
| 130 | 136 | combinations = |
| 131 | − Matrix.expand( | |
| 137 | + expand!( | |
| 132 | 138 | strategy(%{ |
| 133 | 139 | "arch" => ["amd64", "arm64"], |
| 134 | 140 | "exclude" => [%{"arch" => "arm64"}], |
@@ -144,7 +150,7 @@ defmodule GitGud.Workflows.MatrixTest do
| 144 | 150 | |
| 145 | 151 | test "an include-only matrix is just its entries" do |
| 146 | 152 | combinations = |
| 147 | − Matrix.expand( | |
| 153 | + expand!( | |
| 148 | 154 | strategy(%{ |
| 149 | 155 | "include" => [ |
| 150 | 156 | %{"arch" => "amd64", "runner" => "big"}, |
@@ -219,6 +225,15 @@ defmodule GitGud.Workflows.MatrixTest do
| 219 | 225 | assert pinned["steps"] == definition["steps"] |
| 220 | 226 | end |
| 221 | 227 | |
| 228 | + test "keeps `max-parallel`, which claiming reads back off the leg" do | |
| 229 | + definition = %{ | |
| 230 | + "strategy" => %{"matrix" => "${{ fromJSON(needs.setup.outputs.m) }}", "max-parallel" => 1} | |
| 231 | + } | |
| 232 | + | |
| 233 | + assert Matrix.pin_definition(definition, %{"os" => "a"})["strategy"] == | |
| 234 | + %{"matrix" => %{"os" => ["a"]}, "max-parallel" => 1} | |
| 235 | + end | |
| 236 | + | |
| 222 | 237 | test "a job with no matrix loses its strategy entirely" do |
| 223 | 238 | definition = %{"strategy" => %{"fail-fast" => false}, "runs-on" => "x"} |
| 224 | 239 |
@@ -240,4 +255,107 @@ defmodule GitGud.Workflows.MatrixTest do
| 240 | 255 | refute Matrix.fail_fast?(%{"fail-fast" => "false"}) |
| 241 | 256 | end |
| 242 | 257 | end |
| 258 | + | |
| 259 | + describe "expressions" do | |
| 260 | + @needs %{"needs" => %{"setup" => %{"result" => "success", "outputs" => %{}}}} | |
| 261 | + | |
| 262 | + defp with_output(name, value), do: put_in(@needs, ["needs", "setup", "outputs", name], value) | |
| 263 | + | |
| 264 | + test "a whole matrix from an earlier job's output" do | |
| 265 | + contexts = with_output("m", ~s({"os": ["a", "b"], "include": [{"os": "b", "big": true}]})) | |
| 266 | + | |
| 267 | + assert expand!(strategy("${{ fromJSON(needs.setup.outputs.m) }}"), contexts) == [ | |
| 268 | + %{"os" => "a"}, | |
| 269 | + %{"os" => "b", "big" => true} | |
| 270 | + ] | |
| 271 | + end | |
| 272 | + | |
| 273 | + test "one axis from an expression, beside a literal one" do | |
| 274 | + contexts = with_output("targets", ~s(["x86", "arm"])) | |
| 275 | + | |
| 276 | + assert expand!( | |
| 277 | + strategy(%{ | |
| 278 | + "target" => "${{ fromJSON(needs.setup.outputs.targets) }}", | |
| 279 | + "otp" => ["28"] | |
| 280 | + }), | |
| 281 | + contexts | |
| 282 | + ) == [%{"otp" => "28", "target" => "x86"}, %{"otp" => "28", "target" => "arm"}] | |
| 283 | + end | |
| 284 | + | |
| 285 | + test "include from an expression, and an entry of one" do | |
| 286 | + contexts = with_output("extra", ~s([{"os": "c"}])) | |
| 287 | + | |
| 288 | + assert expand!( | |
| 289 | + strategy(%{ | |
| 290 | + "os" => ["a"], | |
| 291 | + "include" => "${{ fromJSON(needs.setup.outputs.extra) }}" | |
| 292 | + }), | |
| 293 | + contexts | |
| 294 | + ) == [%{"os" => "a"}, %{"os" => "c"}] | |
| 295 | + | |
| 296 | + contexts = with_output("one", ~s({"os": "d"})) | |
| 297 | + | |
| 298 | + assert expand!( | |
| 299 | + strategy(%{ | |
| 300 | + "os" => ["a"], | |
| 301 | + "include" => ["${{ fromJSON(needs.setup.outputs.one) }}"] | |
| 302 | + }), | |
| 303 | + contexts | |
| 304 | + ) == [%{"os" => "a"}, %{"os" => "d"}] | |
| 305 | + end | |
| 306 | + | |
| 307 | + test "bad JSON, or the wrong shape, is an error" do | |
| 308 | + assert {:error, {:expression, _}} = | |
| 309 | + Matrix.expand( | |
| 310 | + strategy("${{ fromJSON(needs.setup.outputs.m) }}"), | |
| 311 | + with_output("m", "nope") | |
| 312 | + ) | |
| 313 | + | |
| 314 | + assert {:error, {:not_object, _}} = | |
| 315 | + Matrix.expand( | |
| 316 | + strategy("${{ fromJSON(needs.setup.outputs.m) }}"), | |
| 317 | + with_output("m", "[1]") | |
| 318 | + ) | |
| 319 | + | |
| 320 | + assert {:error, {:not_list, "axis os"}} = | |
| 321 | + Matrix.expand( | |
| 322 | + strategy(%{"os" => "${{ fromJSON(needs.setup.outputs.m) }}"}), | |
| 323 | + with_output("m", ~s({"a": 1})) | |
| 324 | + ) | |
| 325 | + | |
| 326 | + # A context the expression reads that wasn't supplied. | |
| 327 | + assert {:error, {:expression, _}} = | |
| 328 | + Matrix.expand(strategy("${{ fromJSON(needs.setup.outputs.m) }}")) | |
| 329 | + end | |
| 330 | + | |
| 331 | + test "a matrix exclude empties is an error, as on GitHub" do | |
| 332 | + assert Matrix.expand(strategy(%{"os" => ["a"], "exclude" => [%{"os" => "a"}]})) == | |
| 333 | + {:error, :empty_matrix} | |
| 334 | + | |
| 335 | + assert Matrix.expand( | |
| 336 | + strategy("${{ fromJSON(needs.setup.outputs.m) }}"), | |
| 337 | + with_output("m", ~s({"os": []})) | |
| 338 | + ) == {:error, :empty_matrix} | |
| 339 | + | |
| 340 | + assert Matrix.describe_error(:empty_matrix) =~ "no combinations" | |
| 341 | + end | |
| 342 | + | |
| 343 | + test "dynamic?/1 and matrix?/1" do | |
| 344 | + assert Matrix.dynamic?(strategy("${{ fromJSON(needs.setup.outputs.m) }}")) | |
| 345 | + assert Matrix.dynamic?(strategy(%{"os" => ["a", "${{ vars.OS }}"]})) | |
| 346 | + refute Matrix.dynamic?(strategy(%{"os" => ["a", "b"]})) | |
| 347 | + refute Matrix.dynamic?(nil) | |
| 348 | + | |
| 349 | + assert Matrix.matrix?(strategy("${{ fromJSON(needs.setup.outputs.m) }}")) | |
| 350 | + refute Matrix.matrix?(strategy(%{})) | |
| 351 | + end | |
| 352 | + | |
| 353 | + test "max_parallel/1" do | |
| 354 | + assert Matrix.max_parallel(%{"max-parallel" => 2}) == 2 | |
| 355 | + assert Matrix.max_parallel(%{"max-parallel" => "3"}) == 3 | |
| 356 | + assert Matrix.max_parallel(%{"max-parallel" => 0}) == nil | |
| 357 | + assert Matrix.max_parallel(%{"max-parallel" => "${{ vars.N }}"}) == nil | |
| 358 | + assert Matrix.max_parallel(nil) == nil | |
| 359 | + end | |
| 360 | + end | |
| 243 | 361 | end |
added
test/git_gud_web/live/workflow_run_matrix_test.exs
+80
−0
@@ -0,0 +1,80 @@
| 1 | +defmodule GitGudWeb.WorkflowRunMatrixTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The run page while a matrix computed by an earlier job expands: the | |
| 4 | + placeholder row goes, its legs arrive, and their logs (whose streams | |
| 5 | + didn't exist when the page mounted) still render. | |
| 6 | + """ | |
| 7 | + use GitGudWeb.ConnCase, async: false | |
| 8 | + | |
| 9 | + import Phoenix.LiveViewTest | |
| 10 | + import GitGud.ForgeFixtures | |
| 11 | + import Ecto.Query | |
| 12 | + | |
| 13 | + alias GitGud.Repo | |
| 14 | + alias GitGud.Repositories.Storage | |
| 15 | + alias GitGud.Workflows | |
| 16 | + alias GitGud.Workflows.WorkflowJob | |
| 17 | + alias GitGud.Workflows.WorkflowRun | |
| 18 | + alias GitGud.Workflows.Yaml | |
| 19 | + | |
| 20 | + @moduletag :capture_log | |
| 21 | + | |
| 22 | + test "legs replace the placeholder live, and stream their logs", %{conn: conn} do | |
| 23 | + {_user, repo} = repository_fixture(%{visibility: "public"}) | |
| 24 | + repo = Repo.preload(repo, [:owner, :organization]) | |
| 25 | + | |
| 26 | + {:ok, doc} = | |
| 27 | + Yaml.parse(""" | |
| 28 | + name: ci | |
| 29 | + on: push | |
| 30 | + jobs: | |
| 31 | + setup: | |
| 32 | + runs-on: self-hosted | |
| 33 | + steps: | |
| 34 | + - run: true | |
| 35 | + build: | |
| 36 | + needs: setup | |
| 37 | + strategy: | |
| 38 | + matrix: ${{ fromJSON(needs.setup.outputs.m) }} | |
| 39 | + runs-on: self-hosted | |
| 40 | + steps: | |
| 41 | + - run: true | |
| 42 | + """) | |
| 43 | + | |
| 44 | + {:ok, run} = | |
| 45 | + %WorkflowRun{repository_id: repo.id} | |
| 46 | + |> WorkflowRun.create_changeset(%{ | |
| 47 | + "number" => 1, | |
| 48 | + "workflow_path" => ".forgejo/workflows/ci.yml", | |
| 49 | + "workflow_name" => "ci", | |
| 50 | + "trigger" => "push", | |
| 51 | + "head_ref" => "refs/heads/main", | |
| 52 | + "head_sha" => :crypto.strong_rand_bytes(20), | |
| 53 | + "workflow_doc" => doc.raw | |
| 54 | + }) | |
| 55 | + |> Repo.insert!() | |
| 56 | + |> Workflows.rerun() | |
| 57 | + | |
| 58 | + jobs = fn -> Repo.all(from j in WorkflowJob, where: j.workflow_run_id == ^run.id) end | |
| 59 | + [placeholder] = Enum.filter(jobs.(), &(&1.job_id == "build")) | |
| 60 | + setup = Enum.find(jobs.(), &(&1.job_id == "setup")) | |
| 61 | + | |
| 62 | + {:ok, lv, _html} = | |
| 63 | + live(conn, "/r/#{Storage.repo_handle(repo)}/#{repo.name}/actions/#{run.number}") | |
| 64 | + | |
| 65 | + assert has_element?(lv, "#job-#{placeholder.id}") | |
| 66 | + | |
| 67 | + {:ok, setup} = Workflows.set_outputs(setup, %{"m" => ~s({"os":["a","b"]})}) | |
| 68 | + {:ok, _} = Workflows.update_job_status(setup, "success") | |
| 69 | + | |
| 70 | + [leg | _] = Enum.filter(jobs.(), &(&1.job_id == "build")) | |
| 71 | + [step] = leg |> Repo.preload(:steps) |> Map.fetch!(:steps) | |
| 72 | + :ok = Workflows.append_log(step, "hello from a leg") | |
| 73 | + | |
| 74 | + refute has_element?(lv, "#job-#{placeholder.id}") | |
| 75 | + assert has_element?(lv, "#job-#{leg.id}") | |
| 76 | + | |
| 77 | + lv |> element("button[phx-value-id='#{step.id}']") |> render_click() | |
| 78 | + assert has_element?(lv, "#step-log-#{step.id}", "hello from a leg") | |
| 79 | + end | |
| 80 | +end |
Parents: 91138c0