neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Actions: a warning conclusion for jobs that passed but want a look
d8a7c7f · Gabriel Morell · 2026-10-27 18:57
Message
{commit_body(@commit)}
Files changed
modified
README.md
+3
−3
| 103 | 103 | - **Filter patterns** in `branches:`, `tags:`, `paths:` and their `-ignore` lists follow GitHub's cheat sheet: `*` is any run of characters but `/`, `**` any run at all (`**/` also no directory), `?` zero or one and `+` one or more of the **preceding** character, `[a-z0-9]` one listed character (alphanumerics and `a-z`/`A-Z`/`0-9` ranges only), `\x` a literal `x`. In `branches:`, `tags:` and `paths:` the last matching pattern decides, so `!pattern` excludes and a later positive pattern includes again; a list with only `!` patterns matches nothing. `*` no longer crosses `/` in `branches:` / `tags:` (`branches: ['*']` doesn't run for `feature/x`), and `!` works there now. Branch protection and required-check patterns are separate: their `*` still crosses `/` |
| 104 | 104 | - **`paths:` / `paths-ignore:`**: 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 |
| 105 | 105 | - **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` (`repository_id` is sent, but act doesn't map it, so `${{ github.repository_id }}` reads blank). **`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 |
| 106 | − - **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 | |
| 106 | + - **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, and the job concludes **warning** (an amber ⚠ badge) rather than failed; any other expression there counts as false. A job can also conclude warning though it succeeded, by setting the output `gitgud-conclusion` to `warning` (`outputs: { gitgud-conclusion: ${{ steps.scan.outputs.conclusion }} }`; any case, surrounding whitespace ignored, any other value ignored) — a gitgud extension GitHub and Forgejo read as an ordinary output, so there the job just passes. A job that failed without `continue-on-error` stays failed whatever its outputs say. Dependents see a warning job's `needs.<id>.result` as `success`, as GitHub does for `continue-on-error`; a run whose jobs passed, were skipped or warned concludes success, and its page says how many warnings it had. The mobile API (and `gg ci`) report the job's status as `warning`; the `workflow_run` webhook carries the run's conclusion, `success` (there's no per-job webhook, so nothing says GitHub's `neutral`). A re-run starts the job's outputs afresh. Skipped jobs don't fail a run. Unlike GitHub, **cancelling a run cancels its waiting jobs too**, `always()` or not | |
| 107 | 107 | - Re-run / cancel buttons on the workflow show page (a re-run keeps the original run's actor). **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 |
| 108 | 108 | - **`concurrency:`**, workflow and job level, as a group name or `{group:, cancel-in-progress:}`: within a group (per repository) one run or job is in progress and at most one waits (shown as "Waiting for <group>"); a newer arrival cancels the waiting one, or with `cancel-in-progress` the one in progress too, and when the one in progress finishes the waiting one starts. A run (or job) cancelled only by a newer one in its group concludes `cancelled`, not failed. A workflow's group is decided when the run is created, a job's when it would be queued, so it can read `matrix` and `needs`. Group and `cancel-in-progress` may use `${{ }}` with the forge's own expression subset: property paths into `github`, `inputs`, `vars`, `matrix`, `needs` (`a.b`, `a['b']`), string/number/`true`/`false`/`null` literals, `||`, `&&`, `==`, `!=`, `!`, parentheses, `format()`, `fromJSON()` and `toJSON()`. A group outside that subset (any other function, say) is ignored with a warning, shown on the job — the run goes ahead **without** concurrency control rather than being held on a guess. Re-running a single job puts it back through its job-level group, not its run's |
| 109 | − - **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, awaiting-approval 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 | |
| 110 | − - **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 | |
| 109 | + - **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, awaiting-approval and running jobs are pending; success and skipped are success; warning is `warning` (Forgejo's state, an amber pill); 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 | |
| 110 | + - **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 passed: success, or warning (a skipped job reports 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 | |
| 111 | 111 | - **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 |
| 112 | 112 | - **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, and only the job that reserved an entry can upload to or finalize it (so a fork run can't fill in an entry a default-branch job reserved). 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 |
| 113 | 113 | - 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
+7
−3
| 115 | 115 | end |
| 116 | 116 | end |
| 117 | 117 | |
| 118 | + # The commit status states a required check passes with. | |
| 119 | + @passing_states ~w(success warning) | |
| 120 | + | |
| 118 | 121 | @doc """ |
| 119 | 122 | Whether `head_sha`'s commit statuses satisfy the rule's required |
| 120 | 123 | checks, as GitHub and Forgejo decide it: every pattern in |
| 121 | 124 | `required_checks` matches at least one status, and every status a |
| 122 | − pattern matches is `success` (a skipped job reports success). An | |
| 125 | + pattern matches passes: `success` (a skipped job reports success), or | |
| 126 | + `warning` (a job that passed but wants a look). An | |
| 123 | 127 | empty `required_checks` means every status on the commit, of which |
| 124 | 128 | there must be at least one. |
| 125 | 129 |
| 129 | 133 | |
| 130 | 134 | Returns `:ok` (also when the rule doesn't require checks), or |
| 131 | 135 | `{:error, {:status_checks, missing_patterns, failing_contexts}}`, where |
| 132 | − a failing context is one that isn't `success` yet — pending included. | |
| 136 | + a failing context is one that hasn't passed yet — pending included. | |
| 133 | 137 | """ |
| 134 | 138 | def check_statuses(%BranchProtection{require_status_checks: false}, _repo, _sha), do: :ok |
| 135 | 139 |
| 151 | 155 | matches |
| 152 | 156 | |> Map.values() |
| 153 | 157 | |> List.flatten() |
| 154 | − |> Enum.reject(&(&1.state == "success")) | |
| 158 | + |> Enum.reject(&(&1.state in @passing_states)) | |
| 155 | 159 | |> Enum.map(& &1.context) |
| 156 | 160 | |> Enum.uniq() |
| 157 | 161 | |> Enum.sort() |
modified
lib/git_gud/workflows.ex
+103
−20
| 2117 | 2117 | copy: a finished job doesn't go back (`WorkflowJob.status_changeset/3`), |
| 2118 | 2118 | and `{:error, changeset}` says it was refused. |
| 2119 | 2119 | |
| 2120 | + A `failure` or `success` may land as `warning` instead | |
| 2121 | + (`conclusion/2`): the stored row, outputs included, is what decides. | |
| 2122 | + | |
| 2120 | 2123 | `opts`: `:conclusion`; `:reason`, recorded as the job's `status_reason`; |
| 2121 | 2124 | `fail_fast: false` to leave its matrix siblings alone (a job cancelled by |
| 2122 | 2125 | its concurrency group is no verdict on the matrix). |
| 2127 | 2130 | |
| 2128 | 2131 | {:ok, result} = |
| 2129 | 2132 | Repo.transaction(fn -> |
| 2130 | − WorkflowJob | |
| 2131 | − |> where([j], j.id == ^job.id) | |
| 2132 | − |> lock("FOR UPDATE") | |
| 2133 | − |> Repo.one!() | |
| 2134 | − |> WorkflowJob.status_changeset(status, opts) | |
| 2133 | + locked = | |
| 2134 | + WorkflowJob | |
| 2135 | + |> where([j], j.id == ^job.id) | |
| 2136 | + |> lock("FOR UPDATE") | |
| 2137 | + |> Repo.one!() | |
| 2138 | + | |
| 2139 | + {concluded, why} = conclusion(locked, status) | |
| 2140 | + | |
| 2141 | + locked | |
| 2142 | + |> WorkflowJob.status_changeset(concluded, opts) | |
| 2135 | 2143 | |> then(fn cs -> |
| 2136 | − if reason, do: Ecto.Changeset.change(cs, status_reason: reason), else: cs | |
| 2144 | + case reason || why do | |
| 2145 | + nil -> cs | |
| 2146 | + reason -> Ecto.Changeset.change(cs, status_reason: reason) | |
| 2147 | + end | |
| 2137 | 2148 | end) |
| 2138 | 2149 | |> Repo.update() |
| 2139 | 2150 | end) |
| 2305 | 2316 | # * step never started → skipped |
| 2306 | 2317 | # * step was in flight → inherit the job's terminal status (a skipped |
| 2307 | 2318 | # job's steps are skipped) |
| 2308 | − defp finalize_steps(%WorkflowJob{id: jid, status: job_status}) do | |
| 2319 | + # | |
| 2320 | + # A `warning` job's steps read as what happened to them: failed under | |
| 2321 | + # `continue-on-error`, else succeeded. | |
| 2322 | + defp finalize_steps(%WorkflowJob{id: jid} = job) do | |
| 2323 | + job_status = runner_result(job) | |
| 2309 | 2324 | now = DateTime.utc_now() |> DateTime.truncate(:second) |
| 2310 | 2325 | |
| 2311 | 2326 | from(s in WorkflowStep, |
| 2530 | 2545 | end |
| 2531 | 2546 | |
| 2532 | 2547 | @doc """ |
| 2533 | − The result a job's dependents see: `success` for a job that failed | |
| 2534 | − under `continue-on-error: true`, its status otherwise. The job itself | |
| 2535 | − still shows as failed. | |
| 2548 | + The result a job's dependents see: `success` for a `warning` job, its | |
| 2549 | + status otherwise — as on GitHub, where a job that failed under | |
| 2550 | + `continue-on-error` reads as `success` to `needs.<id>.result`. The job | |
| 2551 | + itself shows as `warning`. | |
| 2552 | + | |
| 2553 | + A `failure` under `continue-on-error` reads as `success` too: rows | |
| 2554 | + finished before `warning` existed are stored that way. | |
| 2555 | + """ | |
| 2556 | + def effective_result(%WorkflowJob{status: "warning"}), do: "success" | |
| 2557 | + | |
| 2558 | + def effective_result(%WorkflowJob{status: "failure"} = job) do | |
| 2559 | + if continue_on_error?(job), do: "success", else: "failure" | |
| 2560 | + end | |
| 2561 | + | |
| 2562 | + def effective_result(%WorkflowJob{status: status}), do: status | |
| 2536 | 2563 | |
| 2537 | − `continue-on-error` is honoured when it's literally true, or `"true"` | |
| 2564 | + @doc """ | |
| 2565 | + Whether a job's `continue-on-error` is on: literally true, or `"true"` | |
| 2538 | 2566 | once `${{ matrix.* }}` is substituted (`continue-on-error: ${{ |
| 2539 | 2567 | matrix.experimental }}`). Any other expression is the runner's to |
| 2540 | 2568 | evaluate, and can't be seen from here: it counts as false. |
| 2541 | 2569 | """ |
| 2542 | − def effective_result(%WorkflowJob{status: "failure"} = job) do | |
| 2543 | − case Matrix.substitute(job.definition["continue-on-error"], job.matrix || %{}) do | |
| 2544 | − flag when flag in [true, "true"] -> "success" | |
| 2545 | − _ -> "failure" | |
| 2546 | − end | |
| 2570 | + def continue_on_error?(%WorkflowJob{} = job) do | |
| 2571 | + Matrix.substitute(job.definition["continue-on-error"], job.matrix || %{}) in [true, "true"] | |
| 2547 | 2572 | end |
| 2548 | 2573 | |
| 2549 | − def effective_result(%WorkflowJob{status: status}), do: status | |
| 2574 | + @doc """ | |
| 2575 | + What the runner reported for a job, which for a `warning` job isn't | |
| 2576 | + its status: `failure` when it failed under `continue-on-error`, else | |
| 2577 | + `success`. | |
| 2578 | + """ | |
| 2579 | + def runner_result(%WorkflowJob{status: "warning"} = job), | |
| 2580 | + do: if(continue_on_error?(job), do: "failure", else: "success") | |
| 2581 | + | |
| 2582 | + def runner_result(%WorkflowJob{status: status}), do: status | |
| 2583 | + | |
| 2584 | + # The output a job sets to conclude `warning` though it succeeded: | |
| 2585 | + # | |
| 2586 | + # outputs: | |
| 2587 | + # gitgud-conclusion: ${{ steps.scan.outputs.conclusion }} | |
| 2588 | + # | |
| 2589 | + # A gitgud extension. GitHub and Forgejo treat it as any other output, | |
| 2590 | + # so there the job just passes. | |
| 2591 | + @conclusion_output "gitgud-conclusion" | |
| 2592 | + | |
| 2593 | + # What a job given `status` concludes, and why when that isn't | |
| 2594 | + # `status` itself (for its `status_reason`): | |
| 2595 | + # | |
| 2596 | + # * failed under `continue-on-error` → `warning`: it did fail, but | |
| 2597 | + # was allowed to, which is neither a pass nor a failure to show | |
| 2598 | + # * succeeded with `gitgud-conclusion` set to `warning` (any case, | |
| 2599 | + # trimmed; other values are ignored) → `warning` | |
| 2600 | + # | |
| 2601 | + # A job that failed without `continue-on-error` stays failed whatever | |
| 2602 | + # its outputs say: an output can soften a pass, not excuse a failure. | |
| 2603 | + defp conclusion(%WorkflowJob{} = job, "failure") do | |
| 2604 | + if continue_on_error?(job), | |
| 2605 | + do: {"warning", "Failed, but continue-on-error allows it"}, | |
| 2606 | + else: {"failure", nil} | |
| 2607 | + end | |
| 2608 | + | |
| 2609 | + defp conclusion(%WorkflowJob{outputs: %{@conclusion_output => value}}, "success") | |
| 2610 | + when is_binary(value) do | |
| 2611 | + if value |> String.trim() |> String.downcase() == "warning", | |
| 2612 | + do: {"warning", "Succeeded, and reported #{@conclusion_output}: warning"}, | |
| 2613 | + else: {"success", nil} | |
| 2614 | + end | |
| 2615 | + | |
| 2616 | + defp conclusion(_job, status), do: {status, nil} | |
| 2550 | 2617 | |
| 2551 | 2618 | @doc """ |
| 2552 | 2619 | Whether a job's `if:` calls a status-check function — `always()`, |
| 2572 | 2639 | end |
| 2573 | 2640 | end |
| 2574 | 2641 | |
| 2575 | − # A job failed the run when it failed without `continue-on-error`. A | |
| 2642 | + # A job failed the run when it failed without `continue-on-error` (a | |
| 2643 | + # `warning` job passes it, as `effective_result/1` says). A | |
| 2576 | 2644 | # job cancelled on its own (fail-fast, a runner that gave up) fails it |
| 2577 | 2645 | # too, as on GitHub; only a cancelled run, or one superseded in a |
| 2578 | 2646 | # concurrency group, concludes `cancelled`. |
| 2691 | 2759 | queued, blocked, waiting, running -> "pending" |
| 2692 | 2760 | success, skipped -> "success" (a skipped required |
| 2693 | 2761 | check passes, as on GitHub) |
| 2762 | + warning -> "warning" (Forgejo's state for | |
| 2763 | + it; a required check passes) | |
| 2694 | 2764 | failure, cancelled -> "failure" |
| 2695 | 2765 | cancelled by a newer run or job |
| 2696 | 2766 | in its concurrency group -> "cancelled" (superseded, not |
| 2697 | 2767 | failed: the newer one decides) |
| 2698 | 2768 | |
| 2699 | − A job that fails under `continue-on-error` still reports `failure`: the | |
| 2700 | − job did fail, whatever its dependents make of it. | |
| 2769 | + A job that fails under `continue-on-error` is `warning`: it did fail, | |
| 2770 | + but was allowed to, and a pill that said `failure` sent people to look | |
| 2771 | + at a run that passed. | |
| 2701 | 2772 | |
| 2702 | 2773 | Also wakes the CI dashboards (`broadcast_ci_change/1`). |
| 2703 | 2774 | """ |
| 2776 | 2847 | cond do |
| 2777 | 2848 | status in ~w(queued blocked waiting awaiting_approval running) -> "pending" |
| 2778 | 2849 | status in ~w(success skipped) -> "success" |
| 2850 | + status == "warning" -> "warning" | |
| 2779 | 2851 | status == "cancelled" and superseded_by_group?(job) -> "cancelled" |
| 2780 | 2852 | status in ~w(failure cancelled) -> "failure" |
| 2781 | 2853 | true -> "error" |
| 2810 | 2882 | "running" -> "Running" |
| 2811 | 2883 | "success" -> "Successful" <> took(job, " in ") |
| 2812 | 2884 | "failure" -> "Failing" <> took(job, " after ") |
| 2885 | + "warning" -> warning_description(job) | |
| 2813 | 2886 | "cancelled" -> job.status_reason || "Cancelled" |
| 2814 | 2887 | "skipped" -> "Skipped" |
| 2815 | 2888 | other -> other |
| 2816 | 2889 | end |
| 2817 | 2890 | end |
| 2818 | 2891 | |
| 2892 | + defp warning_description(%WorkflowJob{} = job) do | |
| 2893 | + if continue_on_error?(job), | |
| 2894 | + do: "Failing" <> took(job, " after ") <> " (continue-on-error)", | |
| 2895 | + else: "Warning" <> took(job, " after ") | |
| 2896 | + end | |
| 2897 | + | |
| 2819 | 2898 | defp took(%WorkflowJob{started_at: %DateTime{} = from, completed_at: %DateTime{} = to}, prefix) do |
| 2820 | 2899 | secs = max(DateTime.diff(to, from), 0) |
| 2821 | 2900 |
| 2952 | 3031 | completed_at: nil, |
| 2953 | 3032 | runner_id: nil, |
| 2954 | 3033 | attempt: job.attempt + 1, |
| 3034 | + # The attempt's outputs are its own: a stale | |
| 3035 | + # `gitgud-conclusion: warning` would conclude the | |
| 3036 | + # re-run before it reported anything. | |
| 3037 | + outputs: %{}, | |
| 2955 | 3038 | concurrency_group: nil, |
| 2956 | 3039 | cancel_in_progress: false, |
| 2957 | 3040 | status_reason: nil |
modified
lib/git_gud/workflows/commit_status.ex
+4
−1
| 5 | 5 | `state` is the GitHub-flavored summary: |
| 6 | 6 | * `pending` — the job is queued, blocked, waiting, awaiting approval or running |
| 7 | 7 | * `success` — the job succeeded, or was skipped |
| 8 | + * `warning` — the job passed, but wants a look: it failed under | |
| 9 | + `continue-on-error`, or reported `gitgud-conclusion: warning` | |
| 10 | + (Forgejo's state; a required check it matches passes) | |
| 8 | 11 | * `failure` — the job failed, or was cancelled |
| 9 | 12 | * `cancelled` — the job was cancelled by a newer run or job in its |
| 10 | 13 | concurrency group: superseded rather than failed (not `success` |
| 28 | 31 | |
| 29 | 32 | alias GitGud.Repositories.Repository |
| 30 | 33 | |
| 31 | − @valid_states ~w(pending success failure cancelled error) | |
| 34 | + @valid_states ~w(pending success warning failure cancelled error) | |
| 32 | 35 | |
| 33 | 36 | schema "commit_statuses" do |
| 34 | 37 | field :sha, :binary |
modified
lib/git_gud/workflows/workflow_job.ex
+7
−3
| 23 | 23 | # `awaiting_approval` = a fork pull request's job that would be queued, |
| 24 | 24 | # held until someone with write access approves the run |
| 25 | 25 | # (`Workflows.approve_pull_request_runs/2`). Not claimable, not final. |
| 26 | − @valid_statuses ~w(queued running success failure cancelled blocked skipped waiting awaiting_approval) | |
| 26 | + # `warning` = finished, and passes, but someone should look: it failed | |
| 27 | + # under `continue-on-error`, or it succeeded and set the output | |
| 28 | + # `gitgud-conclusion: warning` (`Workflows.update_job_status/3` decides). | |
| 29 | + # Dependents and the run read it as `success`. | |
| 30 | + @valid_statuses ~w(queued running success failure cancelled blocked skipped waiting awaiting_approval warning) | |
| 27 | 31 | |
| 28 | 32 | schema "workflow_jobs" do |
| 29 | 33 | field :job_id, :string |
| 105 | 109 | status == "running" -> |
| 106 | 110 | fields |
| 107 | 111 | |
| 108 | − status in ~w(success failure cancelled skipped) -> | |
| 112 | + status in ~w(success failure cancelled skipped warning) -> | |
| 109 | 113 | fields |
| 110 | 114 | |> Map.put(:completed_at, now) |
| 111 | 115 | |> Map.put(:conclusion, Keyword.get(opts, :conclusion, status)) |
| 118 | 122 | end |
| 119 | 123 | |
| 120 | 124 | @doc "Terminal statuses — job won't transition out of these." |
| 121 | − def terminal?(s) when s in ~w(success failure cancelled skipped), do: true | |
| 125 | + def terminal?(s) when s in ~w(success failure cancelled skipped warning), do: true | |
| 122 | 126 | def terminal?(_), do: false |
| 123 | 127 | |
| 124 | 128 | def claim_changeset(job, runner_id) do |
modified
lib/git_gud_web/components/core_components.ex
+2
−0
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/runner_controller.ex
+11
−5
Click to load diff…
modified
lib/git_gud_web/live/ci_components.ex
+6
−1
Click to load diff…
modified
lib/git_gud_web/live/workflow_live/index.ex
+1
−0
Click to load diff…
modified
lib/git_gud_web/live/workflow_live/show.ex
+26
−2
Click to load diff…
added
priv/repo/migrations/20261027184400_add_warning_to_commit_status_states.exs
+27
−0
Click to load diff…
modified
test/git_gud/branch_protections/status_checks_test.exs
+9
−1
Click to load diff…
modified
test/git_gud/workflows/commit_status_test.exs
+36
−0
Click to load diff…
modified
test/git_gud/workflows/job_graph_test.exs
+84
−2
Click to load diff…
modified
test/git_gud_web/controllers/runner_controller_test.exs
+45
−3
Click to load diff…
added
test/git_gud_web/live/workflow_run_warning_test.exs
+76
−0
Click to load diff…
Parents: 8cfafe0