Actions: a commit status per job, and branch protection can require them

b812854 · Gabriel Morell · 2026-10-17 17:01

44 files +993 -99
Message
{commit_body(@commit)}

Files changed

modified README.md
+2 −0
@@ -95,6 +95,8 @@ reinvent them.
95 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
96 96 - 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
97 97 - **`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 + - **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
99 + - **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
98 100 - **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
99 101 - **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
100 102 - A simplified **v1 "pipelines" artifact API**, scoped to the run: a job creates, lists and downloads its own run's artifacts only (so jobs of one run can share files). Not yet usable from the stock actions: forgejo-runner points `ACTIONS_RUNTIME_URL` at `/api/actions_pipeline/`, which isn't mounted, and `actions/upload-artifact@v3` / `download-artifact@v3` speak a different shape of that API (chunked `PUT`s with `itemPath`, container item listing). `@v4` (the Twirp `ArtifactService` under `ACTIONS_RESULTS_URL`) isn't supported
modified lib/git_gud/branch_protections.ex
+91 −10
@@ -8,7 +8,8 @@ defmodule GitGud.BranchProtections do
8 8 inspects the proposed ref update before forwarding to
9 9 `git http-backend`.
10 10 * `GitGud.PullRequests.merge!/3` — refuses to merge when the target
11 − ref is protected and the PR doesn't satisfy the rule.
11 + ref is protected and the PR doesn't satisfy the rule, its required
12 + status checks included.
12 13 """
13 14
14 15 import Ecto.Query, warn: false
@@ -58,8 +59,20 @@ defmodule GitGud.BranchProtections do
58 59 @doc """
59 60 Evaluate whether a proposed ref update is allowed.
60 61
61 − `old_sha` / `new_sha` are raw 20-byte binaries. `pr` is optional;
62 − when present (i.e., the update is a PR merge), reviews are counted.
62 + `old_sha` / `new_sha` are raw 20-byte binaries. Every rule applies in
63 + turn: force-push, then approvals, then status checks.
64 +
65 + `opts`:
66 +
67 + * `:pull_request_id` — the update is a PR merge: reviews are
68 + counted, and the rule's required status checks apply
69 + * `:head_sha` — the head being merged, whose statuses are checked
70 + (`check_statuses/3`)
71 + * `override: true` — an admin merging past the PR's rules; skips the
72 + status checks (review rules are `PullRequests.merge!/3`'s)
73 +
74 + A direct push has no pull request, so status checks never block it:
75 + there is no head to have checks on (Forgejo's behaviour too).
63 76 """
64 77 def check_ref_update(%Repository{} = repo, branch, old_sha, new_sha, opts \\ []) do
65 78 case rule_for_branch(repo, branch) do
@@ -75,18 +88,83 @@ defmodule GitGud.BranchProtections do
75 88 end
76 89
77 90 defp evaluate(rule, repo, old_sha, new_sha, opts) do
78 − cond do
79 − force_push?(repo.disk_path, old_sha, new_sha) and not rule.allow_force_push ->
80 − {:error, :force_push_blocked}
91 + with :ok <- check_force_push(rule, repo, old_sha, new_sha),
92 + :ok <- check_approvals(rule, opts) do
93 + check_merge_statuses(rule, repo, opts)
94 + end
95 + end
81 96
82 − rule.required_approvals > 0 ->
83 − check_approvals(rule, opts)
97 + defp check_force_push(rule, repo, old_sha, new_sha) do
98 + if force_push?(repo.disk_path, old_sha, new_sha) and not rule.allow_force_push,
99 + do: {:error, :force_push_blocked},
100 + else: :ok
101 + end
84 102
85 − true ->
86 − :ok
103 + defp check_merge_statuses(rule, repo, opts) do
104 + cond do
105 + is_nil(opts[:pull_request_id]) -> :ok
106 + opts[:override] == true -> :ok
107 + true -> check_statuses(rule, repo, opts[:head_sha])
87 108 end
88 109 end
89 110
111 + @doc """
112 + Whether `head_sha`'s commit statuses satisfy the rule's required
113 + checks, as GitHub and Forgejo decide it: every pattern in
114 + `required_checks` matches at least one status, and every status a
115 + pattern matches is `success` (a skipped job reports success). An
116 + empty `required_checks` means every status on the commit, of which
117 + there must be at least one.
118 +
119 + Patterns are globs whose `*` matches any run of characters, `/` and
120 + spaces included (`ci / test *` matches `ci / test (push)`); everything
121 + else is literal.
122 +
123 + Returns `:ok` (also when the rule doesn't require checks), or
124 + `{:error, {:status_checks, missing_patterns, failing_contexts}}`, where
125 + a failing context is one that isn't `success` yet — pending included.
126 + """
127 + def check_statuses(%BranchProtection{require_status_checks: false}, _repo, _sha), do: :ok
128 +
129 + def check_statuses(%BranchProtection{} = rule, %Repository{} = repo, head_sha) do
130 + statuses = if head_sha, do: GitGud.Workflows.list_commit_statuses(repo, head_sha), else: []
131 +
132 + patterns =
133 + case rule.required_checks || [] do
134 + [] -> ["*"]
135 + patterns -> patterns
136 + end
137 +
138 + matches =
139 + Map.new(patterns, fn p -> {p, Enum.filter(statuses, &check_match?(p, &1.context))} end)
140 +
141 + missing = for p <- patterns, matches[p] == [], do: p
142 +
143 + failing =
144 + matches
145 + |> Map.values()
146 + |> List.flatten()
147 + |> Enum.reject(&(&1.state == "success"))
148 + |> Enum.map(& &1.context)
149 + |> Enum.uniq()
150 + |> Enum.sort()
151 +
152 + if missing == [] and failing == [],
153 + do: :ok,
154 + else: {:error, {:status_checks, missing, failing}}
155 + end
156 +
157 + # Not `GitGud.Workflows.Glob`, whose `*` stops at `/`: a context has
158 + # slashes in it (`ci / test (push)`), and Forgejo's `*` crosses them.
159 + defp check_match?(pattern, context) do
160 + regex =
161 + pattern
162 + |> String.split("*")
163 + |> Enum.map_join(".*", &Regex.escape/1)
164 +
165 + Regex.match?(~r/\A#{regex}\z/s, context)
166 + end
167 +
90 168 defp force_push?(_path, nil, _), do: false
91 169 defp force_push?(_path, <<0::size(160)>>, _), do: false
92 170
@@ -109,6 +187,9 @@ defmodule GitGud.BranchProtections do
109 187 end
110 188 end
111 189
190 + defp check_approvals(%BranchProtection{required_approvals: n}, _opts) when n in [nil, 0],
191 + do: :ok
192 +
112 193 defp check_approvals(rule, opts) do
113 194 pr_id = Keyword.get(opts, :pull_request_id)
114 195
modified lib/git_gud/merges.ex
+3 −1
@@ -146,7 +146,9 @@ defmodule GitGud.Merges do
146 146 branch,
147 147 expected_old,
148 148 analysis.theirs_sha,
149 − Keyword.take(opts, [:pull_request_id])
149 + # For a PR, `theirs` is its head: the commit whose statuses the
150 + # rule's required checks read.
151 + opts |> Keyword.take([:pull_request_id]) |> Keyword.put(:head_sha, analysis.theirs_sha)
150 152 )
151 153 end
152 154
modified lib/git_gud/pull_requests.ex
+25 −1
@@ -670,7 +670,9 @@ defmodule GitGud.PullRequests do
670 670 pr.target_ref,
671 671 current_target_sha,
672 672 pr.head_sha,
673 − pull_request_id: pr.id
673 + pull_request_id: pr.id,
674 + head_sha: pr.head_sha,
675 + override: override?
674 676 ),
675 677 {:ok, base_sha} <- Git.merge_base(path, pr.head_sha, current_target_sha),
676 678 {:ok, tree_sha} <-
@@ -1192,6 +1194,28 @@ defmodule GitGud.PullRequests do
1192 1194 changes ++ approvals
1193 1195 end
1194 1196
1197 + @doc """
1198 + The target branch's required status checks this PR's head doesn't
1199 + satisfy yet (`BranchProtections.check_statuses/3`):
1200 +
1201 + * `{:missing_check, pattern}` — no status on the head matches it
1202 + * `{:failing_check, context}` — a matching status isn't `success`
1203 + (failed, cancelled, or still pending)
1204 +
1205 + Empty when the branch has no rule, or its rule doesn't require
1206 + checks. What `merge!/3` refuses on, so the page and the merge agree;
1207 + an admin's override gets past both.
1208 + """
1209 + def status_check_blockers(%Repository{} = repo, %PullRequest{} = pr) do
1210 + with %{} = rule <- BranchProtections.rule_for_branch(repo, pr.target_ref),
1211 + {:error, {:status_checks, missing, failing}} <-
1212 + BranchProtections.check_statuses(rule, repo, pr.head_sha) do
1213 + Enum.map(missing, &{:missing_check, &1}) ++ Enum.map(failing, &{:failing_check, &1})
1214 + else
1215 + _ -> []
1216 + end
1217 + end
1218 +
1195 1219 @doc "Whether `pr` currently fails any of the repo's review rules."
1196 1220 def review_blocked?(%Repository{} = repo, %PullRequest{} = pr),
1197 1221 do: merge_blockers(repo, pr) != []
modified lib/git_gud/workflows.ex
+148 −60
@@ -19,6 +19,7 @@ defmodule GitGud.Workflows do
19 19 alias GitGud.Repo
20 20 alias GitGud.Repositories
21 21 alias GitGud.Repositories.Repository
22 + alias GitGud.Repositories.Storage
22 23 alias GitGud.Workflows.Concurrency
23 24 alias GitGud.Workflows.Context
24 25 alias GitGud.Workflows.EventPayload
@@ -451,6 +452,10 @@ defmodule GitGud.Workflows do
451 452
452 453 # ── run + jobs + steps creation ──────────────────────────────────────
453 454
455 + # The `status_reason` of a run's jobs when a newer run in its
456 + # concurrency group cancelled it (`run_concurrency/2`).
457 + @run_superseded "Cancelled by run #"
458 +
454 459 defp create_run(%Repository{id: rid} = repo, %{jobs: jobs} = attrs) do
455 460 Repo.transaction(fn ->
456 461 number = next_run_number(rid)
@@ -472,16 +477,20 @@ defmodule GitGud.Workflows do
472 477 # are cancelled would be settled as finished half-built.
473 478 legs = Enum.flat_map(jobs, &create_job(run, &1, hold))
474 479
475 − for %WorkflowJob{status: "queued"} = leg <- legs, do: enqueue_job(leg)
480 + # A pending commit status per job, so the SCM views show the run is
481 + # in flight before the first claim lands.
482 + repo = Repo.preload(repo, [:owner, :organization])
483 + for leg <- legs, do: write_job_commit_status(leg, run, repo)
484 + drop_legacy_commit_status(run)
476 485
477 − # Initial pending commit status so the SCM view shows the run
478 − # is in flight before the first claim lands.
479 − update_run_commit_status(run)
486 + for %WorkflowJob{status: "queued"} = leg <- legs, do: enqueue_job(leg)
480 487
481 488 {run, hold}
482 489 end)
483 490 |> case do
484 491 {:ok, {run, hold}} ->
492 + broadcast_ci_change(run.id)
493 +
485 494 # Cancelled once this run is committed, rather than from inside its
486 495 # transaction: `cancel/2` releases each one's group, and that
487 496 # should see this run.
@@ -547,7 +556,7 @@ defmodule GitGud.Workflows do
547 556 none
548 557 | waiting: waiting,
549 558 cancel: cancel,
550 − cancel_reason: "Cancelled by run ##{run.number} in #{group}"
559 + cancel_reason: "#{@run_superseded}#{run.number} in #{group}"
551 560 }}
552 561 end
553 562 end
@@ -1021,9 +1030,10 @@ defmodule GitGud.Workflows do
1021 1030
1022 1031 if run.status == "queued" do
1023 1032 run |> WorkflowRun.status_changeset("running") |> Repo.update!()
1024 − broadcast_ci_change(run.id)
1025 1033 end
1026 1034
1035 + update_job_commit_status(claimed, run)
1036 +
1027 1037 claimed |> Repo.preload(:steps)
1028 1038 end
1029 1039 end)
@@ -1060,9 +1070,10 @@ defmodule GitGud.Workflows do
1060 1070
1061 1071 if run.status == "queued" do
1062 1072 run |> WorkflowRun.status_changeset("running") |> Repo.update!()
1063 − broadcast_ci_change(run.id)
1064 1073 end
1065 1074
1075 + update_job_commit_status(claimed, run)
1076 +
1066 1077 claimed |> Repo.preload(:steps)
1067 1078 end
1068 1079 end)
@@ -1499,6 +1510,7 @@ defmodule GitGud.Workflows do
1499 1510 case result do
1500 1511 {:ok, updated} ->
1501 1512 broadcast({:job_status, job.workflow_run_id, updated})
1513 + update_job_commit_status(updated, Repo.get!(WorkflowRun, updated.workflow_run_id))
1502 1514
1503 1515 if WorkflowJob.terminal?(updated.status) do
1504 1516 finalize_steps(updated)
@@ -1597,6 +1609,7 @@ defmodule GitGud.Workflows do
1597 1609 defp put_job(%WorkflowJob{} = job, fields) do
1598 1610 row = job |> Ecto.Changeset.change(fields) |> Repo.update!()
1599 1611 broadcast({:job_status, row.workflow_run_id, row})
1612 + update_job_commit_status(row, Repo.get!(WorkflowRun, row.workflow_run_id))
1600 1613 row
1601 1614 end
1602 1615
@@ -1863,7 +1876,6 @@ defmodule GitGud.Workflows do
1863 1876 # `blocked` is still pending — its upstream is in flight — and
1864 1877 # `waiting` is held by a concurrency group.
1865 1878 Enum.any?(jobs, &(&1.status in ~w(queued running blocked waiting))) ->
1866 − update_run_commit_status(run)
1867 1879 :ok
1868 1880
1869 1881 Enum.any?(jobs, &(effective_result(&1) == "failure")) ->
@@ -1889,7 +1901,7 @@ defmodule GitGud.Workflows do
1889 1901
1890 1902 defp finish_run(%WorkflowRun{} = run, status) do
1891 1903 run = run |> WorkflowRun.status_changeset(status) |> Repo.update!()
1892 − update_run_commit_status(run)
1904 + broadcast_ci_change(run.id)
1893 1905 release_run_group(run)
1894 1906 broadcast_run_status(run)
1895 1907 end
@@ -1946,64 +1958,125 @@ defmodule GitGud.Workflows do
1946 1958 defp head_branch(other), do: other
1947 1959
1948 1960 @doc """
1949 − Upsert the commit status row for the given workflow run. Runs on
1950 − every status transition (queued → running → success/failure) so the
1951 − SCM views see ✓/✗ as soon as the run finishes.
1952 −
1953 − `state` mapping:
1954 − queued, running -> "pending"
1955 − success -> "success"
1956 − failure, cancelled -> "failure"
1957 − other (error) -> "error"
1961 + Upsert the commit status for one job of a run. Runs on every job
1962 + transition, so the SCM views and branch protection see each job's
1963 + verdict as soon as it lands.
1964 +
1965 + One status per job, as Forgejo and GitHub report them, named
1966 + `<workflow> / <job> (<event>)` — `ci / build (arm64, 28) (push)` for a
1967 + matrix leg — so a push run and a `pull_request` run on the same head
1968 + don't overwrite each other, and a required check can name one job.
1969 +
1970 + `state` mapping (Forgejo's `toCommitStatus`):
1971 + queued, blocked, waiting, running -> "pending"
1972 + success, skipped -> "success" (a skipped required
1973 + check passes, as on GitHub)
1974 + failure, cancelled -> "failure"
1975 + cancelled by a newer run or job
1976 + in its concurrency group -> "cancelled" (superseded, not
1977 + failed: the newer one decides)
1978 +
1979 + A job that fails under `continue-on-error` still reports `failure`: the
1980 + job did fail, whatever its dependents make of it.
1981 +
1982 + Also wakes the CI dashboards (`broadcast_ci_change/1`).
1958 1983 """
1959 − def update_run_commit_status(%WorkflowRun{} = run) do
1960 − state =
1961 − case run.status do
1962 − s when s in ~w(queued running) -> "pending"
1963 − "success" -> "success"
1964 − s when s in ~w(failure cancelled) -> "failure"
1965 − _ -> "error"
1966 − end
1967 −
1968 − context = "gitgud-actions/" <> (run.workflow_name || run.workflow_path || "workflow")
1984 + def update_job_commit_status(%WorkflowJob{} = job, %WorkflowRun{} = run) do
1985 + repo =
1986 + run.repository_id |> Repositories.get_repository!() |> Repo.preload([:owner, :organization])
1969 1987
1970 − target_url =
1971 − GitGudWeb.Endpoint.url() <>
1972 − "/r/repo/" <>
1973 − Integer.to_string(run.repository_id) <>
1974 − "/actions/runs/" <> Integer.to_string(run.id)
1988 + result = write_job_commit_status(job, run, repo)
1989 + broadcast_ci_change(run.id)
1990 + result
1991 + end
1975 1992
1976 − description =
1977 − case run.status do
1978 − "success" -> "All checks passed"
1979 − "failure" -> "Some checks failed"
1980 − "cancelled" -> "Cancelled"
1981 − _ -> "Run #{run.number} #{run.status}"
1982 − end
1993 + defp write_job_commit_status(_job, %WorkflowRun{head_sha: nil}, _repo), do: :ok
1983 1994
1995 + defp write_job_commit_status(%WorkflowJob{} = job, %WorkflowRun{} = run, %Repository{} = repo) do
1984 1996 attrs = %{
1985 1997 repository_id: run.repository_id,
1986 1998 sha: run.head_sha,
1987 − context: context,
1988 − state: state,
1989 − description: description,
1990 − target_url: target_url
1999 + context: job_status_context(job, run),
2000 + state: job_commit_state(job),
2001 + description: job_status_description(job),
2002 + target_url:
2003 + GitGudWeb.Endpoint.url() <>
2004 + "/r/#{Storage.repo_handle(repo)}/#{repo.name}" <>
2005 + "/actions/#{run.number}#job-#{job.id}"
1991 2006 }
1992 2007
1993 − result =
1994 − %GitGud.Workflows.CommitStatus{}
1995 − |> GitGud.Workflows.CommitStatus.changeset(attrs)
1996 − |> Repo.insert(
1997 − on_conflict: {:replace, [:state, :description, :target_url, :updated_at]},
1998 − conflict_target: [:repository_id, :sha, :context]
1999 − )
2008 + %GitGud.Workflows.CommitStatus{}
2009 + |> GitGud.Workflows.CommitStatus.changeset(attrs)
2010 + |> Repo.insert(
2011 + on_conflict: {:replace, [:state, :description, :target_url, :updated_at]},
2012 + conflict_target: [:repository_id, :sha, :context]
2013 + )
2014 + end
2000 2015
2001 − # Every run transition (queued → running → terminal) flows through
2002 − # here, so it's the single chokepoint for waking the CI dashboards.
2003 − broadcast_ci_change(run.id)
2004 − result
2016 + # The one status per workflow written before statuses were per job, so
2017 + # a re-run on such a commit doesn't leave it beside the jobs' (where a
2018 + # rule requiring every status to pass would never get past it).
2019 + defp drop_legacy_commit_status(%WorkflowRun{head_sha: sha} = run) when is_binary(sha) do
2020 + legacy = "gitgud-actions/" <> (run.workflow_name || run.workflow_path || "workflow")
2021 +
2022 + from(c in GitGud.Workflows.CommitStatus,
2023 + where: c.repository_id == ^run.repository_id and c.sha == ^sha and c.context == ^legacy
2024 + )
2025 + |> Repo.delete_all()
2005 2026 end
2006 2027
2028 + defp drop_legacy_commit_status(_run), do: :ok
2029 +
2030 + @doc """
2031 + The commit status context of a job: `<workflow> / <job> (<event>)`,
2032 + Forgejo's format, so required-check names copied from Forgejo match.
2033 + """
2034 + def job_status_context(%WorkflowJob{} = job, %WorkflowRun{} = run) do
2035 + workflow = run.workflow_name || Path.basename(run.workflow_path || "workflow")
2036 + "#{workflow} / #{job.name || job.job_id} (#{run.trigger})"
2037 + end
2038 +
2039 + defp job_commit_state(%WorkflowJob{status: status} = job) do
2040 + cond do
2041 + status in ~w(queued blocked waiting running) -> "pending"
2042 + status in ~w(success skipped) -> "success"
2043 + status == "cancelled" and superseded_by_group?(job) -> "cancelled"
2044 + status in ~w(failure cancelled) -> "failure"
2045 + true -> "error"
2046 + end
2047 + end
2048 +
2049 + # Cancelled by a newer member of its run's group, or of its own.
2050 + defp superseded_by_group?(%WorkflowJob{status_reason: @run_superseded <> _}), do: true
2051 + defp superseded_by_group?(job), do: superseded?(job)
2052 +
2053 + defp job_status_description(%WorkflowJob{} = job) do
2054 + case job.status do
2055 + "queued" -> "Waiting to run"
2056 + "blocked" -> "Waiting for " <> Enum.join(job.needs, ", ")
2057 + "waiting" -> job.status_reason || "Waiting"
2058 + "running" -> "Running"
2059 + "success" -> "Successful" <> took(job, " in ")
2060 + "failure" -> "Failing" <> took(job, " after ")
2061 + "cancelled" -> job.status_reason || "Cancelled"
2062 + "skipped" -> "Skipped"
2063 + other -> other
2064 + end
2065 + end
2066 +
2067 + defp took(%WorkflowJob{started_at: %DateTime{} = from, completed_at: %DateTime{} = to}, prefix) do
2068 + secs = max(DateTime.diff(to, from), 0)
2069 +
2070 + prefix <>
2071 + cond do
2072 + secs < 60 -> "#{secs}s"
2073 + secs < 3600 -> "#{div(secs, 60)}m"
2074 + true -> "#{div(secs, 3600)}h #{div(rem(secs, 3600), 60)}m"
2075 + end
2076 + end
2077 +
2078 + defp took(_job, _prefix), do: ""
2079 +
2007 2080 # ── pubsub ───────────────────────────────────────────────────────────
2008 2081
2009 2082 def topic_for_run(run_id), do: "workflow_run:#{run_id}"
@@ -2016,6 +2089,11 @@ defmodule GitGud.Workflows do
2016 2089 """
2017 2090 def ci_topic, do: "ci:runs"
2018 2091
2092 + @doc "The repository a run belongs to, for a `ci_topic/0` subscriber to filter on."
2093 + def run_repository_id(run_id) do
2094 + Repo.one(from r in WorkflowRun, where: r.id == ^run_id, select: r.repository_id)
2095 + end
2096 +
2019 2097 @doc "Wake the CI dashboards after a run's status changes."
2020 2098 def broadcast_ci_change(run_id) do
2021 2099 Phoenix.PubSub.broadcast(GitGud.PubSub, ci_topic(), {:ci_run_changed, run_id})
@@ -2136,6 +2214,7 @@ defmodule GitGud.Workflows do
2136 2214 |> case do
2137 2215 {:ok, updated} ->
2138 2216 Enum.each(updated, &broadcast({:job_status, run.id, &1}))
2217 + Enum.each(updated, &update_job_commit_status(&1, run))
2139 2218
2140 2219 # A job whose needs are all done but wouldn't let it run is
2141 2220 # skipped (or cancelled) again rather than left blocked on
@@ -2191,13 +2270,13 @@ defmodule GitGud.Workflows do
2191 2270 # Blocked jobs are cancelled with the rest, even one whose `if:`
2192 2271 # says `always()` or `cancelled()`. GitHub would still run those
2193 2272 # once their needs finish; here a cancelled run runs nothing more.
2194 − {_count, groups} =
2273 + {_count, cancelled} =
2195 2274 Repo.update_all(
2196 2275 from(j in WorkflowJob,
2197 2276 where:
2198 2277 j.workflow_run_id == ^run.id and
2199 2278 j.status in ["queued", "running", "blocked", "waiting"],
2200 − select: j.concurrency_group
2279 + select: j
2201 2280 ),
2202 2281 set: [
2203 2282 status: "cancelled",
@@ -2227,13 +2306,22 @@ defmodule GitGud.Workflows do
2227 2306 {:workflow_job_status, %WorkflowJob{workflow_run_id: run.id}}
2228 2307 )
2229 2308
2230 − {updated, groups |> Enum.reject(&is_nil/1) |> Enum.uniq()}
2309 + {updated, cancelled}
2231 2310 end)
2232 2311 |> case do
2233 − {:ok, {updated, groups}} ->
2234 − update_run_commit_status(updated)
2312 + {:ok, {updated, cancelled}} ->
2313 + repo =
2314 + updated.repository_id
2315 + |> Repositories.get_repository!()
2316 + |> Repo.preload([:owner, :organization])
2317 +
2318 + for job <- cancelled, do: write_job_commit_status(job, updated, repo)
2319 + broadcast_ci_change(updated.id)
2235 2320 release_run_group(updated)
2236 2321
2322 + groups =
2323 + cancelled |> Enum.map(& &1.concurrency_group) |> Enum.reject(&is_nil/1) |> Enum.uniq()
2324 +
2237 2325 for group <- groups,
2238 2326 do: release_job_group(%WorkflowJob{workflow_run_id: run.id, concurrency_group: group})
2239 2327
modified lib/git_gud/workflows/commit_status.ex
+15 −11

Click to load diff…

modified lib/git_gud/workflows/workers/stale_job_reaper.ex
+10 −2

Click to load diff…

modified lib/git_gud_web/components/core_components.ex
+14 −3

Click to load diff…

modified lib/git_gud_web/controllers/api/mobile/json.ex
+3 −0

Click to load diff…

modified lib/git_gud_web/controllers/api/mobile/pull_request_controller.ex
+6 −1

Click to load diff…

modified lib/git_gud_web/live/branch_protection_live/index.ex
+68 −2

Click to load diff…

modified lib/git_gud_web/live/pr_live/show.ex
+68 −5

Click to load diff…

modified lib/git_gud_web/live/repo_live/merge.ex
+9 −0

Click to load diff…

modified lib/git_gud_web/live/workflow_live/show.ex
+7 −3

Click to load diff…

added priv/repo/migrations/20261017162540_add_cancelled_to_commit_status_states.exs
+27 −0

Click to load diff…

added test/git_gud/branch_protections/status_checks_test.exs
+146 −0

Click to load diff…

added test/git_gud/workflows/commit_status_test.exs
+199 −0

Click to load diff…

added test/git_gud_web/live/branch_protection_live/required_checks_test.exs
+55 −0

Click to load diff…

added test/git_gud_web/live/pr_live/required_checks_test.exs
+97 −0

Click to load diff…

Parents: 6b5083e