neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Actions: a commit status per job, and branch protection can require them
b812854 · Gabriel Morell · 2026-10-17 17:01
Message
{commit_body(@commit)}
Files changed
modified
README.md
+2
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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