CI gaps against GitHub / Forgejo Actions
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).