CI gaps against GitHub / Forgejo Actions
Executed (2026-10-17/19): every plan below landed — artifacts and cache ff3668c, job token a9f5a09, runner context e9900fa, job graph 15e553d, cancellation and concurrency 6b5083e, checks b812854, filter patterns 91138c0, matrix 6b0b893, triggers 2cafbbf 9b97d32 ad37081 587faf4 8a80762 a9bbffe, fork pull requests 1b35c31 a5b815d — with follow-ups in 5e43417 (server-side ref moves get push events), eaf7cde (re-runs), dcb7d88 (runner job ownership, cache reserving job) and abee805 (stale-job reaper heartbeat). Still open: #23’s artifacts v3/v4, and the “No plan” rows. See PROGRESS.md slice 77. The table below is the audit as it stood, kept as a record.
Status: audit of 2026-10-17, on master at bfce1e7 (after the path-filter,
event_name and pull_request work). Each gap was checked in the code;
the evidence column names the file and function. What forgejo-runner
expects was checked against its source
(code.forgejo.org/forgejo/runner: internal/app/run/runner.go,
workflow.go, internal/pkg/report/reporter.go) and Forgejo’s server
(services/actions/context.go).
Severity, highest first:
- security — another repository’s data, or credentials, are exposed.
- silently wrong — the workflow runs and does the wrong thing, or reads empty values, with no error.
- silent no-op — a documented feature that never happens, with no error.
- loud — fails visibly; the author finds out.
Ranked
| # | Gap | Severity | Evidence | Plan |
|---|---|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 10 | concurrency: (groups, cancel-in-progress) ignored — concurrent deploys |
silently wrong | nothing in lib/ reads concurrency (grep) |
workflow-cancellation-and-concurrency.md |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 (B) |
| 16 | [skip ci] / skip-checks: true ignored |
silently wrong (extra runs) | Workflows.dispatch/2, dispatch_pull_request/3 |
workflow-triggers.md (A) |
| 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 (C) |
| 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 (E) |
| 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 (F) |
| 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 (D) |
| 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 |
| 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 |
| 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 fixes the README; v4 itself is listed there under “Out” as the next plan |
No plan
| Gap / feature | Why no plan |
|---|---|
on: release |
gitgud has no releases (lib/git_gud/release.ex is the Mix release module). Plan it with a releases feature. |
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. |
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. |
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. |
Branch-protection patterns, whose * also crosses / (BranchProtections.matches_pattern?/2) |
Deliberately left: tightening it would silently unprotect nested branches. A maintainer decision, noted in the filter-patterns plan. |
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. |
Works (checked, nothing to plan)
- Step-level
if:,continue-on-error,timeout-minutes, stepoutputs, job/stepenv, jobdefaults,services:,container:, jobtimeout-minutes— all live in the job’s definition, which is passed to act intact (RunnerPayload.encode/1,job.definition), and act implements them. - Job
outputs:are collected (update_task/2→Workflows.set_outputs/2); only their delivery to dependents is broken (#7). runs-onlabel routing incl.self-hostedand matrix-substituted labels (Workflows.do_claim_next_job/2,Matrix.substitute/2).- Static matrices with
include/exclude,fail-fastfor queued legs (Matrix.expand/1,Workflows.maybe_fail_fast/1; running legs: #4’s plan). pushwithbranches/tags/paths,pull_requestopened/synchronize/reopened,workflow_dispatchvia the API, annotated tags (Workflows.dispatch/2,dispatch_pull_request/3).- Repository and organization secrets and variables
(
GitGud.Secrets.resolve_for/1), reservedGITHUB_*/GITEA_*/FORGEJO_*names (Secrets.ReservedNames). - Logs per job and step (
update_log/2keyed by task id since 56749c0), live log streaming, re-run of a whole run or a single job (Workflows.rerun/1,rerun_job/2; the attempt counter is in the context plan), stale-job recovery (Workers.StaleJobReaper). - Cache lookup order and scoping on read (
ActionsCache.lookup/5filters by repository; PR fallback to the base branch is in the artifacts/cache plan). - Commit statuses on the commit, log and PR pages
(
core_components.excommit_status_badges/1).
Suggested order
Artifacts-and-cache and job-token first (security). Then runner-context, job-graph, cancellation-and-concurrency (the silently-wrong core). Then checks, filter-patterns, matrix, triggers. Fork pull requests last (it needs job-token).