Actions: hand a job its needs' results and outputs, and skip instead of cancel

15e553d · Gabriel Morell · 2026-10-17 15:33

20 files +664 -116
Message
{commit_body(@commit)}

Files changed

modified README.md
+3 −1
@@ -92,6 +92,7 @@ reinvent them.
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
93 93 - **`paths:` / `paths-ignore:`** with GitHub's pattern rules (`*` stays within a directory, `**` crosses them, `?`, `[a-z]`; in `paths:`, `!pattern` un-matches and a later positive pattern re-matches). A push runs if any changed file is selected by `paths:`, or if not every changed file is matched by `paths-ignore:`; both set → `paths:` wins, with a warning. Changed files are the ref's old → new diff (renames count as both paths); a new branch is diffed from where it left the default branch. Tag pushes and manual runs ignore path filters, and when the changed files can't be worked out (no default branch to compare with, an unknown base, a git error, more than 3000 files) the filter passes — a run is never skipped because the forge couldn't look
94 94 - **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
95 + - **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
95 96 - Re-run / cancel buttons on the workflow show page
96 97 - **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
97 98 - **GitHub Actions cache server v1**, scoped to the repository. Restore order: the primary key exactly, then each fallback key as a prefix (newest first), each tried on the run's own ref, then — for a `pull_request` run — the PR's base branch, then the default branch; a save goes to the run's own ref. Upload chunks are placed at their `Content-Range` offsets (`actions/cache` sends four at once) and assembled on finalize; a finalized entry is immutable. Age/size eviction worker, which also clears uploads abandoned for a day. **Not reached by a default forgejo-runner**: with `cache.enabled` (its default) the runner starts its own cache server and points `ACTIONS_CACHE_URL` at it, ignoring the URL gitgud sends; jobs only use this one if the runner has `cache.enabled: false` and `ACTIONS_CACHE_URL` set in `runner.envs`. `actions/cache@v4`'s cache service v2 isn't implemented
@@ -365,7 +366,8 @@ else — `${{ matrix.arch }}` inside a step — is evaluated by the runner
365 366 against a matrix context pinned to that leg's single value.
366 367
367 368 `needs: build` waits for **every** leg, and a downstream job sees one
368 −`needs.build` however many machines produced it. `fail-fast` is on by
369 +`needs.build` however many machines produced it: the worst leg's
370 +result, and the legs' outputs merged. `fail-fast` is on by
369 371 default: the first leg to fail cancels its siblings that haven't
370 372 started, while one already running is left to finish rather than
371 373 killed. `max-parallel` is not implemented — every leg is claimable as
modified lib/git_gud/workflows.ex
+165 −82
@@ -1352,7 +1352,8 @@ defmodule GitGud.Workflows do
1352 1352 # steps:
1353 1353 # * job succeeded → step succeeded
1354 1354 # * step never started → skipped
1355 − # * step was in flight → inherit the job's terminal status
1355 + # * step was in flight → inherit the job's terminal status (a skipped
1356 + # job's steps are skipped)
1356 1357 defp finalize_steps(%WorkflowJob{id: jid, status: job_status}) do
1357 1358 now = DateTime.utc_now() |> DateTime.truncate(:second)
1358 1359
@@ -1365,6 +1366,7 @@ defmodule GitGud.Workflows do
1365 1366 cond do
1366 1367 job_status == "success" -> "success"
1367 1368 step.status == "pending" -> "skipped"
1369 + job_status == "skipped" -> "skipped"
1368 1370 job_status in ["failure", "cancelled"] -> job_status
1369 1371 true -> "failure"
1370 1372 end
@@ -1384,10 +1386,12 @@ defmodule GitGud.Workflows do
1384 1386 # sixteen legs is to find out whether they all pass, and once one
1385 1387 # hasn't you have your answer. Only siblings that haven't started are
1386 1388 # cancelled; a leg already running is left to finish, since killing a
1387 − # mid-flight job loses its logs for no gain.
1389 + # mid-flight job loses its logs for no gain. A leg that fails under
1390 + # `continue-on-error` doesn't count, as on GitHub: that's what marking a
1391 + # leg experimental is for.
1388 1392 defp maybe_fail_fast(%WorkflowJob{status: status} = job)
1389 1393 when status in ~w(failure cancelled) do
1390 − if job.matrix && Matrix.fail_fast?(strategy_of(job)) do
1394 + if job.matrix && Matrix.fail_fast?(strategy_of(job)) && effective_result(job) != "success" do
1391 1395 from(j in WorkflowJob,
1392 1396 where:
1393 1397 j.workflow_run_id == ^job.workflow_run_id and j.job_id == ^job.job_id and
@@ -1406,13 +1410,17 @@ defmodule GitGud.Workflows do
1406 1410
1407 1411 @doc """
1408 1412 After a job reaches a terminal state, walk every sibling job in the
1409 − same run whose `needs:` array contains this job's `job_id` and
1410 − promote them from `blocked`:
1413 + same run whose `needs:` array contains this job's `job_id` and, once
1414 + every leg of every need is terminal, decide it as GitHub does:
1411 1415
1412 − * all listed needs are now `success`/`skipped` → `queued`
1413 − * any listed need is `failure`/`cancelled` → `cancelled`
1414 − (cascading failure, matches GitHub Actions default)
1415 − * mixed-but-not-failed (still some pending needs) → leave `blocked`
1416 + * a need that doesn't exist → `cancelled` (author error)
1417 + * every need concluded `success` (`effective_result/1`) → `queued`
1418 + * otherwise, the job's `if:` calls `always()`, `failure()` or
1419 + `cancelled()` → `queued`, and the runner evaluates the `if:`
1420 + against the real `needs` results
1421 + * otherwise → `skipped`, which resolves its own dependents in turn
1422 +
1423 + While some need is still in flight the job stays `blocked`.
1416 1424
1417 1425 Public so tests can drive it directly.
1418 1426 """
@@ -1431,41 +1439,19 @@ defmodule GitGud.Workflows do
1431 1439 Enum.filter(siblings, fn j -> completed_jid in j.needs and j.status == "blocked" end)
1432 1440
1433 1441 Enum.each(dependents, fn dep ->
1434 − needs_states = Enum.map(dep.needs, fn jid -> Map.get(by_jid, jid) end)
1435 −
1436 − cond do
1437 − Enum.any?(needs_states, &(&1 in [nil, []])) ->
1438 − # A referenced need doesn't exist as a sibling — workflow
1439 − # author error. Cancel rather than block forever.
1440 − {:ok, _} = update_job_status(dep, "cancelled")
1442 + # Skipping an earlier dependent resolves its own dependents first,
1443 + # and one of those may be later in this list: re-read rather than
1444 + # decide it twice.
1445 + dep = Repo.get!(WorkflowJob, dep.id)
1441 1446
1442 − Enum.any?(needs_states, fn legs ->
1443 − Enum.any?(legs, &(&1.status in ~w(failure cancelled)))
1444 − end) ->
1447 + case dep.status == "blocked" && dependency_verdict(dep, by_jid) do
1448 + :cancel ->
1445 1449 {:ok, _} = update_job_status(dep, "cancelled")
1446 1450
1447 − Enum.all?(needs_states, fn legs ->
1448 − Enum.all?(legs, &WorkflowJob.satisfies_dependency?(&1.status))
1449 − end) ->
1450 − # Snapshot upstream results/outputs now, while they're terminal.
1451 − # build_needs/1 reads this instead of re-reading upstream status
1452 − # at fetch time (which raced and could yield RESULT_UNSPECIFIED,
1453 − # making act skip the job on `if: success()`).
1454 − #
1455 − # A matrix's legs collapse to one entry, because `needs.build`
1456 − # is one name downstream however many machines produced it:
1457 − # outputs merge, and the status is the worst of them.
1458 − snapshot =
1459 − Map.new(dep.needs, fn jid ->
1460 − legs = Map.get(by_jid, jid)
1461 −
1462 − {jid,
1463 − %{
1464 − "status" => aggregate_status(legs),
1465 − "outputs" => Enum.reduce(legs, %{}, &Map.merge(&2, &1.outputs || %{}))
1466 − }}
1467 − end)
1451 + :skip ->
1452 + {:ok, _} = update_job_status(dep, "skipped")
1468 1453
1454 + {:queue, snapshot} ->
1469 1455 {:ok, _} =
1470 1456 dep
1471 1457 |> Ecto.Changeset.change(status: "queued", needs_snapshot: snapshot)
@@ -1473,8 +1459,7 @@ defmodule GitGud.Workflows do
1473 1459
1474 1460 broadcast({:job_status, rid, Repo.get!(WorkflowJob, dep.id)})
1475 1461
1476 − true ->
1477 − # Some upstream is still pending. Leave blocked.
1462 + _blocked_or_decided ->
1478 1463 :ok
1479 1464 end
1480 1465 end)
@@ -1482,51 +1467,129 @@ defmodule GitGud.Workflows do
1482 1467 :ok
1483 1468 end
1484 1469
1485 − # `success` only when every leg succeeded; `skipped` when they all
1486 − # skipped. Anything mixed reports `success`, because reaching here
1487 − # means no leg failed and a downstream `if: success()` should run.
1470 + # What `dep`'s needs, as they stand in `by_jid`, make of it: `:blocked`
1471 + # while one is in flight, then `{:queue, snapshot}`, `:skip` or
1472 + # `:cancel` (see `resolve_dependents/1`).
1473 + #
1474 + # The snapshot is taken now, while the needs are terminal, and is what
1475 + # the runner gets as `Task.needs` (`RunnerController.build_needs/1`)
1476 + # rather than re-reading upstream status at fetch time, which raced. A
1477 + # matrix's legs collapse to one entry, because `needs.build` is one name
1478 + # downstream however many machines produced it: outputs merge, and the
1479 + # result is the worst of them.
1480 + defp dependency_verdict(%WorkflowJob{} = dep, by_jid) do
1481 + needs_states = Enum.map(dep.needs, fn jid -> Map.get(by_jid, jid) end)
1482 +
1483 + cond do
1484 + Enum.any?(needs_states, &(&1 in [nil, []])) ->
1485 + :cancel
1486 +
1487 + not Enum.all?(needs_states, fn legs ->
1488 + Enum.all?(legs, &WorkflowJob.terminal?(&1.status))
1489 + end) ->
1490 + :blocked
1491 +
1492 + true ->
1493 + snapshot =
1494 + Map.new(dep.needs, fn jid ->
1495 + legs = Map.get(by_jid, jid)
1496 +
1497 + {jid,
1498 + %{
1499 + "status" => aggregate_status(legs),
1500 + "outputs" => Enum.reduce(legs, %{}, &Map.merge(&2, &1.outputs || %{}))
1501 + }}
1502 + end)
1503 +
1504 + cond do
1505 + Enum.all?(snapshot, fn {_, need} -> need["status"] == "success" end) ->
1506 + {:queue, snapshot}
1507 +
1508 + status_function_if?(dep.definition) ->
1509 + {:queue, snapshot}
1510 +
1511 + true ->
1512 + :skip
1513 + end
1514 + end
1515 + end
1516 +
1517 + @doc """
1518 + The result a job's dependents see: `success` for a job that failed
1519 + under `continue-on-error: true`, its status otherwise. The job itself
1520 + still shows as failed.
1521 +
1522 + `continue-on-error` is honoured when it's literally true, or `"true"`
1523 + once `${{ matrix.* }}` is substituted (`continue-on-error: ${{
1524 + matrix.experimental }}`). Any other expression is the runner's to
1525 + evaluate, and can't be seen from here: it counts as false.
1526 + """
1527 + def effective_result(%WorkflowJob{status: "failure"} = job) do
1528 + case Matrix.substitute(job.definition["continue-on-error"], job.matrix || %{}) do
1529 + flag when flag in [true, "true"] -> "success"
1530 + _ -> "failure"
1531 + end
1532 + end
1533 +
1534 + def effective_result(%WorkflowJob{status: status}), do: status
1535 +
1536 + @doc """
1537 + Whether a job's `if:` calls a status-check function — `always()`,
1538 + `failure()` or `cancelled()`, negated or not. Only such a job runs
1539 + after a need failed, was cancelled or was skipped.
1540 + """
1541 + def status_function_if?(%{"if" => condition}) when is_binary(condition),
1542 + do: Regex.match?(~r/\b(always|failure|cancelled)\s*\(\s*\)/, condition)
1543 +
1544 + def status_function_if?(_definition), do: false
1545 +
1546 + # `needs.<id>.result` for a matrix: the worst leg's effective result —
1547 + # failure, then cancelled, then success; `skipped` only when every leg
1548 + # skipped.
1488 1549 defp aggregate_status(legs) do
1550 + results = Enum.map(legs, &effective_result/1)
1551 +
1489 1552 cond do
1490 − Enum.all?(legs, &(&1.status == "skipped")) -> "skipped"
1491 − Enum.all?(legs, &(&1.status == "success")) -> "success"
1492 − true -> "success"
1553 + "failure" in results -> "failure"
1554 + "cancelled" in results -> "cancelled"
1555 + "success" in results -> "success"
1556 + true -> "skipped"
1493 1557 end
1494 1558 end
1495 1559
1560 + # A job failed the run when it failed without `continue-on-error`. A
1561 + # job cancelled on its own (fail-fast, a runner that gave up) fails it
1562 + # too, as on GitHub; only a cancelled run concludes `cancelled`.
1563 + # Skipped jobs don't fail anything.
1496 1564 defp maybe_complete_run(%WorkflowJob{workflow_run_id: rid}) do
1497 − statuses =
1498 − from(j in WorkflowJob, where: j.workflow_run_id == ^rid, select: j.status)
1499 − |> Repo.all()
1565 + jobs = from(j in WorkflowJob, where: j.workflow_run_id == ^rid) |> Repo.all()
1566 + run = Repo.get!(WorkflowRun, rid)
1500 1567
1501 1568 cond do
1502 1569 # `blocked` is still pending — its upstream is in flight.
1503 − Enum.any?(statuses, &(&1 in ~w(queued running blocked))) ->
1504 − run = Repo.get!(WorkflowRun, rid)
1570 + Enum.any?(jobs, &(&1.status in ~w(queued running blocked))) ->
1505 1571 update_run_commit_status(run)
1506 1572 :ok
1507 1573
1508 − Enum.any?(statuses, &(&1 in ~w(failure cancelled))) ->
1509 − run =
1510 − Repo.get!(WorkflowRun, rid)
1511 − |> WorkflowRun.status_changeset("failure")
1512 − |> Repo.update!()
1574 + Enum.any?(jobs, &(effective_result(&1) == "failure")) ->
1575 + finish_run(run, "failure")
1513 1576
1514 − update_run_commit_status(run)
1515 − broadcast_run_status(run)
1577 + Enum.any?(jobs, &(&1.status == "cancelled")) ->
1578 + finish_run(run, if(run.status == "cancelled", do: "cancelled", else: "failure"))
1516 1579
1517 1580 true ->
1518 − run =
1519 − Repo.get!(WorkflowRun, rid)
1520 − |> WorkflowRun.status_changeset("success")
1521 − |> Repo.update!()
1522 −
1523 − update_run_commit_status(run)
1581 + run = finish_run(run, "success")
1524 1582 notify_run_webhook(run)
1525 − broadcast_run_status(run)
1526 1583 run
1527 1584 end
1528 1585 end
1529 1586
1587 + defp finish_run(%WorkflowRun{} = run, status) do
1588 + run = run |> WorkflowRun.status_changeset(status) |> Repo.update!()
1589 + update_run_commit_status(run)
1590 + broadcast_run_status(run)
1591 + end
1592 +
1530 1593 # Notify the run's LiveView that the run reached a terminal state so it
1531 1594 # can flip the status badge + show the re-run button without a reload.
1532 1595 # (The per-job broadcast races ahead of `maybe_complete_run`, so the
@@ -1705,19 +1768,12 @@ defmodule GitGud.Workflows do
1705 1768 legs ->
1706 1769 by_jid = Enum.group_by(siblings, & &1.job_id)
1707 1770
1708 − needs_ok? =
1709 − fn job ->
1710 − job.needs == [] or
1711 − Enum.all?(job.needs, fn jid ->
1712 − case Map.get(by_jid, jid) do
1713 − [_ | _] = upstream ->
1714 − Enum.all?(upstream, &WorkflowJob.satisfies_dependency?(&1.status))
1715 −
1716 − _ ->
1717 − false
1718 − end
1719 − end)
1720 − end
1771 + # Decided as `resolve_dependents/1` would decide it now, against
1772 + # the needs as they stand (and their results snapshotted afresh).
1773 + verdict = fn
1774 + %WorkflowJob{needs: []} -> {:queue, %{}}
1775 + job -> dependency_verdict(job, by_jid)
1776 + end
1721 1777
1722 1778 Repo.transaction(fn ->
1723 1779 updated =
@@ -1743,10 +1799,17 @@ defmodule GitGud.Workflows do
1743 1799 ]
1744 1800 )
1745 1801
1802 + {status, snapshot} =
1803 + case verdict.(job) do
1804 + {:queue, snapshot} -> {"queued", snapshot}
1805 + _ -> {"blocked", job.needs_snapshot}
1806 + end
1807 +
1746 1808 {:ok, row} =
1747 1809 job
1748 1810 |> Ecto.Changeset.change(
1749 − status: if(needs_ok?.(job), do: "queued", else: "blocked"),
1811 + status: status,
1812 + needs_snapshot: snapshot,
1750 1813 conclusion: nil,
1751 1814 started_at: nil,
1752 1815 completed_at: nil,
@@ -1767,6 +1830,23 @@ defmodule GitGud.Workflows do
1767 1830 |> case do
1768 1831 {:ok, updated} ->
1769 1832 Enum.each(updated, &broadcast({:job_status, run.id, &1}))
1833 +
1834 + # A job whose needs are all done but wouldn't let it run is
1835 + # skipped (or cancelled) again rather than left blocked on
1836 + # upstreams that won't finish twice, which would hold the run
1837 + # open for good.
1838 + Enum.each(updated, fn
1839 + %WorkflowJob{status: "blocked"} = row ->
1840 + case verdict.(row) do
1841 + :skip -> {:ok, _} = update_job_status(row, "skipped")
1842 + :cancel -> {:ok, _} = update_job_status(row, "cancelled")
1843 + _ -> :ok
1844 + end
1845 +
1846 + _ ->
1847 + :ok
1848 + end)
1849 +
1770 1850 broadcast_ci_change(run.id)
1771 1851 {:ok, Repo.get!(WorkflowRun, run.id)}
1772 1852
@@ -1792,9 +1872,12 @@ defmodule GitGud.Workflows do
1792 1872 Repo.transaction(fn ->
1793 1873 now = DateTime.utc_now(:second)
1794 1874
1875 + # Blocked jobs are cancelled with the rest, even one whose `if:`
1876 + # says `always()` or `cancelled()`. GitHub would still run those
1877 + # once their needs finish; here a cancelled run runs nothing more.
1795 1878 Repo.update_all(
1796 1879 from(j in WorkflowJob,
1797 − where: j.workflow_run_id == ^run.id and j.status in ["queued", "running"]
1880 + where: j.workflow_run_id == ^run.id and j.status in ["queued", "running", "blocked"]
1798 1881 ),
1799 1882 set: [status: "cancelled", conclusion: "cancelled", completed_at: now]
1800 1883 )
modified lib/git_gud/workflows/runner_payload.ex
+4 −2
@@ -38,8 +38,10 @@ defmodule GitGud.Workflows.RunnerPayload do
38 38 # `if: success()` evaluates false, silently skipping the job. That
39 39 # broke every multi-stage (test -> image) workflow. The dependency is
40 40 # already honoured out-of-band: the forge only dispatches a job once
41 − # its needs are satisfied (Workflows.resolve_dependents), and upstream
42 − # results/outputs reach the job via the Task `needs` field.
41 + # its needs are terminal (Workflows.resolve_dependents), and upstream
42 + # results/outputs reach the job via the Task `needs` field, from which
43 + # forgejo-runner rebuilds the job's `needs:` against mock jobs
44 + # (RunnerController.build_needs/1).
43 45 definition = Map.drop(job.definition, ["needs", :needs])
44 46
45 47 run
modified lib/git_gud/workflows/workflow_job.ex
+7 −15
@@ -7,13 +7,14 @@ defmodule GitGud.Workflows.WorkflowJob do
7 7 alias GitGud.Workflows.WorkflowStep
8 8
9 9 # `blocked` = waiting on upstream `needs:`. Promoted to `queued` (or
10 − # `cancelled`) by `Workflows.resolve_dependents/1` when any sibling
11 − # job completes. `claim_next_job/1` only picks `queued`, so blocked
12 − # jobs are never accidentally claimed by a runner before their
10 + # `skipped`, or `cancelled`) by `Workflows.resolve_dependents/1` once
11 + # every need is terminal. `claim_next_job/1` only picks `queued`, so
12 + # blocked jobs are never accidentally claimed by a runner before their
13 13 # prerequisites finish.
14 − # `skipped` = upstream that the GitHub-Actions `if:` evaluated false
15 − # for. Treated as success for the purpose of resolving downstream
16 − # `needs:` (matches GitHub semantics).
14 + # `skipped` = a job whose own `if:` the runner evaluated false, or one
15 + # the forge skipped because a need didn't succeed. Not a success:
16 + # dependents are skipped in turn unless their `if:` uses a status
17 + # function (GitHub semantics).
17 18 @valid_statuses ~w(queued running success failure cancelled blocked skipped)
18 19
19 20 schema "workflow_jobs" do
@@ -89,15 +90,6 @@ defmodule GitGud.Workflows.WorkflowJob do
89 90 def terminal?(s) when s in ~w(success failure cancelled skipped), do: true
90 91 def terminal?(_), do: false
91 92
92 − @doc """
93 − Did this job's terminal state count as 'satisfying' a downstream
94 − `needs:` declaration? Mirrors GitHub Actions semantics: a need is
95 − satisfied by `success` or `skipped`, never by `failure` or
96 − `cancelled` (the downstream gets cascade-cancelled instead).
97 − """
98 − def satisfies_dependency?(s) when s in ~w(success skipped), do: true
99 − def satisfies_dependency?(_), do: false
100 −
101 93 def claim_changeset(job, runner_id) do
102 94 change(job, status: "running", runner_id: runner_id, started_at: DateTime.utc_now(:second))
103 95 end
modified lib/git_gud_web/controllers/runner_controller.ex
+35 −12
@@ -41,6 +41,7 @@ defmodule GitGudWeb.RunnerController do
41 41 alias :"Elixir.Runner.V1.RegisterRequest", as: RegisterRequest
42 42 alias :"Elixir.Runner.V1.RegisterResponse", as: RegisterResponse
43 43 alias :"Elixir.Runner.V1.Task", as: TaskMsg
44 + alias :"Elixir.Runner.V1.TaskNeed", as: TaskNeed
44 45 alias :"Elixir.Runner.V1.UpdateLogRequest", as: UpdateLogRequest
45 46 alias :"Elixir.Runner.V1.UpdateLogResponse", as: UpdateLogResponse
46 47 alias :"Elixir.Runner.V1.UpdateTaskRequest", as: UpdateTaskRequest
@@ -223,8 +224,8 @@ defmodule GitGudWeb.RunnerController do
223 224 context: build_context_struct(job, run, repo, token),
224 225 secrets: secrets,
225 226 # `needs.<upstream_id>` surfaces each declared upstream's
226 − # result + outputs to this job. Empty map when the workflow
227 − # declares no `needs:`.
227 + # result + outputs to this job. Empty map when the job declares
228 + # no `needs:`.
228 229 needs: build_needs(job),
229 230 vars: vars
230 231 }
@@ -252,17 +253,38 @@ defmodule GitGudWeb.RunnerController do
252 253 Map.put(registry, "CI_FORGE_URL", GitGudWeb.Endpoint.url())
253 254 end
254 255
255 − # Always send an EMPTY needs map. forgejo-runner's generateWorkflow
256 − # injects a MOCK job (with no `runs-on`) for every Task.needs entry and
257 − # rebuilds the target job's RawNeeds from them; act then can't resolve
258 − # the runs-on-less mock and skips the job via `if: success()`. With no
259 − # entries, no mock is injected and RawNeeds is emptied, so `success()`
260 − # passes and the job runs. Dispatch is already gated on upstream success
261 − # by Workflows.resolve_dependents/1, so the dependency stays honoured.
262 − # Trade-off: `${{ needs.<id>.result/outputs }}` isn't surfaced to the
263 − # job — none of our workflows read cross-job needs outputs.
256 + # `needs.<id>.result` and `needs.<id>.outputs`, from the snapshot
257 + # `Workflows.resolve_dependents/1` took when it queued this job.
258 + # forgejo-runner's generateWorkflow turns each entry into a mock job
259 + # (no `runs-on`, no steps, `Result` preset to the enum's name
260 + # lowercased) and points the job's RawNeeds at them; act's `success()`
261 + # / `failure()` and the `needs` context then read those results. The
262 + # mocks run as no-ops ("No steps found"), so their preset result
263 + # stands. A job with no `needs:` has an empty snapshot and gets none.
264 + #
265 + # Sent empty for a while (ed9a7cd), when every mock arrived with result
266 + # `unspecified` and act skipped the job on `if: success()` — that was
267 + # TaskNeed's field numbers being swapped (fixed in c4d7080), not the
268 + # mocks.
269 + defp build_needs(%GitGud.Workflows.WorkflowJob{needs_snapshot: %{} = snap})
270 + when map_size(snap) > 0 do
271 + Map.new(snap, fn {jid, need} ->
272 + {jid,
273 + %TaskNeed{
274 + result: status_to_result_enum(need["status"]),
275 + outputs: need["outputs"] || %{}
276 + }}
277 + end)
278 + end
279 +
264 280 defp build_needs(_job), do: %{}
265 281
282 + defp status_to_result_enum("success"), do: :RESULT_SUCCESS
283 + defp status_to_result_enum("failure"), do: :RESULT_FAILURE
284 + defp status_to_result_enum("cancelled"), do: :RESULT_CANCELLED
285 + defp status_to_result_enum("skipped"), do: :RESULT_SKIPPED
286 + defp status_to_result_enum(_), do: :RESULT_UNSPECIFIED
287 +
266 288 # Forgejo's runner proto switched `Task.context` from
267 289 # `map<string,string>` to `google.protobuf.Struct` so values can carry
268 290 # nested objects, numbers, and bools. Every key is a string except
@@ -522,12 +544,13 @@ defmodule GitGudWeb.RunnerController do
522 544 defp map_result(:RESULT_SUCCESS), do: "success"
523 545 defp map_result(:RESULT_FAILURE), do: "failure"
524 546 defp map_result(:RESULT_CANCELLED), do: "cancelled"
525 − defp map_result(:RESULT_SKIPPED), do: "success"
547 + defp map_result(:RESULT_SKIPPED), do: "skipped"
526 548 defp map_result(_), do: "running"
527 549
528 550 defp result_atom("success"), do: :RESULT_SUCCESS
529 551 defp result_atom("failure"), do: :RESULT_FAILURE
530 552 defp result_atom("cancelled"), do: :RESULT_CANCELLED
553 + defp result_atom("skipped"), do: :RESULT_SKIPPED
531 554 defp result_atom(_), do: :RESULT_UNSPECIFIED
532 555
533 556 # ── update_log ───────────────────────────────────────────────────────
added test/git_gud/workflows/job_graph_test.exs
+371 −0
@@ -0,0 +1,371 @@
1 +defmodule GitGud.Workflows.JobGraphTest do
2 + @moduledoc """
3 + What a job's `needs:` make of it once they finish, and what the run
4 + concludes — GitHub's rules: a failed, cancelled or skipped need skips
5 + its dependents unless their `if:` calls a status function, and
6 + `continue-on-error` hides a failure from both.
7 + """
8 + use GitGud.DataCase, async: false
9 +
10 + alias GitGud.Workflows
11 + alias GitGud.Workflows.WorkflowJob
12 + alias GitGud.Workflows.WorkflowRun
13 + alias GitGud.Workflows.Yaml
14 +
15 + import GitGud.ForgeFixtures
16 +
17 + setup do
18 + {_user, repo} = repository_fixture()
19 + {:ok, repo: repo}
20 + end
21 +
22 + # As in MatrixDispatchTest: a seed row plus a re-run creates the jobs
23 + # through the real path without a repository on disk.
24 + defp run_workflow(repo, yaml) do
25 + {:ok, doc} = Yaml.parse(yaml)
26 +
27 + seed =
28 + %WorkflowRun{repository_id: repo.id}
29 + |> WorkflowRun.create_changeset(%{
30 + "number" => System.unique_integer([:positive]),
31 + "workflow_path" => ".forgejo/workflows/ci.yml",
32 + "workflow_name" => doc.name,
33 + "trigger" => "push",
34 + "head_ref" => "refs/heads/main",
35 + "head_sha" => :crypto.strong_rand_bytes(20),
36 + "workflow_doc" => doc.raw
37 + })
38 + |> Repo.insert!()
39 +
40 + {:ok, run} = Workflows.rerun(seed)
41 + run
42 + end
43 +
44 + defp job(run, job_id) do
45 + Repo.one!(from j in WorkflowJob, where: j.workflow_run_id == ^run.id and j.job_id == ^job_id)
46 + end
47 +
48 + defp status(run, job_id), do: job(run, job_id).status
49 + defp run_status(run), do: Repo.get!(WorkflowRun, run.id).status
50 +
51 + describe "after a failure" do
52 + setup %{repo: repo} do
53 + run =
54 + run_workflow(repo, """
55 + name: ci
56 + on: push
57 + jobs:
58 + build:
59 + runs-on: self-hosted
60 + steps:
61 + - run: false
62 + deploy:
63 + needs: build
64 + runs-on: self-hosted
65 + steps:
66 + - run: true
67 + notify:
68 + needs: build
69 + if: always()
70 + runs-on: self-hosted
71 + steps:
72 + - run: true
73 + report:
74 + needs: build
75 + if: ${{ failure() }}
76 + runs-on: self-hosted
77 + steps:
78 + - run: true
79 + tidy:
80 + needs: build
81 + if: "!cancelled()"
82 + runs-on: self-hosted
83 + steps:
84 + - run: true
85 + """)
86 +
87 + {:ok, run: run}
88 + end
89 +
90 + test "a plain dependent is skipped, not cancelled", %{run: run} do
91 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
92 +
93 + assert status(run, "deploy") == "skipped"
94 + end
95 +
96 + test "dependents whose `if:` calls a status function are queued with the real result",
97 + %{run: run} do
98 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
99 +
100 + for id <- ~w(notify report tidy) do
101 + dependent = job(run, id)
102 + assert dependent.status == "queued"
103 + assert %{"build" => %{"status" => "failure"}} = dependent.needs_snapshot
104 + end
105 + end
106 +
107 + test "the run fails once the queued dependents finish", %{run: run} do
108 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
109 + assert run_status(run) in ~w(queued running)
110 +
111 + for id <- ~w(notify report tidy),
112 + do: {:ok, _} = Workflows.update_job_status(job(run, id), "success")
113 +
114 + assert run_status(run) == "failure"
115 + end
116 + end
117 +
118 + test "a skipped need skips a dependent without a status function", %{repo: repo} do
119 + run =
120 + run_workflow(repo, """
121 + name: ci
122 + on: push
123 + jobs:
124 + build:
125 + runs-on: self-hosted
126 + steps:
127 + - run: true
128 + deploy:
129 + needs: build
130 + runs-on: self-hosted
131 + steps:
132 + - run: true
133 + """)
134 +
135 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "skipped")
136 +
137 + assert status(run, "deploy") == "skipped"
138 + # Skipped jobs don't fail a run.
139 + assert run_status(run) == "success"
140 + end
141 +
142 + test "a failure skips a whole chain", %{repo: repo} do
143 + run =
144 + run_workflow(repo, """
145 + name: ci
146 + on: push
147 + jobs:
148 + a:
149 + runs-on: self-hosted
150 + steps:
151 + - run: false
152 + b:
153 + needs: a
154 + runs-on: self-hosted
155 + steps:
156 + - run: true
157 + c:
158 + needs: [a, b]
159 + runs-on: self-hosted
160 + steps:
161 + - run: true
162 + """)
163 +
164 + {:ok, _} = Workflows.update_job_status(job(run, "a"), "failure")
165 +
166 + assert status(run, "b") == "skipped"
167 + assert status(run, "c") == "skipped"
168 + assert run_status(run) == "failure"
169 + end
170 +
171 + test "a dependent waits for every need before it's decided", %{repo: repo} do
172 + run =
173 + run_workflow(repo, """
174 + name: ci
175 + on: push
176 + jobs:
177 + a:
178 + runs-on: self-hosted
179 + steps:
180 + - run: false
181 + b:
182 + runs-on: self-hosted
183 + steps:
184 + - run: true
185 + c:
186 + needs: [a, b]
187 + if: always()
188 + runs-on: self-hosted
189 + steps:
190 + - run: true
191 + """)
192 +
193 + {:ok, _} = Workflows.update_job_status(job(run, "a"), "failure")
194 + assert status(run, "c") == "blocked"
195 +
196 + {:ok, _} = Workflows.update_job_status(job(run, "b"), "success")
197 + assert status(run, "c") == "queued"
198 + end
199 +
200 + describe "continue-on-error" do
201 + test "a failure doesn't hold back dependents or fail the run", %{repo: repo} do
202 + run =
203 + run_workflow(repo, """
204 + name: ci
205 + on: push
206 + jobs:
207 + build:
208 + continue-on-error: true
209 + runs-on: self-hosted
210 + steps:
211 + - run: false
212 + deploy:
213 + needs: build
214 + runs-on: self-hosted
215 + steps:
216 + - run: true
217 + """)
218 +
219 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
220 +
221 + deploy = job(run, "deploy")
222 + assert deploy.status == "queued"
223 + assert %{"build" => %{"status" => "success"}} = deploy.needs_snapshot
224 +
225 + {:ok, _} = Workflows.update_job_status(deploy, "success")
226 +
227 + assert status(run, "build") == "failure"
228 + assert run_status(run) == "success"
229 + end
230 +
231 + test "is read through a matrix value, and spares the leg's siblings fail-fast", %{
232 + repo: repo
233 + } do
234 + run =
235 + run_workflow(repo, """
236 + name: ci
237 + on: push
238 + jobs:
239 + build:
240 + strategy:
241 + matrix:
242 + experimental: [true, false]
243 + continue-on-error: ${{ matrix.experimental }}
244 + runs-on: self-hosted
245 + steps:
246 + - run: true
247 + """)
248 +
249 + [experimental, stable] =
250 + from(j in WorkflowJob, where: j.workflow_run_id == ^run.id, order_by: j.id)
251 + |> Repo.all()
252 + |> Enum.sort_by(&(&1.matrix["experimental"] != true))
253 +
254 + assert Workflows.effective_result(%{experimental | status: "failure"}) == "success"
255 + assert Workflows.effective_result(%{stable | status: "failure"}) == "failure"
256 +
257 + {:ok, _} = Workflows.update_job_status(experimental, "failure")
258 + assert Repo.get!(WorkflowJob, stable.id).status == "queued"
259 + end
260 + end
261 +
262 + test "a matrix need with one failed leg reports failure", %{repo: repo} do
263 + run =
264 + run_workflow(repo, """
265 + name: ci
266 + on: push
267 + jobs:
268 + build:
269 + strategy:
270 + fail-fast: false
271 + matrix:
272 + arch: [amd64, arm64]
273 + runs-on: self-hosted
274 + steps:
275 + - run: true
276 + report:
277 + needs: build
278 + if: always()
279 + runs-on: self-hosted
280 + steps:
281 + - run: true
282 + """)
283 +
284 + [amd, arm] =
285 + Repo.all(
286 + from j in WorkflowJob,
287 + where: j.workflow_run_id == ^run.id and j.job_id == "build",
288 + order_by: j.id
289 + )
290 +
291 + {:ok, _} = Workflows.update_job_status(amd, "success")
292 + {:ok, _} = Workflows.update_job_status(arm, "failure")
293 +
294 + assert %{"build" => %{"status" => "failure"}} = job(run, "report").needs_snapshot
295 + end
296 +
297 + test "re-running a skipped dependent whose need still failed skips it again", %{repo: repo} do
298 + run =
299 + run_workflow(repo, """
300 + name: ci
301 + on: push
302 + jobs:
303 + build:
304 + runs-on: self-hosted
305 + steps:
306 + - run: false
307 + deploy:
308 + needs: build
309 + runs-on: self-hosted
310 + steps:
311 + - run: true
312 + """)
313 +
314 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
315 + assert {:ok, _} = Workflows.rerun_job(Repo.get!(WorkflowRun, run.id), "deploy")
316 +
317 + assert status(run, "deploy") == "skipped"
318 + assert run_status(run) == "failure"
319 + end
320 +
321 + test "a cancelled run cancels blocked jobs, `always()` or not", %{repo: repo} do
322 + run =
323 + run_workflow(repo, """
324 + name: ci
325 + on: push
326 + jobs:
327 + build:
328 + runs-on: self-hosted
329 + steps:
330 + - run: true
331 + notify:
332 + needs: build
333 + if: always()
334 + runs-on: self-hosted
335 + steps:
336 + - run: true
337 + """)
338 +
339 + {:ok, _} = Workflows.cancel(Repo.get!(WorkflowRun, run.id))
340 +
341 + assert status(run, "build") == "cancelled"
342 + assert status(run, "notify") == "cancelled"
343 + assert run_status(run) == "cancelled"
344 + end
345 +
346 + describe "status_function_if?/1" do
347 + test "spots the status functions, negated or wrapped" do
348 + for condition <- [
349 + "always()",
350 + "${{ failure() }}",
351 + "!cancelled()",
352 + "success() || failure ( )",
353 + "always() && needs.build.outputs.x == '1'"
354 + ] do
355 + assert Workflows.status_function_if?(%{"if" => condition}), condition
356 + end
357 + end
358 +
359 + test "ignores everything else" do
360 + for definition <- [
361 + %{},
362 + %{"if" => true},
363 + %{"if" => "success()"},
364 + %{"if" => "github.ref == 'refs/heads/main'"},
365 + %{"if" => "needs.always_on.result == 'success'"}
366 + ] do
367 + refute Workflows.status_function_if?(definition), inspect(definition)
368 + end
369 + end
370 + end
371 +end
modified test/git_gud/workflows/matrix_dispatch_test.exs
+2 −2
@@ -317,13 +317,13 @@ defmodule GitGud.Workflows.MatrixDispatchTest do
317 317 assert Repo.get!(WorkflowJob, release.id).status == "queued"
318 318 end
319 319
320 − test "one failing leg cancels the downstream job", %{run: run} do
320 + test "one failing leg skips the downstream job", %{run: run} do
321 321 [amd, arm, release] = jobs(run)
322 322
323 323 {:ok, _} = Workflows.update_job_status(amd, "success")
324 324 {:ok, _} = Workflows.update_job_status(arm, "failure")
325 325
326 − assert Repo.get!(WorkflowJob, release.id).status == "cancelled"
326 + assert Repo.get!(WorkflowJob, release.id).status == "skipped"
327 327 end
328 328
329 329 test "the snapshot collapses the legs into one name", %{run: run} do
modified test/git_gud_web/controllers/runner_controller_test.exs
+65 −2
@@ -435,6 +435,67 @@ defmodule GitGudWeb.RunnerControllerTest do
435 435 end
436 436 end
437 437
438 + describe "needs and results" do
439 + setup ctx do
440 + {:ok, runner, token} =
441 + GitGud.Workflows.Runners.register(%{
442 + name: "graph-runner",
443 + version: "v1",
444 + labels: %{"labels" => ["ubuntu-latest"]}
445 + })
446 +
447 + Map.merge(ctx, %{runner: runner, runner_token: token})
448 + end
449 +
450 + test "FetchTask sends each need's result and outputs", %{conn: conn, runner_token: token} do
451 + snapshot = %{
452 + "build" => %{"status" => "success", "outputs" => %{"x" => "1"}},
453 + "lint" => %{"status" => "failure", "outputs" => %{}}
454 + }
455 +
456 + {task, _run} =
457 + fetch_task(conn, token, %{trigger: "push"}, fn _ -> :ok end, needs_snapshot: snapshot)
458 +
459 + assert %Runner.V1.TaskNeed{result: :RESULT_SUCCESS, outputs: %{"x" => "1"}} =
460 + task.needs["build"]
461 +
462 + assert %Runner.V1.TaskNeed{result: :RESULT_FAILURE, outputs: outputs} = task.needs["lint"]
463 + assert outputs == %{}
464 + end
465 +
466 + test "FetchTask sends no needs for a job without any", %{conn: conn, runner_token: token} do
467 + {task, _run} = fetch_task(conn, token, %{trigger: "push"})
468 + assert task.needs == %{}
469 + end
470 +
471 + test "UpdateTask with RESULT_SKIPPED records the job as skipped", %{
472 + conn: conn,
473 + runner_token: token
474 + } do
475 + {task, _run} = fetch_task(conn, token, %{trigger: "push"})
476 +
477 + body =
478 + %Runner.V1.UpdateTaskRequest{
479 + state: %Runner.V1.TaskState{id: task.id, result: :RESULT_SKIPPED}
480 + }
481 + |> Runner.V1.UpdateTaskRequest.encode()
482 + |> IO.iodata_to_binary()
483 +
484 + resp =
485 + conn
486 + |> put_req_header("content-type", "application/protobuf")
487 + |> put_req_header("authorization", "Bearer " <> token)
488 + |> post("/api/actions/_apis/runner.v1.RunnerService/UpdateTask", body)
489 +
490 + assert resp.status == 200
491 +
492 + assert %Runner.V1.UpdateTaskResponse{state: %{result: :RESULT_SKIPPED}} =
493 + Runner.V1.UpdateTaskResponse.decode(resp.resp_body)
494 +
495 + assert GitGud.Repo.get!(GitGud.Workflows.WorkflowJob, task.id).status == "skipped"
496 + end
497 + end
498 +
438 499 # Queue one job on a run built from `run_attrs`, let the runner fetch it,
439 500 # and return the task's context as a plain map.
440 501 defp fetch_context(conn, token, run_attrs) do
@@ -443,8 +504,9 @@ defmodule GitGudWeb.RunnerControllerTest do
443 504 end
444 505
445 506 # The same, returning `{task, run}`. `prepare` gets the repository before
446 − # the fetch (to plant secrets, say).
447 − defp fetch_task(conn, token, run_attrs, prepare \\ fn _repo -> :ok end) do
507 + # the fetch (to plant secrets, say); `job_changes` are set on the queued
508 + # job (fields `create_changeset/2` doesn't cast, like `needs_snapshot`).
509 + defp fetch_task(conn, token, run_attrs, prepare \\ fn _repo -> :ok end, job_changes \\ []) do
448 510 user = GitGud.AccountsFixtures.user_fixture()
449 511
450 512 {:ok, repo} =
@@ -474,6 +536,7 @@ defmodule GitGudWeb.RunnerControllerTest do
474 536 runs_on: %{"labels" => ["ubuntu-latest"]},
475 537 definition: %{"runs-on" => "ubuntu-latest", "steps" => [%{"run" => "true"}]}
476 538 })
539 + |> Ecto.Changeset.change(job_changes)
477 540 |> GitGud.Repo.insert!()
478 541
479 542 body = FetchTaskRequest.encode(%FetchTaskRequest{tasks_version: 0}) |> IO.iodata_to_binary()
modified test/git_gud_web/protos/runner_wire_test.exs
+12 −0
@@ -79,6 +79,18 @@ defmodule GitGudWeb.Protos.RunnerWireTest do
79 79 assert bytes == <<0x10, 0x01>>
80 80 end
81 81
82 + test "TaskNeed: outputs ride on field 1 as map entries, result on field 2" do
83 + bytes =
84 + %TaskNeed{result: :RESULT_FAILURE, outputs: %{"x" => "1"}}
85 + |> TaskNeed.encode()
86 + |> IO.iodata_to_binary()
87 +
88 + entry = str(1, "x") <> str(2, "1")
89 + assert bytes == str(1, entry) <> <<0x10, 0x02>>
90 +
91 + assert %TaskNeed{result: :RESULT_FAILURE, outputs: %{"x" => "1"}} = TaskNeed.decode(bytes)
92 + end
93 +
82 94 defp decode_varint(<<0::1, n::7, rest::binary>>), do: {n, rest}
83 95
84 96 defp decode_varint(<<1::1, n::7, rest::binary>>) do

Parents: e9900fa