neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Plans: where CI differs from GitHub and Forgejo Actions
95a46eb · Gabriel Morell · 2026-10-17 05:06
Message
{commit_body(@commit)}
Files changed
added
docs/plans/ci-gaps.md
+94
−0
| 1 | +# CI gaps against GitHub / Forgejo Actions | |
| 2 | + | |
| 3 | +Status: audit of 2026-10-17, on master at bfce1e7 (after the path-filter, | |
| 4 | +`event_name` and `pull_request` work). Each gap was checked in the code; | |
| 5 | +the evidence column names the file and function. What forgejo-runner | |
| 6 | +expects was checked against its source | |
| 7 | +(code.forgejo.org/forgejo/runner: `internal/app/run/runner.go`, | |
| 8 | +`workflow.go`, `internal/pkg/report/reporter.go`) and Forgejo's server | |
| 9 | +(`services/actions/context.go`). | |
| 10 | + | |
| 11 | +Severity, highest first: | |
| 12 | + | |
| 13 | +- **security** — another repository's data, or credentials, are exposed. | |
| 14 | +- **silently wrong** — the workflow runs and does the wrong thing, or | |
| 15 | + reads empty values, with no error. | |
| 16 | +- **silent no-op** — a documented feature that never happens, with no | |
| 17 | + error. | |
| 18 | +- **loud** — fails visibly; the author finds out. | |
| 19 | + | |
| 20 | +## Ranked | |
| 21 | + | |
| 22 | +| # | Gap | Severity | Evidence | Plan | | |
| 23 | +|---|---|---|---|---| | |
| 24 | +| 1 | Artifact endpoints trust the URL's run id, not the token: any job can list, download and create artifacts in any repository's runs | security | `lib/git_gud_web/controllers/actions_artifact_controller.ex` `create/2`, `list/2`, `download/2` (ignores `run_id`); `RunnerJwtAuth` assigns `:runner_run_id`, unused | [workflow-artifacts-and-cache.md](workflow-artifacts-and-cache.md) | | |
| 25 | +| 2 | Job token stays valid 24 h after the job ends (publish packages, read private code) | security | `lib/git_gud/workflows/runner_jwt.ex` `verify/1` checks only signature and `exp` (`ttl_seconds` 86 400) | [workflow-job-token.md](workflow-job-token.md) | | |
| 26 | +| 3 | Cache uploads: any job can append to/finalize another repo's entry; chunks stored in arrival order (`actions/cache` uploads 4 at a time) → caches > 32 MB corrupt; O(n²) append | security + silently wrong | `lib/git_gud_web/controllers/actions_cache_controller.ex` `upload_chunk/2`, `finalize/2`; `lib/git_gud/workflows/actions_cache.ex` `append/2` (read-concat-write, `Content-Range` ignored) | [workflow-artifacts-and-cache.md](workflow-artifacts-and-cache.md) | | |
| 27 | +| 4 | Cancel doesn't stop a running job: the runner's next `UpdateTask` (result unspecified) flips it back to `running` and the reply never says cancelled | silently wrong | `lib/git_gud_web/controllers/runner_controller.ex` `update_task/2`, `map_result/1`; `WorkflowJob.status_changeset/3` has no terminal guard; `Workflows.cancel/1` | [workflow-cancellation-and-concurrency.md](workflow-cancellation-and-concurrency.md) | | |
| 28 | +| 5 | `secrets.GITHUB_TOKEN` is empty (only user secrets are sent) | silently wrong | `RunnerController.build_task/1` (`GitGud.Secrets.resolve_for/1` only) | [workflow-runner-context.md](workflow-runner-context.md) | | |
| 29 | +| 6 | Workflow-level `env:` and `defaults:` never reach the runner; `on:` replaced by `"push"` (loses dispatch input defaults) | silently wrong | `lib/git_gud/workflows/runner_payload.ex` `encode/1` | [workflow-runner-context.md](workflow-runner-context.md) | | |
| 30 | +| 7 | `needs.<id>.outputs` / `.result` always empty | silently wrong | `RunnerController.build_needs/1` returns `%{}`; snapshot kept in `Workflows.resolve_dependents/1` but never sent (emptied in ed9a7cd while `TaskNeed` field numbers were still swapped — fixed in c4d7080) | [workflow-job-graph.md](workflow-job-graph.md) | | |
| 31 | +| 8 | `if: always()` / `failure()` jobs are cancelled after a failure; a job skipped by its `if:` is recorded as success and its dependents run; job `continue-on-error` ignored | silently wrong | `Workflows.resolve_dependents/1` (cancels on failed need), `RunnerController.map_result(:RESULT_SKIPPED)` → `"success"`, `WorkflowJob.satisfies_dependency?/1`, `Workflows.maybe_complete_run/1` | [workflow-job-graph.md](workflow-job-graph.md) | | |
| 32 | +| 9 | `github.event` is an empty object (sent as a string); `run_id`, `run_attempt`, `workflow_ref`, `server_url`, `api_url`, ids missing — so `github.event.pull_request.number`, `head_commit.message`, `inputs.*` are blank | silently wrong | `RunnerController.build_context_struct/4` (`"event" => run.trigger`, all values `string_value` in `wrap_as_struct/1`); runner reads `taskContext["event"].GetStructValue()` | [workflow-runner-context.md](workflow-runner-context.md) | | |
| 33 | +| 10 | `concurrency:` (groups, `cancel-in-progress`) ignored — concurrent deploys | silently wrong | nothing in `lib/` reads `concurrency` (grep) | [workflow-cancellation-and-concurrency.md](workflow-cancellation-and-concurrency.md) | | |
| 34 | +| 11 | One commit status per workflow name: a push run and a `pull_request` run on the same head (or two workflows sharing a `name:`) overwrite each other; no per-job statuses; PR page doesn't refresh when CI finishes | silently wrong | `Workflows.update_run_commit_status/1` (context `gitgud-actions/<name>`, upsert on `(repository_id, sha, context)`); `lib/git_gud_web/live/pr_live/show.ex` no `ci_topic` subscription | [workflow-checks.md](workflow-checks.md) | | |
| 35 | +| 12 | `branches:`/`tags:` `*` crosses `/`; no `!` negation there; `?`/`+` mean "any char"/literal instead of GitHub's quantifiers (also in `paths:`) | silently wrong | `Yaml.glob_match?/2`, `Yaml.kind_match?/5`; `lib/git_gud/workflows/glob.ex` `translate/2` | [workflow-filter-patterns.md](workflow-filter-patterns.md) | | |
| 36 | +| 13 | `permissions:` ignored: tokens are never narrowed (silent over-privilege), and `contents: write` can't push (loud) | silently wrong / loud | `RunnerJwt.mint/2` (no scope claim); `GitGudWeb.Plugs.GitSmartHttp` (job token: upload-pack only); `GitGud.Packages.Access.allowed?/3` | [workflow-job-token.md](workflow-job-token.md) | | |
| 37 | +| 14 | `matrix: ${{ fromJSON(...) }}` collapses to one leg with an empty matrix; `max-parallel` ignored; a fully-excluded matrix runs one empty leg | silently wrong | `lib/git_gud/workflows/matrix.ex` `expand/1` (`expand(_) -> [%{}]`), `pin_definition/2` (drops strategy / `max-parallel`); `Workflows.do_claim_next_job/2` | [workflow-matrix.md](workflow-matrix.md) | | |
| 38 | +| 15 | `workflow_dispatch` inputs dropped (API accepts, discards); no "Run workflow" button | silently wrong | `lib/git_gud_web/controllers/api/v1/workflow_controller.ex` (module doc), `Workflows.dispatch_manual/4` | [workflow-triggers.md](workflow-triggers.md) (B) | | |
| 39 | +| 16 | `[skip ci]` / `skip-checks: true` ignored | silently wrong (extra runs) | `Workflows.dispatch/2`, `dispatch_pull_request/3` | [workflow-triggers.md](workflow-triggers.md) (A) | | |
| 40 | +| 17 | `on: schedule` never fires | silent no-op | no scheduler reads workflow crons (`config/config.exs` Oban cron has only internal workers) | [workflow-triggers.md](workflow-triggers.md) (C) | | |
| 41 | +| 18 | `pull_request` `closed` / `edited` / `labeled` / `unlabeled` never fire | silent no-op | `Workflows.dispatch_pull_request/3` (refuses non-open), only opened/synchronize/reopened enqueued (`PullRequests`) | [workflow-triggers.md](workflow-triggers.md) (E) | | |
| 42 | +| 19 | `on: workflow_run` never fires; the `workflow_run` **webhook** fires only for successful (or cancelled) runs, never failed ones | silent no-op / silently wrong | `Workflows.maybe_complete_run/1` (calls `notify_run_webhook/1` in the success branch only) | [workflow-triggers.md](workflow-triggers.md) (F) | | |
| 43 | +| 20 | `create` / `delete` never fire | silent no-op | `Repositories.Workers.CommitBackfill.perform/1` (returns before dispatch on delete; new refs only as `push`) | [workflow-triggers.md](workflow-triggers.md) (D) | | |
| 44 | +| 21 | Fork and federated PRs never run (deliberate, debug log only); no `pull_request_target` | silent no-op | `Workflows.dispatch_pull_request/3` | [workflow-fork-pull-requests.md](workflow-fork-pull-requests.md) | | |
| 45 | +| 22 | Branch protection can't require status checks: `require_status_checks` / `required_checks` columns exist, are cast, and are read by nothing; not in the UI | silent no-op | `lib/git_gud/branch_protections/branch_protection.ex`; `BranchProtections.evaluate/5`; `branch_protection_live/index.ex` | [workflow-checks.md](workflow-checks.md) | | |
| 46 | +| 23 | `actions/upload-artifact@v4` / `download-artifact@v4` unsupported (only the v1 pipelines API exists); README claims "artifacts API v4" | loud | `lib/git_gud_web/router.ex` (`/pipelines/workflows/:run_id/artifacts` only) | [workflow-artifacts-and-cache.md](workflow-artifacts-and-cache.md) fixes the README; v4 itself is listed there under "Out" as the next plan | | |
| 47 | + | |
| 48 | +## No plan | |
| 49 | + | |
| 50 | +| Gap / feature | Why no plan | | |
| 51 | +|---|---| | |
| 52 | +| `on: release` | gitgud has no releases (`lib/git_gud/release.ex` is the Mix release module). Plan it with a releases feature. | | |
| 53 | +| `environment:` (environment secrets, protection rules, deployments) | gitgud has no environments; the key passes through to act, which ignores it, and the job runs with repository secrets. Worth a plan only together with an environments feature. | | |
| 54 | +| OIDC (`permissions: id-token: write`, `ACTIONS_ID_TOKEN_REQUEST_*`) | Loud (the action errors when the variables are missing); needs an OIDC issuer in gitgud — a feature, not a gap fix. | | |
| 55 | +| Reusable workflows (`jobs.<id>.uses: ./.github/workflows/x.yml`) and `on: workflow_call` | **Unverified.** `Workflows.create_job_leg/3` creates such a job with `runs-on` defaulted to `ubuntu-latest` and no steps, and hands its definition to the runner; whether forgejo-runner/act expands a local `uses:` there was not tested. Test on a real runner first; plan only if it fails. | | |
| 56 | +| Branch-protection patterns, whose `*` also crosses `/` (`BranchProtections.matches_pattern?/2`) | Deliberately left: tightening it would silently **un**protect nested branches. A maintainer decision, noted in the filter-patterns plan. | | |
| 57 | +| `mix precommit` can't pass on master | Not a workflow gap: ~25 pre-existing compile warnings fail `--warnings-as-errors`, and a repo-wide `mix format` rewrites ~40 files (non-idempotent HEEx output). Every plan here works around it (compare warning lists, format only touched files). Worth a maintenance pass of its own. | | |
| 58 | + | |
| 59 | +## Works (checked, nothing to plan) | |
| 60 | + | |
| 61 | +- Step-level `if:`, `continue-on-error`, `timeout-minutes`, step `outputs`, | |
| 62 | + job/step `env`, job `defaults`, `services:`, `container:`, job | |
| 63 | + `timeout-minutes` — all live in the job's definition, which is | |
| 64 | + passed to act intact (`RunnerPayload.encode/1`, `job.definition`), and | |
| 65 | + act implements them. | |
| 66 | +- Job `outputs:` are collected (`update_task/2` → `Workflows.set_outputs/2`); | |
| 67 | + only their delivery to dependents is broken (#7). | |
| 68 | +- `runs-on` label routing incl. `self-hosted` and matrix-substituted | |
| 69 | + labels (`Workflows.do_claim_next_job/2`, `Matrix.substitute/2`). | |
| 70 | +- Static matrices with `include`/`exclude`, `fail-fast` for queued legs | |
| 71 | + (`Matrix.expand/1`, `Workflows.maybe_fail_fast/1`; running legs: #4's | |
| 72 | + plan). | |
| 73 | +- `push` with `branches`/`tags`/`paths`, `pull_request` | |
| 74 | + opened/synchronize/reopened, `workflow_dispatch` via the API, annotated | |
| 75 | + tags (`Workflows.dispatch/2`, `dispatch_pull_request/3`). | |
| 76 | +- Repository and organization secrets and variables | |
| 77 | + (`GitGud.Secrets.resolve_for/1`), reserved `GITHUB_*`/`GITEA_*`/ | |
| 78 | + `FORGEJO_*` names (`Secrets.ReservedNames`). | |
| 79 | +- Logs per job and step (`update_log/2` keyed by task id since 56749c0), | |
| 80 | + live log streaming, re-run of a whole run or a single job | |
| 81 | + (`Workflows.rerun/1`, `rerun_job/2`; the attempt counter is in the | |
| 82 | + context plan), stale-job recovery (`Workers.StaleJobReaper`). | |
| 83 | +- Cache lookup order and scoping on read (`ActionsCache.lookup/5` filters | |
| 84 | + by repository; PR fallback to the base branch is in the artifacts/cache | |
| 85 | + plan). | |
| 86 | +- Commit statuses on the commit, log and PR pages | |
| 87 | + (`core_components.ex` `commit_status_badges/1`). | |
| 88 | + | |
| 89 | +## Suggested order | |
| 90 | + | |
| 91 | +Artifacts-and-cache and job-token first (security). Then runner-context, | |
| 92 | +job-graph, cancellation-and-concurrency (the silently-wrong core). Then | |
| 93 | +checks, filter-patterns, matrix, triggers. Fork pull requests last (it | |
| 94 | +needs job-token). |
added
docs/plans/workflow-artifacts-and-cache.md
+194
−0
| 1 | +# Plan: artifacts and cache that stay in their own run and repository | |
| 2 | + | |
| 3 | +Status: **ready to execute.** Written to be carried out start to finish by | |
| 4 | +one agent without further questions. Independent of the other CI plans, | |
| 5 | +but read step 3 against [workflow-runner-context.md](workflow-runner-context.md): | |
| 6 | +that plan adds `run_id` to the job context, which is what the artifact | |
| 7 | +URLs are built from. | |
| 8 | + | |
| 9 | +## Why | |
| 10 | + | |
| 11 | +Two security holes and one data-corruption bug, all in the services a | |
| 12 | +running job calls back into. | |
| 13 | + | |
| 14 | +1. **Artifacts trust the URL, not the token.** | |
| 15 | + `GitGudWeb.ActionsArtifactController` takes `run_id` from the path | |
| 16 | + (`/api/actions/_apis/pipelines/workflows/:run_id/artifacts…`) and never | |
| 17 | + compares it with the JWT's `rid`, which `GitGudWeb.Plugs.RunnerJwtAuth` | |
| 18 | + has already put in `conn.assigns.runner_run_id`. `create/2` writes into | |
| 19 | + whatever run the path names; `list/2` lists any run; `download/2` ignores | |
| 20 | + `run_id` completely and serves any artifact id. So any job, in any | |
| 21 | + repository, holding its own valid job token, can read every artifact on | |
| 22 | + the instance (ids are sequential) and plant artifacts in other | |
| 23 | + repositories' runs. | |
| 24 | +2. **Cache uploads aren't checked against the repository either.** | |
| 25 | + `GitGudWeb.ActionsCacheController.upload_chunk/2` and `finalize/2` load | |
| 26 | + the entry by id (`ActionsCache.get!/1`) with no check that | |
| 27 | + `entry.repository_id` is the token's `pid`. A job can append to another | |
| 28 | + repository's reserved-but-unfinished cache entry (cache poisoning). | |
| 29 | +3. **Cache chunks are concatenated in arrival order.** | |
| 30 | + `ActionsCache.append/2` reads the whole stored object and writes it back | |
| 31 | + with the new body appended; the `Content-Range` header the client sends | |
| 32 | + is ignored. `actions/cache` uploads in 32 MB chunks, **four at a time** | |
| 33 | + (`uploadConcurrency`), so any cache over 32 MB is stored with its chunks | |
| 34 | + in whatever order the requests finished — and two concurrent appends can | |
| 35 | + also lose a chunk (read-modify-write race). The cache "saves", the next | |
| 36 | + job restores a corrupt tarball. It is also O(n²) in memory for a large | |
| 37 | + cache. | |
| 38 | + | |
| 39 | +And two smaller ones the same work should fix: | |
| 40 | + | |
| 41 | +4. The README says "GitHub Actions artifacts API v4". The code implements a | |
| 42 | + simplified v1 "pipelines" API only. `actions/upload-artifact@v4` and | |
| 43 | + `download-artifact@v4` (what GitHub's docs and every new workflow use) | |
| 44 | + speak a different, Twirp-based protocol | |
| 45 | + (`twirp/github.actions.results.api.v1.ArtifactService/*`, located via | |
| 46 | + `ACTIONS_RESULTS_URL`) and fail. That failure is loud, so this plan only | |
| 47 | + corrects the README and records v4 as a follow-up (see "Out"). | |
| 48 | +5. A PR run's cache falls back only to the default branch | |
| 49 | + (`ActionsCacheController.refs_for/1`); GitHub falls back to the PR's | |
| 50 | + **base** branch. | |
| 51 | + | |
| 52 | +## Semantics to match | |
| 53 | + | |
| 54 | +- An artifact belongs to one workflow run. A job may create, list and | |
| 55 | + download artifacts **of its own run only** (that is how | |
| 56 | + `upload-artifact@v3`/`download-artifact@v3` share files between jobs of a | |
| 57 | + run). Anything else is 404 (not 403: don't confirm the id exists). | |
| 58 | +- A cache entry belongs to one repository. Only a job of that repository | |
| 59 | + may upload to, finalize, or read it. (Read was already scoped: | |
| 60 | + `ActionsCache.lookup/5` filters on `repository_id`.) | |
| 61 | +- Cache upload chunks carry `Content-Range: bytes <start>-<end>/*`; the | |
| 62 | + stored object is the chunks **placed at their offsets**. Finalize gives | |
| 63 | + the total `size`. | |
| 64 | +- Cache restore order for a PR run: current ref, then the PR's base | |
| 65 | + branch, then the default branch. | |
| 66 | + | |
| 67 | +## Where things are | |
| 68 | + | |
| 69 | +| | | | |
| 70 | +|---|---| | |
| 71 | +| Routes | `lib/git_gud_web/router.ex`, `scope "/api/actions/_apis"` with `pipe_through :runner_jwt` | | |
| 72 | +| JWT plug | `lib/git_gud_web/plugs/runner_jwt_auth.ex` — assigns `:runner_jwt_claims`, `:runner_job_id`, `:runner_run_id` | | |
| 73 | +| Artifacts | `lib/git_gud_web/controllers/actions_artifact_controller.ex` (`create/2`, `upload/2`, `finalize/2`, `list/2`, `download/2`); schema `lib/git_gud/workflows/workflow_artifact.ex` | | |
| 74 | +| Cache | `lib/git_gud_web/controllers/actions_cache_controller.ex` (`get_cache/2`, `reserve_cache/2`, `upload_chunk/2`, `finalize/2`, `refs_for/1`); `lib/git_gud/workflows/actions_cache.ex` (`reserve/4`, `append/2`, `finalize/2`); schema `lib/git_gud/workflows/cache_entry.ex` | | |
| 75 | +| Storage | `GitGud.Storage` (`put/4`, `get/2`, `presign_url/4`) — check what else the behaviour offers (multipart? append?) before choosing step 4's design | | |
| 76 | +| Tests | `test/git_gud_web/controllers/actions_endpoints_test.exs`, `test/git_gud/workflows/actions_cache_test.exs`, `test/git_gud/workflows/runner_jwt_test.exs` (minting a token for a job) | | |
| 77 | + | |
| 78 | +## Steps | |
| 79 | + | |
| 80 | +### 1. Artifacts: the token's run, not the path's | |
| 81 | + | |
| 82 | +In `ActionsArtifactController`, add a private `own_run(conn, run_id_param)` | |
| 83 | +that returns `{:ok, rid}` when `String.to_integer(run_id_param) == | |
| 84 | +conn.assigns.runner_run_id`, else `:error`; an unparsable id is `:error`. | |
| 85 | +Every action starts with it and answers **404** on `:error`. | |
| 86 | + | |
| 87 | +- `create/2`, `list/2`, `finalize/2`: as above. | |
| 88 | +- `upload/2`: already compares the artifact's run with the path; also | |
| 89 | + require the path's run to be the token's. | |
| 90 | +- `download/2`: load the artifact and require | |
| 91 | + `artifact.workflow_run_id == conn.assigns.runner_run_id`, else 404. Use | |
| 92 | + `Repo.get/2`, not `get!/2` (a missing id is 404, not a 500). | |
| 93 | +- `upload/2` and `upload_chunk/2` read bodies with fixed caps | |
| 94 | + (`1_000_000_000`, `200_000_000`). Leave the values; just make the oversize | |
| 95 | + case answer 413 instead of crashing (`Plug.Conn.read_body/2` returns | |
| 96 | + `{:more, …}` past `:length`). | |
| 97 | + | |
| 98 | +Also: if `run_id` in the path is `0`/empty because the job had no | |
| 99 | +`github.run_id` (see the context plan), the old behaviour wrote into run 1 | |
| 100 | +of some other repository. After this step it is a 404 — loud, and fixed for | |
| 101 | +real by the context plan's `run_id`. | |
| 102 | + | |
| 103 | +### 2. Cache: the token's repository | |
| 104 | + | |
| 105 | +In `ActionsCacheController.upload_chunk/2` and `finalize/2`, load with | |
| 106 | +`ActionsCache.get/1` (add it: `Repo.get(CacheEntry, id)`) and require | |
| 107 | +`entry.repository_id == jwt_repo_id(conn)`; otherwise 404. Also refuse | |
| 108 | +(409) an upload to an entry whose `archived_at` is set — a finalized entry | |
| 109 | +is immutable. | |
| 110 | + | |
| 111 | +### 3. Cache: chunks at their offsets | |
| 112 | + | |
| 113 | +Replace read-append-write with offset-placed chunks: | |
| 114 | + | |
| 115 | +- Parse `Content-Range: bytes S-E/*` in `upload_chunk/2` (missing or | |
| 116 | + malformed → 400). Check `E - S + 1 == byte_size(body)`. | |
| 117 | +- Store each chunk as its own object, `"#{entry.storage_key}.part.#{S}"`, | |
| 118 | + with `Storage.put/4`. Concurrent chunks then never touch the same object. | |
| 119 | +- `finalize/2`: list the parts for the entry (keep the offsets in a new | |
| 120 | + table `cache_entry_parts (cache_entry_id, offset, size, storage_key)` | |
| 121 | + written by `upload_chunk/2` — simpler and more portable than listing the | |
| 122 | + bucket; `mix ecto.gen.migration add_cache_entry_parts`), check they tile | |
| 123 | + `0..size-1` with no gaps or overlaps (else 400 and leave the entry | |
| 124 | + unarchived), then assemble them **in offset order** into | |
| 125 | + `entry.storage_key` and delete the parts. Assemble by streaming if | |
| 126 | + `GitGud.Storage` can (look at the behaviour; Garage is S3, so a multipart | |
| 127 | + upload or server-side compose may exist); otherwise concatenate in | |
| 128 | + memory — still correct, and the 200 MB-per-request cap bounds each read. | |
| 129 | +- `ActionsCache.append/2` is then unused: remove it. | |
| 130 | +- The `CacheEviction` worker (`lib/git_gud/workflows/workers/cache_eviction.ex`) | |
| 131 | + must also delete orphaned parts of entries that were never finalized: | |
| 132 | + check what it does with unarchived entries today and extend it. | |
| 133 | + | |
| 134 | +### 4. Cache: a PR falls back to its base branch | |
| 135 | + | |
| 136 | +In `refs_for/1`, when the run's `trigger == "pull_request"` and it has | |
| 137 | +`pr_base_ref` (column added by the pull_request plan), the fallback list is | |
| 138 | +`[run.head_ref, "refs/heads/" <> run.pr_base_ref, default_ref]`. | |
| 139 | +`ActionsCache.lookup/5` takes a single `default_ref`; change it to take a | |
| 140 | +list of fallback refs (`[ref | fallbacks]`) and keep its exact-then-prefix | |
| 141 | +order across them. Saving still goes to the run's own ref. | |
| 142 | + | |
| 143 | +### 5. README | |
| 144 | + | |
| 145 | +In README's CI list, replace "GitHub Actions artifacts API v4" with what is | |
| 146 | +true: the v1 pipelines artifact API (`actions/upload-artifact@v3` / | |
| 147 | +`download-artifact@v3`), scoped to the run; `@v4` not yet supported. State | |
| 148 | +the cache's restore order. | |
| 149 | + | |
| 150 | +## Tests | |
| 151 | + | |
| 152 | +`actions_endpoints_test.exs` (mint tokens with `RunnerJwt.mint/2` for jobs | |
| 153 | +in two different repositories' runs): | |
| 154 | + | |
| 155 | +- job A creates an artifact in its run; job B (other repo) gets 404 on | |
| 156 | + create/list/upload/download against A's run and on download of A's | |
| 157 | + artifact id through B's own run path; | |
| 158 | +- job A lists and downloads its own artifact (still works); | |
| 159 | +- a second job of the **same** run can list and download it (cross-job | |
| 160 | + sharing still works). | |
| 161 | + | |
| 162 | +`actions_cache_test.exs` / controller test: | |
| 163 | + | |
| 164 | +- B's token gets 404 on `upload_chunk`/`finalize` of A's entry; | |
| 165 | +- three chunks uploaded **out of order** (offsets 2N, 0, N) then finalize → | |
| 166 | + the restored bytes equal the original; | |
| 167 | +- a gap (chunks 0 and 2N only) → finalize 400, entry not archived; | |
| 168 | +- upload after finalize → 409; | |
| 169 | +- PR run: an entry saved on the base branch (not the default branch) is | |
| 170 | + found by a PR run's lookup; an entry on an unrelated branch is not. | |
| 171 | + | |
| 172 | +## Out | |
| 173 | + | |
| 174 | +- Artifacts v4 (`ArtifactService` Twirp: `CreateArtifact`, | |
| 175 | + `FinalizeArtifact`, `ListArtifacts`, `GetSignedArtifactURL`, blob upload | |
| 176 | + to a signed URL, `ACTIONS_RESULTS_URL` in the job env). Worth its own | |
| 177 | + plan once this one lands; Forgejo implements it in | |
| 178 | + `routers/api/actions/artifactsv4.go`, which is the reference. | |
| 179 | +- Cache service v2 (`actions/cache@v4` with `ACTIONS_CACHE_SERVICE_V2`). | |
| 180 | + First check whether jobs reach this cache at all: forgejo-runner ships | |
| 181 | + its own cache server and may point `ACTIONS_CACHE_URL` at it regardless | |
| 182 | + of the `ACTIONS_CACHE_URL` key gitgud puts in the task context | |
| 183 | + (`runner_controller.ex`, `build_context_struct/4`). Record what you find | |
| 184 | + in the README. | |
| 185 | + | |
| 186 | +## Done when | |
| 187 | + | |
| 188 | +- `mix test` passes; no new compile warnings (the repo has pre-existing | |
| 189 | + ones: compare the warning list before and after, `mix compile --force`). | |
| 190 | + `mix format` only the files you touched — a repo-wide `mix format` | |
| 191 | + rewrites ~40 unrelated files. | |
| 192 | +- One commit, in the repository's message style, naming the cross-repo | |
| 193 | + artifact access as the reason. | |
| 194 | +- **Not pushed.** Pushing gitgud deploys the forge. |
added
docs/plans/workflow-cancellation-and-concurrency.md
+204
−0
| 1 | +# Plan: cancelling a running job, and `concurrency:` | |
| 2 | + | |
| 3 | +Status: **ready to execute.** Written to be carried out start to finish by | |
| 4 | +one agent without further questions. Steps 1–2 (cancellation reaching the | |
| 5 | +runner) stand alone and are worth committing on their own first; steps 3–7 | |
| 6 | +(`concurrency:`) build on them. Two commits. | |
| 7 | + | |
| 8 | +## Why | |
| 9 | + | |
| 10 | +1. **Cancel doesn't cancel a running job.** `Workflows.cancel/1` marks the | |
| 11 | + run's queued and running jobs `cancelled` in the database and stops | |
| 12 | + there. forgejo-runner learns of a cancellation only from the reply to | |
| 13 | + its periodic `UpdateTask` (`internal/pkg/report/reporter.go`, | |
| 14 | + `ReportState`: if the reply's `state.result` is `RESULT_CANCELLED` or | |
| 15 | + `RESULT_FAILURE`, it cancels the job's context). But while a job runs the | |
| 16 | + runner reports `RESULT_UNSPECIFIED`, and | |
| 17 | + `RunnerController.update_task/2` maps that to `"running"` | |
| 18 | + (`map_result/1`) and writes it back with `Workflows.update_job_status/3` | |
| 19 | + — so the job flips from `cancelled` back to `running`, the reply says | |
| 20 | + `RESULT_UNSPECIFIED`, and the runner carries on to the end. The run page | |
| 21 | + says "cancelled" while the job keeps deploying. Nothing in | |
| 22 | + `WorkflowJob.status_changeset/3` stops a terminal job from becoming | |
| 23 | + non-terminal again. | |
| 24 | +2. **Fail-fast leaves running legs running.** `Workflows.maybe_fail_fast/1` | |
| 25 | + cancels only `queued`/`blocked` siblings ("killing a mid-flight job loses | |
| 26 | + its logs for no gain"). GitHub cancels in-progress legs too; once step 1 | |
| 27 | + exists, cancellation keeps the logs (the runner still uploads them). | |
| 28 | +3. **`concurrency:` is ignored.** Nothing in `lib/` reads it (grep | |
| 29 | + `concurrency`). A deploy workflow with | |
| 30 | + `concurrency: {group: deploy-prod, cancel-in-progress: false}` runs two | |
| 31 | + deploys at once when two pushes land close together; a PR workflow with | |
| 32 | + `cancel-in-progress: true` burns runners on superseded commits. Both are | |
| 33 | + silent. | |
| 34 | + | |
| 35 | +## Semantics to match (GitHub, "Control the concurrency of workflows and jobs") | |
| 36 | + | |
| 37 | +- `concurrency` at the workflow top level or on a job: either a string | |
| 38 | + (the group) or `{group: <string>, cancel-in-progress: <bool>}`. Both | |
| 39 | + values may be expressions (`${{ … }}`). | |
| 40 | +- Within a group (scoped to the repository): at most **one running** and | |
| 41 | + **one pending** run (or job). A new member arriving while one is running: | |
| 42 | + - `cancel-in-progress: true` → the running member and any pending member | |
| 43 | + are cancelled; the new one starts; | |
| 44 | + - otherwise → the new one waits as pending; an **already pending** member | |
| 45 | + is cancelled (replaced by the newer one). | |
| 46 | +- When the running member finishes, the pending one starts. | |
| 47 | +- Workflow-level groups are decided when the run is created; job-level | |
| 48 | + groups when the job would otherwise be queued (it can use `matrix` and | |
| 49 | + `needs`). | |
| 50 | + | |
| 51 | +## Where things are | |
| 52 | + | |
| 53 | +| | | | |
| 54 | +|---|---| | |
| 55 | +| Runner's view | `lib/git_gud_web/controllers/runner_controller.ex` `update_task/2`, `map_result/1`, `result_atom/1` | | |
| 56 | +| Job lifecycle | `lib/git_gud/workflows.ex` `update_job_status/3`, `cancel/1`, `maybe_fail_fast/1`, `maybe_complete_run/1`, `create_run/2`, `create_job_leg/3`, `resolve_dependents/1`, `do_claim_next_job/2` (claims `status == "queued"` only) | | |
| 57 | +| Schemas | `lib/git_gud/workflows/workflow_job.ex` (`@valid_statuses`, `status_changeset/3`, `terminal?/1`), `workflow_run.ex` | | |
| 58 | +| Stale job reaper | `lib/git_gud/workflows/workers/stale_job_reaper.ex` — make sure it doesn't resurrect cancelled jobs | | |
| 59 | +| UI | `lib/git_gud_web/live/workflow_live/show.ex`, `index.ex`, `lib/git_gud_web/live/ci_components.ex` (status badges) | | |
| 60 | +| Tests | `test/git_gud/workflows/rerun_cancel_test.exs`, `matrix_dispatch_test.exs`, `test/git_gud_web/controllers/runner_controller_test.exs` | | |
| 61 | + | |
| 62 | +## Steps | |
| 63 | + | |
| 64 | +### 1. A terminal job stays terminal; the runner is told | |
| 65 | + | |
| 66 | +- In `update_task/2`: if the job is already terminal | |
| 67 | + (`WorkflowJob.terminal?/1`) and the runner reports `RESULT_UNSPECIFIED` | |
| 68 | + (still running), **don't** update its status; reply with | |
| 69 | + `result_atom(job.status)` — `RESULT_CANCELLED` for a cancelled job, which | |
| 70 | + makes the runner stop. Still apply step states and log rows (they are | |
| 71 | + the job's real history). | |
| 72 | +- When the runner then sends its final result for a job the server | |
| 73 | + already cancelled, keep `cancelled` (the server's decision wins) but | |
| 74 | + record the step states; reply `RESULT_CANCELLED`. | |
| 75 | +- Guard at the schema too: `WorkflowJob.status_changeset/3` refuses a | |
| 76 | + transition from a terminal status to `running`/`queued`/`blocked` unless | |
| 77 | + given `force: true`; `rerun_job/2` (the one legitimate path back) passes | |
| 78 | + it. Grep for every `status_changeset(` caller and check each. | |
| 79 | +- `StaleJobReaper` must only touch `running` jobs (check it). | |
| 80 | + | |
| 81 | +### 2. Fail-fast cancels running legs | |
| 82 | + | |
| 83 | +`maybe_fail_fast/1`: include `running` siblings. They become `cancelled` | |
| 84 | +in the database; step 1 makes their runners stop. Update the comment that | |
| 85 | +explains why it didn't. Commit steps 1–2. | |
| 86 | + | |
| 87 | +### 3. A small expression evaluator for groups | |
| 88 | + | |
| 89 | +Group names are almost always built from a few context values: | |
| 90 | +`${{ github.workflow }}-${{ github.ref }}`, | |
| 91 | +`${{ github.event.pull_request.number || github.ref }}`, | |
| 92 | +`${{ github.head_ref || github.run_id }}`, `${{ inputs.env }}`, | |
| 93 | +`${{ matrix.target }}`. Add `GitGud.Workflows.Expr` with | |
| 94 | +`interpolate(string, contexts) :: {:ok, String.t()} | :error` that | |
| 95 | +replaces each `${{ … }}` with the evaluated value, supporting: property | |
| 96 | +paths (`a.b.c`, `a['b']`) into `contexts` (string-keyed maps: `github`, | |
| 97 | +`inputs`, `vars`, `matrix`, `needs`), string/number/boolean/null literals, | |
| 98 | +`||`, `&&`, `==`, `!=`, `!`, parentheses, and `format('{0}-{1}', …)`. | |
| 99 | +Values stringify as GitHub does (null → `""`, booleans `true`/`false`). | |
| 100 | +Anything else (other functions, unknown syntax) → `:error`. Keep it a | |
| 101 | +recursive-descent parser in one module; no new dependency. | |
| 102 | + | |
| 103 | +Contexts are built from what the run already stores: `github` = the same | |
| 104 | +keys the runner context gets (`RunnerController.build_context_struct/4` | |
| 105 | +— extract the map-building half into a public function in | |
| 106 | +`GitGud.Workflows` or a new `GitGud.Workflows.Context` module, so the | |
| 107 | +runner and the evaluator can't disagree), `inputs` from the event payload | |
| 108 | +if [workflow-runner-context.md](workflow-runner-context.md) has been | |
| 109 | +executed (else `%{}`), `vars` from `GitGud.Secrets.resolve_for/1`'s vars, | |
| 110 | +`matrix` = the job's combination, `needs` = the job's `needs_snapshot`. | |
| 111 | + | |
| 112 | +If evaluation returns `:error`, the run/job proceeds **without** | |
| 113 | +concurrency control, and the reason is logged at warning level and shown | |
| 114 | +on the run (see step 6). Never block a run on an expression we couldn't | |
| 115 | +read. | |
| 116 | + | |
| 117 | +### 4. Store the group | |
| 118 | + | |
| 119 | +Migration (`mix ecto.gen.migration add_concurrency_to_workflow_runs_and_jobs`): | |
| 120 | +`concurrency_group :string` and `cancel_in_progress :boolean, default: | |
| 121 | +false` on both `workflow_runs` and `workflow_jobs`, plus an index on | |
| 122 | +`(repository_id, concurrency_group)` for runs; for jobs index | |
| 123 | +`(concurrency_group)` and join through the run for the repository. Run it | |
| 124 | +on dev and test. | |
| 125 | + | |
| 126 | +New job status `waiting` (held by concurrency, not claimable): add to | |
| 127 | +`WorkflowJob.@valid_statuses`, not terminal, and to the UI badge helpers. | |
| 128 | +`maybe_complete_run/1` treats it as in flight. | |
| 129 | + | |
| 130 | +### 5. Workflow-level concurrency | |
| 131 | + | |
| 132 | +In `create_run/2` (inside its transaction), read | |
| 133 | +`workflow_doc["concurrency"]`; evaluate the group with the run's | |
| 134 | +contexts. If a group results: | |
| 135 | + | |
| 136 | +- lock the group's members (`SELECT … FOR UPDATE` on runs of this repo in | |
| 137 | + this group with status `queued`/`running`, plus runs with any job | |
| 138 | + `waiting`), then: | |
| 139 | + - `cancel-in-progress` → `Workflows.cancel/1` each of them (after | |
| 140 | + commit: do the cancels after the transaction returns, collecting the | |
| 141 | + ids inside it, to avoid nesting `cancel/1`'s own transaction); | |
| 142 | + - else → cancel any member that has not started (all its jobs | |
| 143 | + `waiting`), and if one is running, create this run's initially-queued | |
| 144 | + jobs as `waiting` instead of `queued`. | |
| 145 | +- When a run reaches a terminal state (`maybe_complete_run/1` and | |
| 146 | + `cancel/1`), release its group: the oldest run in the group whose jobs | |
| 147 | + are `waiting` gets them moved to `queued` (or `blocked` when they have | |
| 148 | + `needs`, matching `create_job_leg/3`'s rule). | |
| 149 | + | |
| 150 | +### 6. Job-level concurrency | |
| 151 | + | |
| 152 | +Same rules per job, evaluated when the job would become `queued` (in | |
| 153 | +`create_job_leg/3` for jobs without `needs`, and in | |
| 154 | +`resolve_dependents/1` when promoting), with `matrix` and `needs` in the | |
| 155 | +contexts. The group's members are jobs (any repository-scoped run) in | |
| 156 | +`queued`/`running`/`waiting`. Release in `update_job_status/3` when the | |
| 157 | +job becomes terminal. | |
| 158 | + | |
| 159 | +On the run page, show a waiting job as "Waiting for <group>" and a | |
| 160 | +cancelled-by-concurrency job with that reason: add a nullable | |
| 161 | +`status_reason :string` to `workflow_jobs` in the same migration and set | |
| 162 | +it in steps 5–6. | |
| 163 | + | |
| 164 | +### 7. Payload and docs | |
| 165 | + | |
| 166 | +Make sure `RunnerPayload.encode/1` does **not** pass `concurrency` to the | |
| 167 | +runner (act ignores it today; it should never decide). README CI list: | |
| 168 | +`concurrency` (workflow and job level, `cancel-in-progress`), the supported | |
| 169 | +expression subset, and that an unreadable group means no concurrency | |
| 170 | +control. | |
| 171 | + | |
| 172 | +## Tests | |
| 173 | + | |
| 174 | +- Runner protocol (`runner_controller_test.exs`): claim a job, cancel its | |
| 175 | + run, then `UpdateTask` with `RESULT_UNSPECIFIED` → reply | |
| 176 | + `RESULT_CANCELLED`, job still `cancelled`; a final `RESULT_SUCCESS` after | |
| 177 | + cancel → job stays `cancelled`. | |
| 178 | +- `rerun_job/2` still revives a cancelled job. | |
| 179 | +- Fail-fast: a running sibling leg is cancelled. | |
| 180 | +- `Expr` unit tests: each pattern listed in step 3; `||` fallbacks with | |
| 181 | + null and empty string (GitHub: empty string is falsy); unknown function | |
| 182 | + → `:error`. | |
| 183 | +- Workflow concurrency (on-disk dispatch, `path_dispatch_test.exs` style, | |
| 184 | + with `concurrency: {group: deploy, cancel-in-progress: false}`): first | |
| 185 | + push → jobs queued; second → jobs waiting; third → second run cancelled, | |
| 186 | + third waiting; completing the first (drive `update_job_status/3`) → | |
| 187 | + third queued. | |
| 188 | +- `cancel-in-progress: true`: second push cancels the first run (its | |
| 189 | + running job is `cancelled`, and its runner would be told — assert via | |
| 190 | + `update_task` as above). | |
| 191 | +- Group `${{ github.workflow }}-${{ github.ref }}`: pushes to two different | |
| 192 | + branches don't interact. | |
| 193 | +- Job-level group with `${{ matrix.target }}`: legs with the same target | |
| 194 | + serialize, different targets don't. | |
| 195 | +- An unparseable group (`${{ toJSON(github) }}`) → runs normally, warning | |
| 196 | + logged. | |
| 197 | + | |
| 198 | +## Done when | |
| 199 | + | |
| 200 | +- `mix test` passes; no new compile warnings (compare `mix compile --force` | |
| 201 | + warning lists before/after); `mix format` only touched files. | |
| 202 | +- Two commits in the repository's message style: cancellation, then | |
| 203 | + concurrency. | |
| 204 | +- **Not pushed.** |
added
docs/plans/workflow-checks.md
+174
−0
| 1 | +# Plan: one commit status per job, and branch protection that can require them | |
| 2 | + | |
| 3 | +Status: **ready to execute.** Written to be carried out start to finish by | |
| 4 | +one agent without further questions. Independent of the other CI plans; | |
| 5 | +if [workflow-job-graph.md](workflow-job-graph.md) has been executed, jobs | |
| 6 | +can also be `skipped` — map that as step 1 says. | |
| 7 | + | |
| 8 | +## Why | |
| 9 | + | |
| 10 | +1. **Two runs on one commit overwrite each other's status.** | |
| 11 | + `Workflows.update_run_commit_status/1` writes one row per run with | |
| 12 | + context `"gitgud-actions/" <> (run.workflow_name || run.workflow_path)`, | |
| 13 | + upserting on `(repository_id, sha, context)`. A same-repository pull | |
| 14 | + request's head commit gets a `push` run (the push to the branch) and a | |
| 15 | + `pull_request` run (synchronize) of the **same workflow** — one context, | |
| 16 | + so whichever finished last is what the PR page shows: a failing PR run | |
| 17 | + can be hidden by a passing push run. Two workflow files that share a | |
| 18 | + `name:` collide the same way. | |
| 19 | +2. **Statuses are per workflow, not per job.** GitHub and Forgejo report | |
| 20 | + each job: `<workflow> / <job> (<event>)` (Forgejo: | |
| 21 | + `services/actions/commit_status.go`). A workflow with `test` and | |
| 22 | + `lint` shows one pill here, so you can't see which job failed, and | |
| 23 | + branch protection (below) couldn't require `lint` alone. | |
| 24 | +3. **Branch protection can't require checks.** `BranchProtection` has | |
| 25 | + `require_status_checks` and `required_checks` columns | |
| 26 | + (`lib/git_gud/branch_protections/branch_protection.ex`), cast by its | |
| 27 | + changeset, but nothing reads them: `BranchProtections.evaluate/5` checks | |
| 28 | + force-push and approvals only, and the settings UI | |
| 29 | + (`lib/git_gud_web/live/branch_protection_live/index.ex`) doesn't offer | |
| 30 | + them. Anything that sets them through the changeset gets a rule that | |
| 31 | + silently enforces nothing. | |
| 32 | +4. **The PR page doesn't update when CI finishes.** | |
| 33 | + `lib/git_gud_web/live/pr_live/show.ex` renders | |
| 34 | + `<.commit_status_badges repo={@repo} sha={@pr.head_sha} />` (line ~533) | |
| 35 | + but doesn't subscribe to `Workflows.ci_topic/0`, so pills stay "pending" | |
| 36 | + until a reload. | |
| 37 | + | |
| 38 | +## Semantics to match | |
| 39 | + | |
| 40 | +- Context per job: `"#{workflow_name} / #{job_name} (#{event})"`, where | |
| 41 | + `workflow_name` = `run.workflow_name || Path.basename(run.workflow_path)`, | |
| 42 | + `job_name` = the job row's `name` (already `build (arm64, 28)` for a | |
| 43 | + matrix leg), `event` = `run.trigger`. That is Forgejo's format, so | |
| 44 | + required-check names people copy from Forgejo match. | |
| 45 | +- State: queued/blocked/waiting/running → `pending`; success → `success`; | |
| 46 | + failure and cancelled → `failure` (today's mapping, and Forgejo's in | |
| 47 | + `services/actions/commit_status.go`, `toCommitStatus`); skipped → | |
| 48 | + `success` with description "Skipped" (Forgejo maps skipped to success, | |
| 49 | + and GitHub counts a skipped required check as passing). | |
| 50 | +- Required checks (GitHub/Forgejo): a PR may be merged into a branch whose | |
| 51 | + rule has `require_status_checks` only when, for the PR head commit, | |
| 52 | + every pattern in `required_checks` matches at least one status and | |
| 53 | + **every** matching status is `success`. Patterns are globs (`*` matches | |
| 54 | + any run of characters, including `/` and spaces — Forgejo uses glob | |
| 55 | + patterns for this field). An empty `required_checks` with | |
| 56 | + `require_status_checks` on means "every status on the head commit must | |
| 57 | + be success, and there must be at least one". | |
| 58 | +- Direct pushes to a protected branch are **not** blocked by status checks | |
| 59 | + (no PR, no head to have checks on — Forgejo's behaviour); say so in the | |
| 60 | + UI hint. | |
| 61 | +- Admin override: `merge!/3`'s `override: true` already bypasses review | |
| 62 | + rules; let it bypass status checks too (same audit trail). | |
| 63 | + | |
| 64 | +## Where things are | |
| 65 | + | |
| 66 | +| | | | |
| 67 | +|---|---| | |
| 68 | +| Status writer | `lib/git_gud/workflows.ex`: `update_run_commit_status/1` (called from `create_run/2`, `maybe_complete_run/1`, `cancel/1`), `update_job_status/3`, `create_job_leg/3` | | |
| 69 | +| Status schema | `lib/git_gud/workflows/commit_status.ex`; reader `Workflows.list_commit_statuses/2` | | |
| 70 | +| Pills | `lib/git_gud_web/components/core_components.ex` `commit_status_badges/1`, `display_context/1` (strips `gitgud-actions/`) | | |
| 71 | +| PR page | `lib/git_gud_web/live/pr_live/show.ex` (mount, `handle_info/2`, merge box), `PullRequests.merge_blockers/2` (review rules, shown on the page) | | |
| 72 | +| Protection | `lib/git_gud/branch_protections.ex` `check_ref_update/5`, `evaluate/5`; schema above; callers `PullRequests.merge!/3`, `GitGud.Merges` (`check_protection/5`), `GitGudWeb.PreReceiveController` | | |
| 73 | +| Settings UI | `lib/git_gud_web/live/branch_protection_live/index.ex` | | |
| 74 | +| Tests | `test/git_gud/branch_protections_test.exs`, `test/git_gud/workflows/rerun_cancel_test.exs`, `test/git_gud_web/live/pr_live/` | | |
| 75 | + | |
| 76 | +## Steps | |
| 77 | + | |
| 78 | +### 1. Per-job statuses | |
| 79 | + | |
| 80 | +Add `Workflows.update_job_commit_status(job, run)` writing the context and | |
| 81 | +state above, `target_url` = the run page anchored to the job if the run | |
| 82 | +page has job anchors (check `workflow_live/show.ex`; else the run page), | |
| 83 | +`description` = e.g. "Successful in 2m", "Failing after 40s", "Waiting to | |
| 84 | +run", "Skipped". Call it: | |
| 85 | +- for every job row created in `create_job_leg/3` (initial pending), | |
| 86 | +- in `update_job_status/3` after the update, | |
| 87 | +- in `cancel/1` for each job it cancels (it uses `update_all`; select the | |
| 88 | + affected rows first, or re-read them after). | |
| 89 | + | |
| 90 | +Stop writing the per-run `gitgud-actions/<workflow>` status (delete the | |
| 91 | +writer, keep `broadcast_ci_change/1` — move that call into the new | |
| 92 | +function). Old rows stay on old commits; `display_context/1` keeps | |
| 93 | +stripping their prefix. | |
| 94 | + | |
| 95 | +### 2. PR page refreshes | |
| 96 | + | |
| 97 | +In `pr_live/show.ex` `mount/3` (when `connected?`), subscribe to | |
| 98 | +`Workflows.ci_topic/0`; on `{:ci_run_changed, run_id}` re-render the | |
| 99 | +status pills if the run is in this repository (load the run's | |
| 100 | +`repository_id` — cheap) — e.g. bump an assign the badges component reads | |
| 101 | +so it reloads. | |
| 102 | + | |
| 103 | +### 3. Required checks: evaluation | |
| 104 | + | |
| 105 | +In `BranchProtections`, add `check_statuses(rule, repo, head_sha)` | |
| 106 | +returning `:ok` or `{:error, {:status_checks, missing :: [pattern], | |
| 107 | +failing :: [context]}}`, using `Workflows.list_commit_statuses/2` and a | |
| 108 | +glob matcher where `*` matches anything (a private regex helper; don't use | |
| 109 | +`GitGud.Workflows.Glob`, whose `*` stops at `/`). | |
| 110 | + | |
| 111 | +Restructure `evaluate/5` from a `cond` into a `with` chain so all rules | |
| 112 | +apply in turn (today the approvals clause returns early and nothing after | |
| 113 | +it could run): force-push → approvals → status checks. Status checks apply | |
| 114 | +only when `opts[:pull_request_id]` is given (a merge); pass the PR head | |
| 115 | +sha in opts (`head_sha:`) from `merge!/3` and `Merges` (check what | |
| 116 | +`Merges` has in hand: `analysis.theirs_sha` is the head being merged). | |
| 117 | + | |
| 118 | +`merge!/3`: `override: true` skips the status-check error as it skips | |
| 119 | +review blocks. Find how override is threaded into `check_ref_update` today | |
| 120 | +(it isn't — it's only used by `check_review_block/3`) and pass it. | |
| 121 | + | |
| 122 | +### 4. Required checks: on the PR page | |
| 123 | + | |
| 124 | +Extend what the merge box shows (next to `PullRequests.merge_blockers/2`'s | |
| 125 | +review blockers): "Required checks: <pattern> missing", "<context> | |
| 126 | +failing", and disable the merge button the same way review blocks do. Put | |
| 127 | +the lookup in `PullRequests` (e.g. `status_check_blockers(repo, pr)` | |
| 128 | +calling `BranchProtections.rule_for_branch/2` + `check_statuses/3`) so the | |
| 129 | +LiveView and `merge!/3` agree. | |
| 130 | + | |
| 131 | +### 5. Required checks: settings UI | |
| 132 | + | |
| 133 | +In `branch_protection_live/index.ex`: a checkbox "Require status checks to | |
| 134 | +pass before merging" (`require_status_checks`) and a text input "Required | |
| 135 | +checks" (comma- or newline-separated → `required_checks` list; parse in | |
| 136 | +the LiveView before casting), with a hint listing the contexts seen on the | |
| 137 | +default branch's latest commit (`Workflows.list_commit_statuses/2`) so | |
| 138 | +people can copy exact names. Show them in the rule list. | |
| 139 | + | |
| 140 | +### 6. Docs | |
| 141 | + | |
| 142 | +README CI list: per-job statuses and their names; required checks and | |
| 143 | +their glob rule; that direct pushes aren't checked. | |
| 144 | + | |
| 145 | +## Tests | |
| 146 | + | |
| 147 | +- A run with jobs `test` and `lint` creates two pending statuses with the | |
| 148 | + Forgejo-format contexts; failing `lint` → its status `failure`, `test` | |
| 149 | + unaffected. | |
| 150 | +- A `push` run and a `pull_request` run on the same sha → four distinct | |
| 151 | + statuses (`… (push)` and `… (pull_request)`), neither overwrites the | |
| 152 | + other. | |
| 153 | +- Cancel → each cancelled job's status becomes `failure` with description | |
| 154 | + "Cancelled". | |
| 155 | +- `BranchProtections` with `require_status_checks: true, | |
| 156 | + required_checks: ["ci / test *"]`: | |
| 157 | + - head has `ci / test (push)` success → merge allowed; | |
| 158 | + - head has it `failure` → `{:status_checks, [], ["ci / test (push)"]}`; | |
| 159 | + - no matching status → `{:status_checks, ["ci / test *"], []}`; | |
| 160 | + - `required_approvals: 1` plus checks: both enforced (the old `cond` | |
| 161 | + would have stopped at approvals); | |
| 162 | + - a direct push (no `pull_request_id`) is not blocked by checks; | |
| 163 | + - `merge!(…, override: true)` merges despite a failing check. | |
| 164 | +- LiveView: the PR page shows the missing/failing checks and the merge | |
| 165 | + button is disabled; a broadcast on `ci_topic` re-renders the pills. | |
| 166 | +- Branch protection settings: saving the checkbox and a two-line | |
| 167 | + `required_checks` field persists a two-element list. | |
| 168 | + | |
| 169 | +## Done when | |
| 170 | + | |
| 171 | +- `mix test` passes; no new compile warnings (compare `mix compile --force` | |
| 172 | + warning lists before/after); `mix format` only touched files. | |
| 173 | +- One commit in the repository's message style. | |
| 174 | +- **Not pushed.** |
added
docs/plans/workflow-filter-patterns.md
+144
−0
| 1 | +# Plan: GitHub's filter patterns for branches, tags and paths | |
| 2 | + | |
| 3 | +Status: **ready to execute.** Written to be carried out start to finish by | |
| 4 | +one agent without further questions. Independent of the other CI plans. | |
| 5 | + | |
| 6 | +## Why | |
| 7 | + | |
| 8 | +Workflows are written against GitHub's "Filter pattern cheat sheet" | |
| 9 | +(docs.github.com, Workflow syntax → Filter pattern cheat sheet). Two | |
| 10 | +matchers here disagree with it: | |
| 11 | + | |
| 12 | +1. **`branches:` / `tags:` use a looser matcher.** | |
| 13 | + `GitGud.Workflows.Yaml.glob_match?/2` turns `*` into `.*`, so `*` | |
| 14 | + crosses `/`, and `?` into `.`. On GitHub `branches: ['*']` matches | |
| 15 | + `main` but **not** `feature/x`, and `releases/*` does not match | |
| 16 | + `releases/1.0/hotfix`. Here they do, so workflows meant for top-level | |
| 17 | + branches also run for every nested branch. Silent. Noted as a follow-up | |
| 18 | + when `paths:` landed (commit 34b2c06): "changing it changes which | |
| 19 | + existing branch filters fire" — which is exactly the point: they fire | |
| 20 | + when they shouldn't. | |
| 21 | +2. **`?` and `+` mean something else in `GitGud.Workflows.Glob`** (used | |
| 22 | + for `paths:`). The cheat sheet: `?` "matches zero or one of the | |
| 23 | + preceding character", `+` "matches one or more of the preceding | |
| 24 | + character" — regex quantifiers, not wildcards. `Glob` treats `?` as any | |
| 25 | + single character and `+` as a literal. (The path-filter plan specified | |
| 26 | + the wrong meaning; this corrects it.) `[]` matches "one alphanumeric | |
| 27 | + character listed in the brackets or included in ranges" — ranges only | |
| 28 | + `a-z`, `A-Z`, `0-9` — which `Glob` approximates more loosely. | |
| 29 | +3. **No negative patterns in `branches:` / `tags:`.** The cheat sheet | |
| 30 | + allows `!pattern` there with the same ordered semantics as `paths:` | |
| 31 | + (a later `!` excludes, a later positive re-includes; at least one | |
| 32 | + positive pattern required). Here `!release/old` is a literal that | |
| 33 | + matches nothing, so the list behaves as if the line weren't there — | |
| 34 | + the excluded branch runs. | |
| 35 | +4. A backslash escapes a special character (`\*`, `\[`, `\!`); neither | |
| 36 | + matcher supports it. | |
| 37 | + | |
| 38 | +## Semantics to match (the cheat sheet) | |
| 39 | + | |
| 40 | +| pattern | meaning | | |
| 41 | +|---|---| | |
| 42 | +| `*` | zero or more characters, not `/` | | |
| 43 | +| `**` | zero or more of any character | | |
| 44 | +| `?` | zero or one of the **preceding** character | | |
| 45 | +| `+` | one or more of the **preceding** character | | |
| 46 | +| `[...]` | one character from the list/ranges (`a-z`, `A-Z`, `0-9`) | | |
| 47 | +| `!` at the start | negates; only in lists, evaluated in order | | |
| 48 | +| `\x` | literal `x` | | |
| 49 | + | |
| 50 | +Plus, for paths only (already implemented, keep): `**/` also matches zero | |
| 51 | +directories (`**/README.md` matches `README.md`). | |
| 52 | + | |
| 53 | +Lists (branches, branches-ignore, tags, tags-ignore, paths, paths-ignore): | |
| 54 | +a ref/file is selected if the **last** pattern that matches it is | |
| 55 | +positive. A list whose patterns are all negative selects nothing (GitHub | |
| 56 | +rejects it; log a warning and treat it so). `-ignore` lists don't use `!`. | |
| 57 | + | |
| 58 | +## Where things are | |
| 59 | + | |
| 60 | +| | | | |
| 61 | +|---|---| | |
| 62 | +| Path patterns | `lib/git_gud/workflows/glob.ex` (`compile/1`, `match?/2`, `translate/2`, `class/2`) | | |
| 63 | +| Ref patterns | `lib/git_gud/workflows/yaml.ex` `kind_match?/5`, `glob_match?/2`; path lists `paths_match?/3`, `path_selected?/2` | | |
| 64 | +| Tests | `test/git_gud/workflows/glob_test.exs`, `trigger_filters_test.exs` | | |
| 65 | +| Not to touch | `GitGud.BranchProtections.matches_pattern?/2` has the same loose `*` — but protection rules are security policy and narrowing them silently would *unprotect* branches. Leave it; mention it in the commit message as a separate decision for the maintainer. | | |
| 66 | + | |
| 67 | +## Steps | |
| 68 | + | |
| 69 | +### 1. One pattern engine | |
| 70 | + | |
| 71 | +In `GitGud.Workflows.Glob.translate/2`: | |
| 72 | +- `?` and `+` become regex quantifiers on the preceding **atom** (the | |
| 73 | + previous literal character or class). A `?`/`+` at the start of a | |
| 74 | + pattern, or after `*`/`**`/another quantifier, is a literal (escape it). | |
| 75 | + Track "previous atom" in the accumulator: switch `translate/2` to build | |
| 76 | + a list of atoms (`{:lit, char}`, `{:class, body}`, `:star`, `:globstar`, | |
| 77 | + `:globstar_slash`) and render at the end, applying quantifiers to the | |
| 78 | + last atom. | |
| 79 | +- `[...]`: allow only alphanumerics and ranges within `a-z`/`A-Z`/`0-9`. | |
| 80 | + The cheat sheet has no negated classes, so `[!…]`/`[^…]` — like any | |
| 81 | + bracket containing something else — makes the `[` a literal. | |
| 82 | +- `\x` → literal `x`. | |
| 83 | +- Keep `**/` (zero or more directories), `/**` and bare `**`. | |
| 84 | +- Add an option `compile(pattern, mode)` with `mode` `:path` (as now) or | |
| 85 | + `:ref`. Today the only difference is `**/` — keep the zero-directory | |
| 86 | + rule for both (GitHub's ref examples don't contradict it); the option | |
| 87 | + exists so the call sites say what they mean. If you find no difference | |
| 88 | + is needed, skip the option. | |
| 89 | + | |
| 90 | +Update the moduledoc (it says `?` matches one character) and the README | |
| 91 | +line from the path-filter commit. | |
| 92 | + | |
| 93 | +### 2. Lists with negation everywhere | |
| 94 | + | |
| 95 | +Extract the ordered-list rule from `paths_match?/3` (`path_selected?/2`) | |
| 96 | +into `Glob.select?(patterns, string)` → boolean: last matching pattern | |
| 97 | +wins, `!` negates, no match → false, all-negative list → false + warning. | |
| 98 | +Use it for `paths:` (unchanged behaviour), and in `Yaml.kind_match?/5` | |
| 99 | +for `branches:` and `tags:`. For `branches-ignore:` / `tags-ignore:` / | |
| 100 | +`paths-ignore:`: selected = any pattern matches (no negation). | |
| 101 | + | |
| 102 | +### 3. Ref matching uses the engine | |
| 103 | + | |
| 104 | +Delete `Yaml.glob_match?/2`; `kind_match?/5` calls `Glob.select?/2` / | |
| 105 | +`Glob.match?/2`. | |
| 106 | + | |
| 107 | +### 4. Docs | |
| 108 | + | |
| 109 | +README CI list: one line with the pattern table above, saying `*` no | |
| 110 | +longer crosses `/` in `branches:`/`tags:` and that `!` works there. | |
| 111 | +PROGRESS: note the behaviour change (a workflow with `branches: ['*']` | |
| 112 | +stops running for `feature/x`). | |
| 113 | + | |
| 114 | +## Tests | |
| 115 | + | |
| 116 | +`glob_test.exs`: | |
| 117 | +- `?`: `colou?r` matches `color` and `colour`, not `colouur`; | |
| 118 | +- `+`: `v1+` matches `v1`, `v11`, not `v`; `a+b` vs `b`; | |
| 119 | +- leading `?`/`+` literal; | |
| 120 | +- `[0-9]`, `[CB]at`; `[!a]` literal; | |
| 121 | +- escapes: `a\*b` matches `a*b` only; | |
| 122 | +- existing cases still pass, **except** those asserting the old `?` | |
| 123 | + meaning (`a?c` matched `abc`) — rewrite them to the new rule and say so | |
| 124 | + in the commit message. | |
| 125 | + | |
| 126 | +`trigger_filters_test.exs`: | |
| 127 | +- `branches: ['*']` matches `main`, not `feature/x`; `branches: ['**']` | |
| 128 | + matches both; | |
| 129 | +- `branches: ['releases/**', '!releases/**-alpha']` excludes | |
| 130 | + `releases/1.0-alpha`, includes `releases/1.0`; | |
| 131 | +- re-include: `['*', '!b*', 'beta']` → `beta` yes, `bravo` no; | |
| 132 | +- all-negative list → nothing matches; | |
| 133 | +- `tags: ['v[0-9]+.[0-9]+.[0-9]+']` matches `v10.2.33`, not `v1.x.3`; | |
| 134 | +- `branches-ignore: ['feature/*']` ignores `feature/a` but not | |
| 135 | + `feature/a/b` (because `*` no longer crosses `/`). | |
| 136 | + | |
| 137 | +## Done when | |
| 138 | + | |
| 139 | +- `mix test` passes; no new compile warnings (compare `mix compile --force` | |
| 140 | + warning lists before/after); `mix format` only touched files. | |
| 141 | +- One commit in the repository's message style, stating the behaviour | |
| 142 | + change for `branches:`/`tags:` and that branch protection patterns were | |
| 143 | + deliberately left alone. | |
| 144 | +- **Not pushed.** |
added
docs/plans/workflow-fork-pull-requests.md
+184
−0
Click to load diff…
added
docs/plans/workflow-job-graph.md
+210
−0
Click to load diff…
added
docs/plans/workflow-job-token.md
+160
−0
Click to load diff…
added
docs/plans/workflow-matrix.md
+148
−0
Click to load diff…
added
docs/plans/workflow-runner-context.md
+213
−0
Click to load diff…
added
docs/plans/workflow-triggers.md
+242
−0
Click to load diff…
Parents: bfce1e7