Docs: README, ROADMAP and PROGRESS after the CI and security work

876aa0b · Gabriel Morell · 2026-10-19 20:32

6 files +217 -31
Message
{commit_body(@commit)}

Files changed

modified PROGRESS.md
+104 −2
@@ -12,6 +12,108 @@ intermediate "I was here when I stopped" state) lives in the
12 12
13 13 ---
14 14
15 +## Slice 78 — Security hardening
16 +
17 +A pass over who may do what, prompted by the CI work's own token and
18 +runner fixes: most holes were a LiveView event handler trusting that
19 +the button it answers was only drawn for the right people, or an id
20 +the client sent. The rule now is that the contexts decide, every
21 +handler asks again, and a foreign or junk id is ignored, never acted
22 +on or confirmed to exist.
23 +
24 +### Pages ask again
25 +
26 +- **Labels** (8975744): managing them is a repo admin's (org labels an org admin's); attaching is the author's or an admin's, at issue creation a triager's; every handler takes only this repository's (or its org's) own label. `delete` used to take any label id on the instance.
27 +- **Issues close/reopen** (9af3f43): the author or a triager, as the mobile API already had it. It had no check at all.
28 +- **Repository settings** (a6f4469): secrets, deploy keys, interaction policy, packages (retention, pin/unpin), webhooks, branch protection, the wiki editor and the org page's `create_team` were open to any reader. `GitGudWeb.RepoAdmin` checks in mount and re-reads rights in every mutating handler; the interaction-policy page saves through a changeset that casts nothing else. One test file per page under `test/git_gud_web/live/repo_auth/`.
29 +- **Everything else that mutates** (e3f46f9): registry-token and job-token pages, repo settings, the merge editor, runners, run re-run/cancel, the PR admin override, and the org pages (`GitGudWeb.OrgAdmin.with_org_admin/2`) re-derive admin per event; moderation pages re-ask in `ModerationActions`. Reports from issue/PR pages name only what the page loaded. `add_comment` no longer crashes for an anonymous visitor or a banned author — both are a flash. Client ids go through `RepoAdmin.parse_id/1` and scoped getters instead of `get!`.
30 +
31 +### Pull requests
32 +
33 +- **Merging takes write access** (f5a1289), in `PullRequests.merge!/3` itself; the button was drawn for everyone, and an anonymous click crashed. **A cross-repo source must be readable** by whoever opens the PR — naming someone's private repository as the source fetched its history into yours. Failed attempts no longer leave `refs/pull-tmp/*` behind.
34 +- **Only writers' reviews count**: `PullRequests.counting_reviews/2` (each reviewer's latest, write access, not the author's while self-approval is blocked) feeds `effective_approvals/2`, `blocking_reviews/2` and a protected branch's `required_approvals`, which counted every approved row. Federated reviews show but don't count. **Behaviour change** for repos that relied on outside approvals.
35 +
36 +### Accounts
37 +
38 +- **2FA login needs proof of the second factor** (92ad654): the password alone, plus typing `/users/log-in/two-factor/complete?u=<id>`, logged in. `UserAuth.two_factor_verified?/2` wants the WebAuthn flag or a signed, nonce-bound, 120 s token from the challenge. `TwoFactorAttempts`: five failures per pending login, twenty per user per hour (shared with TOTP sudo). Recovery codes and invites are claimed with a conditional update, so two tabs can't spend one twice. Sudo is re-checked per handler (`UserAuth.with_sudo/3`) and covers security-key enrollment.
39 +- **A TOTP code is good once** (96a5dc6): `users.totp_last_step`, claimed conditionally, so a replay inside the 30 s window, an older step's code and a race all lose.
40 +
41 +### Runners, caches, registries
42 +
43 +- **Runners report only their own jobs** (dcb7d88): `UpdateTask` took any job id, so any registered runner could set another repository's results (which required checks read) and outputs (which reach later jobs with that repo's secrets).
44 +- **Cache uploads come from the reserving job**: `cache_entries.reserved_by_job_id`; a fork run of the same repository can no longer fill in a default-branch entry.
45 +- **Registry tokens sign only repository scopes**, and a space-separated `scope` is split rather than discarded.
46 +- **zot webhook fails closed** (fea96e8) without `ZOT_WEBHOOK_SECRET`, logged at boot. The v1 API answers a private repository the caller can't read with 404, not 403/401.
47 +
48 +### The node's own endpoints, and outbound URLs
49 +
50 +- **Hook secret** (fe05dfe): `/_hooks/pre-receive`, `/_hooks/post-receive` and `/_ssh/resolve` were behind an empty `:loopback` pipeline on a node listening on `::`. `GitGudWeb.Plugs.HookAuth` wants `x-gitgud-hook-token`, a secret minted at boot (or `GITGUD_HOOK_SECRET`) and inherited by every git the node spawns. The OpenSSH `authorized_keys` shim runs under sshd, so that setup needs `GITGUD_HOOK_SECRET` set on both sides.
51 +- **SSRF guard** (49fdeb3): `GitGud.OutboundUrl` for imports and their provider APIs, mirrors, upstream sync, federated PR fetches, webhooks, actor fetches and inbox delivery. Public addresses only, no redirects, the checked address pinned (Req's connect step, git's `http.curloptResolve`); `git://` is checked but can't be pinned. Plain paths as clone URLs are refused. `GITGUD_IMPORT_ALLOW_PRIVATE`, `GITGUD_WEBHOOK_ALLOW_PRIVATE`, `GITGUD_FEDERATION_ALLOW_PRIVATE` lift it per purpose. **Behaviour change**: webhooks to LAN hosts stop until the operator opts in.
52 +
53 +### Federation
54 +
55 +- **Only public repositories and organizations federate** (4d7e44f): any remote actor could Follow a private repository (follows are auto-accepted) and receive its pushes, issues and comments. Internal/private ones have no actor, inbox or collection entry, `publish_*` no-ops, and inbound activities aimed at one are rejected. **Behaviour change**: existing remote followers of private repos stop hearing from them.
56 +- A refused Offer's fetched branch is dropped; `sourceBranch` must be a valid name. Offer/Like/Dislike honour `interaction_policy` as Create did; a Note or review finds a federated PR only in the inbox's own repository; an Accept counts only from the followee.
57 +- Blocks and the allowlist compare hosts case-insensitively, without a trailing dot, and a block covers subdomains. A fetched actor must name the URL it came from and its own key; a `keyId` naming a local actor is never fetched. Signatures must cover `(request-target)`, `host`, `date` and `digest`.
58 +
59 +### A regression the hardening surfaced
60 +
61 +- **Stale-job reaper by heartbeat** (abee805): it re-queued every job `running` for five minutes by `started_at`, which stopped moving in 56749c0. Harmless while runners' reports were taken from anyone; with job ownership and per-attempt tokens it cut long jobs off (neiam/ppl's package upload 401'd six minutes in). `workflow_jobs.heartbeat_at`, moved by the job's own runner's `UpdateTask` / `UpdateLog` at most every 30 s, is what the reaper reads now.
62 +
63 +### Still open
64 +
65 +- **Federation decisions** for the maintainer: follows are auto-accepted, so a `followers` interaction policy is only as strict as "anyone who asked"; the `approved` policy doesn't gate follows either. The inbox rate limiter buckets by the host the request *claims* before its signature is verified, and rejected bodies are still stored. There is no signature replay cache (a captured delivery replays inside the clock-skew window), and activity ids aren't checked against the actor's host.
66 +
67 +---
68 +
69 +## Slice 77 — CI that does what GitHub's and Forgejo's documentation says
70 +
71 +The audit in [docs/plans/ci-gaps.md](docs/plans/ci-gaps.md) ranked 23
72 +gaps where a workflow written from GitHub's or Forgejo's docs ran and
73 +did the wrong thing, or silently nothing. Each plan in `docs/plans/`
74 +landed as its own commit; details are in the README's CI section and
75 +the commit messages.
76 +
77 +### Security first
78 +
79 +- **Artifacts stay in their run, cache uploads in their repository** (ff3668c) — ci-gaps #1, #3. Cache chunks land at their `Content-Range` offset (`cache_entry_parts`) and are assembled on finalize; caches over 32 MB were stored scrambled. PR runs restore from the base branch before the default branch.
80 +- **The job token ends with its job** (+5 min, per attempt) and **honours `permissions:`** (a9f5a09) — #2, #13. `contents: write` can push (own repo, branch protection applies); a push made with the job token starts no workflows.
81 +
82 +### The silently-wrong core
83 +
84 +- **Runner context** (e9900fa) — #5, #6, #9: `secrets.GITHUB_TOKEN` (and `GITEA_`/`FORGEJO_TOKEN`), `github.event` as an object (`workflow_runs.event_payload`, `GitGud.Workflows.EventPayload`), `run_id`, `run_attempt`, `workflow_ref`, ids, `server_url`/`api_url`; workflow-level `env:`, `defaults:`, `permissions:`, `run-name:` and the real `on:` reach the runner.
85 +- **Job graph** (15e553d) — #7, #8: `needs.*.outputs`/`.result` delivered again; failed needs skip rather than cancel, `always()`/`failure()`/`cancelled()` jobs run, job-level `continue-on-error`.
86 +- **Cancellation and `concurrency:`** (6b5083e) — #4, #10: a cancel reaches the runner via the `UpdateTask` reply and sticks; groups with `cancel-in-progress`, new `waiting` status; `GitGud.Workflows.Expr` evaluates groups.
87 +- **A commit status per job and required status checks** (b812854) — #11, #22: `<workflow> / <job> (<event>)`, a `cancelled` state for superseded jobs, live pills on the PR page, branch protection's required checks enforced and in the UI.
88 +- **Filter patterns** (91138c0) — #12: one `GitGud.Workflows.Glob` for `branches:`/`tags:`/`paths:`. **Behaviour change**: `branches: ['*']` no longer matches `feature/x`.
89 +- **Matrices** (6b0b893) — #14: `fromJSON` matrices (expanded once their needs finish, from a placeholder row), `max-parallel` (claim-time count under an advisory lock), an empty matrix fails.
90 +
91 +### Triggers — #15–#20
92 +
93 +- `[skip ci]` / `skip-checks: true` (2cafbbf); `workflow_dispatch` inputs validated and delivered, plus a **Run workflow** form on the Actions page (9b97d32); `on: schedule` from the default branch (`workflow_schedules`, `Workers.ScheduleTick`, ad37081); `create` / `delete` (587faf4); `pull_request` `closed`/`edited`/`labeled`/`unlabeled` (8a80762); `on: workflow_run` and a `workflow_run` webhook for failed runs too (a9bbffe).
94 +
95 +### Fork and federated pull requests — #21
96 +
97 +- **They run, after approval, without secrets or variables and with a read-only token** (1b35c31): `workflow_runs.fork`, new `awaiting_approval` status, Approve/Reject banner on the PR page, Forgejo's re-approve-every-push rule. **`on: pull_request_target`** runs the target branch's workflow with its secrets (a5b815d); the README warns about checking out the head there.
98 +
99 +### Clients, and what exercising it found
100 +
101 +- **Mobile API** (1d7ef31, 24ca95f): job `status_reason`/`attempt`/`superseded`/`placeholder`, dispatchable workflows and dispatch with inputs, approve/reject fork runs; opening, editing, closing, reopening and labelling pull requests.
102 +- **Android** (30f666b) and **`gg` CLI** (d1406da, f19604a, df92b0a) learnt the same: new statuses, Run workflow, fork-run approval, `gg pull create/edit/close/reopen/label`. The app gained JVM unit tests.
103 +- **Re-runs** (eaf7cde): an empty literal matrix fails again on re-run instead of handing act an empty strategy; a re-run keeps its actor.
104 +- **Branches the forge moves itself get push events** (5e43417): PR merges, applied suggestions, resolved conflicts and fork syncs enqueue the same `CommitBackfill` the hook does, so a merge commit gets its `push` run, webhook, caches and stats.
105 +
106 +### Known gaps
107 +
108 +- **Artifacts aren't usable from the stock actions**: the v1 API here doesn't match `@actions/artifact` (`upload-artifact@v3` chunked `PUT`s with `itemPath`; forgejo-runner's `ACTIONS_RUNTIME_URL` points at an unmounted `/api/actions_pipeline/`), and `@v4`'s Twirp `ArtifactService` isn't implemented — ci-gaps #23, the next plan.
109 +- **forgejo-runner uses its own cache by default** (`cache.enabled`), so this cache server is only reached with `cache.enabled: false` and `ACTIONS_CACHE_URL` in `runner.envs`. Cache service v2 (`actions/cache@v4`) isn't implemented.
110 +- **`github.repository_id` reads blank**: it is sent, but act doesn't map it into the context.
111 +- **Reusable workflows** (`jobs.<id>.uses: ./…`, `on: workflow_call`) are untested on a real runner.
112 +- **Not planned**: `environment:`, OIDC (`id-token: write`), `on: release` — no environments, issuer or releases to back them.
113 +- Much of the runner-facing work was checked against forgejo-runner's source rather than a live runner (the only one is production, reached by pushing); the fixes in eaf7cde and abee805 are what exercising it turned up.
114 +
115 +---
116 +
15 117 ## Slice 76 — Registry access, cross-repo CI, and a runner protocol fix
16 118
17 119 Driven by moving a multi-image project (game client, login server, demo,
@@ -30,13 +132,13 @@ each gap below was a wall that pipeline hit.
30 132 ### Cross-repository CI
31 133
32 134 - **Job-token grants** (repo settings → Job token access): a repository lets another repository's — or every repository in an organization's — job tokens read its images and packages and/or dispatch its workflows. GitLab's job-token allowlist; the repository reached into decides.
33 −- **`workflow_dispatch`**: `POST /api/v1/repos/:owner/:name/actions/workflows/:workflow/dispatches`, Gitea/GitHub-compatible, with a job token (own repo or granted) or a `ci:write` personal token. The workflow must declare `on: workflow_dispatch`. `inputs` are accepted and not yet passed through.
135 +- **`workflow_dispatch`**: `POST /api/v1/repos/:owner/:name/actions/workflows/:workflow/dispatches`, Gitea/GitHub-compatible, with a job token (own repo or granted) or a `ci:write` personal token. The workflow must declare `on: workflow_dispatch`. `inputs` are accepted and not yet passed through. (Passed through, and validated, since slice 77.)
34 136
35 137 ### Workflow triggers
36 138
37 139 - **`paths:` / `paths-ignore:`** are honoured for `push`, with GitHub's semantics and pattern syntax (`GitGud.Workflows.Glob`). Every push used to run every workflow: bigtrippr's Android workflow, filtered to `android/**`, ran its tests and re-published an identical APK under a new versionCode on each server-only push. The push worker now passes the ref's old SHA; dispatch lists the changed files once (`Git.changed_paths/3`, `git diff --name-only --no-renames -z`), only when some workflow filters on paths. A new branch is diffed from its merge-base with the default branch. Tags and manual runs ignore path filters; anything that can't be determined (or > 3000 files) fails open.
38 140 - **`github.event_name`** is set in every job's context. It was empty, so a bigtrippr job gated on `github.event_name == 'push'` never ran.
39 −- **`on: pull_request`** for same-repository pull requests: `opened` (PR creation), `synchronize` (a push that moves an open PR's head, attributed to the pusher) and `reopened` (which first catches the head up with the branch, since closed PRs aren't refreshed on push), each through the `Workflows.Workers.PullRequestDispatch` Oban job so the LiveView and the push worker never wait on it. `Workflows.dispatch_pull_request/3` reads workflows from the PR head, pins `refs/pull/<N>/head`, and records the PR number and branches on the run (migration `add_pull_request_to_workflow_runs`) for the job's `head_ref` / `base_ref`. `types:`, `branches:` / `branches-ignore:` (the target branch) and `paths:` are honoured; `branches-ignore:` (and `tags-ignore:`) now work for `push` too. Fork and federated PRs are not dispatched — they'd run with the target's secrets — and the head is tested, not a merge result.
141 +- **`on: pull_request`** for same-repository pull requests: `opened` (PR creation), `synchronize` (a push that moves an open PR's head, attributed to the pusher) and `reopened` (which first catches the head up with the branch, since closed PRs aren't refreshed on push), each through the `Workflows.Workers.PullRequestDispatch` Oban job so the LiveView and the push worker never wait on it. `Workflows.dispatch_pull_request/3` reads workflows from the PR head, pins `refs/pull/<N>/head`, and records the PR number and branches on the run (migration `add_pull_request_to_workflow_runs`) for the job's `head_ref` / `base_ref`. `types:`, `branches:` / `branches-ignore:` (the target branch) and `paths:` are honoured; `branches-ignore:` (and `tags-ignore:`) now work for `push` too. Fork and federated PRs are not dispatched — they'd run with the target's secrets — and the head is tested, not a merge result. (Fork and federated PRs run after approval, without secrets, since slice 77.)
40 142 - ~~Follow-up: `branches:` / `tags:` still use the old matcher, whose `*` crosses `/`.~~ Done: `branches:` / `tags:` / `paths:` and their `-ignore` lists share `GitGud.Workflows.Glob`, with GitHub's cheat sheet. **Behaviour change**: a workflow with `branches: ['*']` stops running for `feature/x` (use `'**'`); `!pattern` excludes in `branches:` / `tags:`; `?` / `+` are quantifiers on the preceding character (`?` used to be any one character in `paths:`); a backslash escapes (`\*`). Branch-protection patterns (`BranchProtections.matches_pattern?/2`), whose `*` also crosses `/`, are deliberately unchanged — narrowing them would unprotect nested branches; the maintainer's call.
41 143
42 144 ### Runner protocol
modified README.md
+84 −19
@@ -39,24 +39,25 @@ reinvent them.
39 39 - **Quote-reply** button on every comment — appends `> `-prefixed source with attribution
40 40 - **History subpage** on every issue and PR — pushes (with sha pair and commit count), renames, description edits, labels, state changes, reviews, comment edits and moderation, interleaved in one timeline. Body edits fold their before/after into a collapsed diff that renders from the recorded payload, so it survives the comment being deleted
41 41 - **Cross-reference backlinks** — writing `#42` leaves a linked entry in *that* item's history, so the relationship reads from both ends. Only genuinely new references record, and a `#42` inside backticks is text rather than a link
42 − - Pull requests with mergeability probe, 3-way merge, cross-repo (fork) PRs, reopen, and a **dedicated code review screen** at `/pulls/:n/files` — the diff gets the whole viewport instead of sharing a column with the discussion
42 + - Pull requests with mergeability probe, 3-way merge, cross-repo (fork) PRs, reopen, and a **dedicated code review screen** at `/pulls/:n/files` — the diff gets the whole viewport instead of sharing a column with the discussion. Merging takes write access to the target (checked in `PullRequests.merge!/3` itself, not only by hiding the button), and a cross-repo PR's source must be a repository its opener can read — nothing is fetched from one they can't
43 43 - **Review flow** on its own tab: assigned reviewers (requested by handle, or from chips suggesting who actually reviews in this repo), one standing verdict per reviewer rather than a pile of contradictory rows, and the full log behind a disclosure
44 44 - **Line comments** anchored to a file and line, pinned to the head they were written against
45 45 - **Suggested changes** — a ```` ```suggestion ```` block renders as the proposed replacement with an Apply button that commits it to the source branch. Refuses rather than guesses: `:stale` if the branch moved since, and the ref update is a compare-and-swap so a concurrent push loses the race instead of being overwritten
46 46 - **In-browser conflict resolution** at `/r/:owner/:name/merge` — every unresolved path with all three sides and git's own marked-up merge to edit, or take one side wholesale. Its own route, not a PR tab, because the other caller is an upstream branch that diverged and has no PR. Commits with both parents, so the branch merges cleanly afterwards; refuses a partial resolution, refuses content that still carries conflict markers, and honours branch protection — resolving in a browser is still a ref update
47 − - **Per-repo merge rules**, all off by default except the last: block merging while changes are requested, require N approving reviews, and stop authors approving their own PR (on for new repos). Repo admins can override, and an override is recorded as such
47 + - **Per-repo merge rules**, all off by default except the last: block merging while changes are requested, require N approving reviews, and stop authors approving their own PR (on for new repos). Anyone who can read a PR may review it, but **only the verdicts of reviewers with write access count**, as on GitHub — each reviewer's latest, never the author's own while self-approval is blocked — toward these rules and a protected branch's required approvals; the reviews tab marks a verdict that doesn't count, and federated reviews show but don't count. Repo admins can override, and an override is recorded as such
48 48 - **Editable titles and descriptions** on issues and PRs, plus editable and soft-deletable comments — deletion is soft so the history can still render what was edited before it went
49 49 - Forks + fork-sync (fast-forward CAS) + compare-any-two-refs view
50 50 - **Repository import** from a GitHub org/user, a GitLab group (subgroups included) or user, or any single git URL — `git clone --mirror` per repo on its own background job, with a live progress page. A name that's already taken isn't automatically a collision: if the local repo and the remote share a root commit they're the same project, so the local one is **adopted** — pointed at that upstream for syncing, with no ref touched — and only genuinely unrelated history still skips. Re-sync is on demand or hourly (opt-in, anonymous), in one of two modes: `mirror` force-updates and prunes, `reconcile` fast-forwards what it can and reports the rest for a human. Git data only; issues and PRs don't come across. Provider tokens are encrypted at rest, handed to `git` out-of-band (never in the clone URL), and wiped when the import finishes
51 − - Branch protection, deploy keys, webhooks, wikis
52 − - **Labels** with HTML color picker + 16-swatch palette + live preview; **org-level labels** inherited by every repo unless the repo opts out (and re-opts in via a hidden-labels expander)
51 + - Branch protection, deploy keys, webhooks, wikis (editing a wiki page takes write access)
52 + - **Labels** with HTML color picker + 16-swatch palette + live preview; **org-level labels** inherited by every repo unless the repo opts out (and re-opts in via a hidden-labels expander). Managing labels is a repo (or org) admin's; putting them on an issue or PR is its author's or an admin's, and on a new issue a triager's. Closing and reopening an issue is its author's or a triager's
53 53 - **Profiles**
54 54 - **User + org profile pages** at `/u/:handle` and `/orgs/:handle`. Display name, bio, url, location, company, public email, timezone, socials. Two-column layout with bio on the left, pinned repos + README on the right.
55 55 - **Theme-aware identicon avatars** — deterministic 5×5 mirrored grid, fills reference daisyUI CSS variables so swapping themes recolors every avatar live. No upload pipeline yet; the identicon is the avatar.
56 56 - **Pinned repos** — drag-to-reorder grid, up to 7 per user / per org, includes repos from orgs you're a member of. Removed-from-org? Your personal pins of that org's repos clean themselves up.
57 57 - **Auto-created same-name profile README** — registering `alice` provisions `alice/alice` with a seeded "Hi, I'm @alice 👋 …" `README.md` on the default branch. Edit it like any other repo; the rendered output shows on the profile.
58 58 - **Mobile**
59 − - **Android client** in [`android/`](android/) — repo list with per-repo CI status, CI runs/jobs/logs with re-run and cancel, issue and PR viewing + commenting + reviewing + merging, wiki reading. Same Compose shell and nine OKLCh palettes as the web
59 + - **Android client** in [`android/`](android/) — repo list with per-repo CI status, CI runs/jobs/logs with re-run and cancel, Run workflow (with a control per declared input), approving or rejecting a fork's waiting runs, issue and PR viewing + commenting + reviewing + merging, wiki reading. Same Compose shell and nine OKLCh palettes as the web
60 + - **`gg` CLI** in [`cli/`](cli/) — the same `/api/mobile/v1` surface from a terminal, kept in step with the app: CI runs, logs, `gg ci watch`, `gg ci dispatch` with `-f name=value` inputs, fork-run approval, and opening, editing, closing, reopening and labelling pull requests (`gg pull create` defaults `--head` to the checked-out branch)
60 61 - **Pairing by QR**: mint a token with the "Mobile app" scope bundle at `/users/settings/tokens` and scan the `gitgud://pair?…` code. The device *is* its token, so revoking the token signs the phone out
61 62 - **Per-surface API scopes** — `repo:read`, `ci:read`/`ci:write`, `issues:read`/`issues:write`, `pulls:read`/`pulls:write`, `wiki:read`/`wiki:write`. The mobile checkbox ticks the bundle; tokens carry the scopes individually, so one surface can be revoked without re-pairing. See [docs/mobile-api.md](docs/mobile-api.md)
62 63 - **Account + org governance**
@@ -71,16 +72,17 @@ reinvent them.
71 72 - Optional expiry, with no sweeper to run: a lapsed ban simply stops matching. The row survives as a record of who banned whom, when, why, and who lifted it
72 73 - **Auth & MFA**
73 74 - Magic-link login + email/password
74 − - **TOTP** (any RFC 6238 authenticator) with QR enrollment + 10 single-use recovery codes
75 + - **TOTP** (any RFC 6238 authenticator) with QR enrollment + 10 single-use recovery codes. A TOTP code is good once (a replay inside its 30-second window is refused), a login finishes only with proof the second factor was checked, and failed codes are limited — five per pending login, twenty per user per hour
75 76 - **WebAuthn / FIDO2** — hardware security keys (YubiKey, Titan), platform authenticators (Touch ID / Windows Hello), browser passkeys
76 77 - Recovery codes shared across factors; instance-level enforcement with N-day grace period
77 − - **Sudo mode** on every credential-management page — settings, SSH keys, API tokens, two-factor, invites all demand a recent re-authentication, so a stolen session cookie can't enrol its own authenticator or mint a token
78 + - **Sudo mode** on every credential-management page — settings, SSH keys, API tokens, two-factor, invites all demand a recent re-authentication, so a stolen session cookie can't enrol its own authenticator or mint a token. It is re-checked on every action, not only when the page loads, and enrolling a security key needs it too
78 79 - **Federation (ForgeFed / ActivityPub)**
79 − - HTTP Signatures on inbound + outbound deliveries; outbound dead-letter after 8 retries
80 + - **Only public repositories and organizations federate**: an internal or private one has no actor, no inbox and no place in its org's collection (the same 404 as a missing one), and nothing about it is published
81 + - HTTP Signatures on inbound + outbound deliveries, covering `(request-target)`, `host`, `date` and `digest`; a fetched actor must name the URL it was fetched from and its own key. Outbound dead-letter after 8 retries
80 82 - Push / ticket / merge / comment activities outbound; per-task hourly key refresh of stale remote actors
81 83 - Inbound: Follow, Offer (federated PR), Update, Reject, Create(Note) comments, Like/Dislike as PR reviews
82 84 - Outbound `Offer` from the PR new page; outbound Like/Dislike on local reviews of federated PRs
83 − - Per-instance moderation: allowlist mode (default), per-instance + per-actor blocks, interaction policies (`public` / `followers` / `approved`)
85 + - Per-instance moderation: allowlist mode (default), per-instance + per-actor blocks (an instance block covers its subdomains; hosts compare case-insensitively), interaction policies (`public` / `followers` / `approved`) applied to inbound Offer, Like, Dislike and Create alike
84 86 - **Actionable reports queue** in admin: inline previews, **replace-with-mod-message** (reversible — admins + the original author can `<details>`-toggle the original), suspend remote actors, forward to source instance via AP `Flag`. Re-reporting moderated content is gated at the API layer.
85 87 - **`mix gitgud.federation.suggest_blocks`** — peer block lists become a *review queue*, not enforcement (cascades are deliberately declined)
86 88 - **CI**
@@ -88,7 +90,7 @@ reinvent them.
88 90 - Repo-scoped + org-scoped runner registration with one-shot tokens minted from the settings UI
89 91 - **Matrix builds**, with `runs-on` as a real label set — `strategy.matrix` expands into one job per combination, each claimed independently, so `runs-on: [self-hosted, "${{ matrix.arch }}"]` sends the arm64 leg to an arm64 machine and the amd64 leg to an amd64 one. `include`/`exclude` follow GitHub's semantics, `fail-fast` defaults on, `max-parallel` caps how many legs of the job run at once, and `needs:` names the group rather than a leg. The matrix, an axis, or `include`/`exclude` can be computed — `matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}` — with the expression subset below plus `fromJSON()`/`toJSON()`; one that reads `needs` shows as a single job until those needs finish, then becomes its legs. A matrix with no combinations left (everything excluded, an empty axis) or an expression that doesn't evaluate to the right shape fails the job, saying why in its log
90 92 - **Five deployment styles** offered in the registration UI: Native binary, Docker, Podman, Podman Quadlet, Kubernetes (Deployment + dind sidecar) — copy-pasteable `register` + `daemon` snippets per style
91 − - **Triggers**: `push` (with `branches:` / `branches-ignore:` / `tags:` / `tags-ignore:`, GitHub's rule that naming one kind excludes the other kind of ref, and a list beating its `-ignore` if both are set), `pull_request`, `pull_request_target` and `workflow_dispatch`
93 + - **Triggers**: `push` (with `branches:` / `branches-ignore:` / `tags:` / `tags-ignore:`, GitHub's rule that naming one kind excludes the other kind of ref, and a list beating its `-ignore` if both are set), `pull_request`, `pull_request_target`, `workflow_dispatch`, `schedule`, `create` / `delete` and `workflow_run`, each below
92 94 - **`on: pull_request`**: `types:` `opened`, `synchronize` (the head moved) and `reopened` (the default), and — when `types:` names them — `closed` (on close and on merge; `github.event.pull_request.merged` tells them apart, and it is dispatched once, as the PR closes), `edited` (title or body changed) and `labeled` / `unlabeled` (`github.event.label.name`). `branches:` / `branches-ignore:` filter the **target** branch, and `paths:` the PR's diff from its merge-base. Workflows are read from the PR's head commit, so a PR can change its own CI, and the job tests the head itself at `refs/pull/<N>/head` — not a merge with the target, as GitHub's `refs/pull/<N>/merge` does. The job gets `github.event_name`, `head_ref` and `base_ref` (the source and target branches), `ref_name` `<N>/head` and an empty `ref_type`.
93 95 - **Fork and federated pull requests run, after approval**: their workflow comes from the PR's head, so their author wrote it, and their runs are marked as fork runs. A fork run's job gets **no secrets** but the job token, **no variables** but the built-in `CI_FORGE_URL` / `CI_REGISTRY` (stricter than GitHub, which still hands over variables: here they often hold internal hostnames — expressions the forge evaluates for it, `concurrency:` and matrices, can't read them either), and a **read-only token** (`contents: read`, `packages: read`, nothing else) whatever its `permissions:` say. Its jobs wait as *awaiting approval* — not claimable by any runner, and pending in the commit status — until someone with write access to the target approves them on the pull request page (Approve, or Reject, which cancels them); unless the PR's author is a local user with write access to the target, and so is whoever triggered the run (a fork pushed to by someone else needs approving). Federated pull requests always wait. Approval covers the runs on the PR's current head; every new push needs approving again (Forgejo's rule, stricter than GitHub's first-time-contributor one). A fork run's cache saves go to `refs/pull/<N>/head`, so it can't seed the base branch's cache. `github.event.pull_request.head.repo` is the fork (`fork: true`), or for a federated PR its clone URL, and a federated author's `user.login` is `name@host`
94 96 - **`on: pull_request_target`**, with the same `types:` / `branches:` / `paths:` filters as `pull_request`, runs the workflow from the **target branch's head**, on that commit (`github.sha` is the target's head, `github.ref` `refs/heads/<target>`, `ref_name`/`ref_type` the branch's; `head_ref`/`base_ref` and `github.event.pull_request` as for `pull_request`) — for every pull request, same-repository, fork or federated, with the target's secrets and variables and a normal token, and without approval, as on GitHub, because the code that runs is the target's own. A `pull_request_target` workflow the pull request adds doesn't run: it isn't on the target branch. `[skip ci]` doesn't apply to it. **Checking out the pull request's head (`github.event.pull_request.head.sha`) in such a job and running anything from it is the classic way to leak a repository's secrets**: that code then runs with them, and its cache saves land in the target branch's scope
@@ -99,14 +101,14 @@ reinvent them.
99 101 - **`[skip ci]`**, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]` in the head commit's message (any case), or a `skip-checks: true` trailer, starts no `push` or `pull_request` runs — the head commit only, as on GitHub; other events aren't skipped
100 102 - **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 `/`
101 103 - **`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
102 − - **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`. **`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
104 + - **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
103 105 - **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
104 − - Re-run / cancel buttons on the workflow show page. **Cancelling stops running jobs**: the runner hears it in the reply to its next progress report and stops the job, still uploading its log; a cancelled job stays cancelled whatever its runner reports afterwards
106 + - 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
105 107 - **`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
106 108 - **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
107 109 - **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
108 110 - **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
109 − - **GitHub Actions cache server v1**, scoped to the repository. Restore order: the primary key exactly, then each fallback key as a prefix (newest first), each tried on the run's own ref, then — for a `pull_request` run — the PR's base branch, then the default branch; a save goes to the run's own ref. Upload chunks are placed at their `Content-Range` offsets (`actions/cache` sends four at once) and assembled on finalize; a finalized entry is immutable. Age/size eviction worker, which also clears uploads abandoned for a day. **Not reached by a default forgejo-runner**: with `cache.enabled` (its default) the runner starts its own cache server and points `ACTIONS_CACHE_URL` at it, ignoring the URL gitgud sends; jobs only use this one if the runner has `cache.enabled: false` and `ACTIONS_CACHE_URL` set in `runner.envs`. `actions/cache@v4`'s cache service v2 isn't implemented
111 + - **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
110 112 - 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
111 113 - Repo-scoped + org-scoped secrets/variables (AES-256-GCM at rest, key rotation)
112 114 - **Storage**
@@ -121,7 +123,7 @@ reinvent them.
121 123 - **Cargo** ([sparse index](https://doc.rust-lang.org/cargo/reference/registry-index.html)) — `cargo publish`, `add`, `yank`. Prefix-sharded index with real `ETag`/`304` revalidation, `features2`/`v: 2` splitting, and the `explicit_name_in_toml` → `package` rename translation
122 124 - **apt** ([DebianRepository/Format](https://wiki.debian.org/DebianRepository/Format)) — signed `InRelease`, `Packages.xz`, pool. **Namespace-wide repositories at `_all`**: one `deb` line and one keyring file for everything an org (or a user) publishes, rather than one of each per repository. **Browsable**, the way a real mirror is — click down through `dists/` and `pool/` in a browser, in the forge's own theme, with the listing derived from what the repository actually serves so it can neither advertise a 404 nor show a repository you couldn't read
123 125 - **raw** — static files with a JSON manifest, for clients that have no package manager: a microcontroller doing OTA, a shell script, an installer. `manifest.json` lists every version with an absolute URL and a SHA-256 per file and a `latest` pointer, so the whole client protocol is "fetch one document, then fetch a URL out of it". Publish with `curl --upload-file` to `/<version>/<filename>`; one package per repository, and `latest` means the last thing published rather than the highest version string, since raw bytes have no version grammar to sort by
124 − - **OCI** hosted in [zot](https://zotregistry.dev/) — the forge owns `/v2/token` (`docker login` → Basic-auth handshake → RS256 JWT scoped to `repository:<owner>/<name>:pull,push`); zot owns the manifests and blobs. Deployment manifest in `zot.yml`; keypair from `mix gitgud.registry.keygen`; `(package, version)` mirrored via `/_webhooks/zot`
126 + - **OCI** hosted in [zot](https://zotregistry.dev/) — the forge owns `/v2/token` (`docker login` → Basic-auth handshake → RS256 JWT scoped to `repository:<owner>/<name>:pull,push`); zot owns the manifests and blobs. Deployment manifest in `zot.yml`; keypair from `mix gitgud.registry.keygen`; `(package, version)` mirrored via `/_webhooks/zot`, which takes only requests bearing `ZOT_WEBHOOK_SECRET` and refuses every request when it isn't set
125 127 - **Publish from CI with no stored secret** — `${{ github.token }}` is accepted by every registry and authorized to write its own repository only, exactly as at the OCI token endpoint, as far as the workflow's `permissions: packages:` allows (`write` unless it says otherwise)
126 128 - **Signing keys are per namespace**, not per repository — one `signed-by=` entry or one `--public-key` covers every repository an org publishes. Served at `/pkg/_keys/:format/:owner`; private halves encrypted at rest under the `GitGud.Secrets` master key
127 129 - Browseable at `/r/<owner>/<name>/packages`, grouped by format, with paste-ready install *and* publish instructions for both a terminal and a workflow
@@ -247,7 +249,14 @@ These all live in `config/*.exs`. Key ones:
247 249 | `:syms` | Symbols repos can be marked with; `[]` disables the feature | `["⭐", "🚀", "💔", "⁉️", "❤️"]` |
248 250 | `GitGud.Accounts.TwoFactor` `required` / `grace_period_days` | Instance-wide MFA enforcement + grace window | `false` / `14` |
249 251 | `GitGud.Workflows.Workers.CacheEviction` `max_age_days` / `per_repo_max_mb` | `actions/cache` pruning knobs | `90` / `500` / `dry_run: false` |
250 −| `GitGudWeb.PackagesWebhookController` `shared_secret` | Bearer-token gate on the zot webhook (env: `ZOT_WEBHOOK_SECRET`) | `nil` (open in dev) |
252 +| `GitGudWeb.PackagesWebhookController` `shared_secret` | Bearer-token gate on the zot webhook (env: `ZOT_WEBHOOK_SECRET`). **Required**: without it `/_webhooks/zot` refuses every request (fail closed), logged once at boot | `nil` (webhook refuses everything) |
253 +| `GITGUD_HOOK_SECRET` (env) | Shared secret the git hooks and the SSH shim send (`x-gitgud-hook-token`) to `/_hooks/*` and `/_ssh/resolve`; anything else gets a 403. The node mints one at boot and hands it to every `git` it spawns, so the embedded SSH daemon and smart HTTP need nothing. **Set it, to the same value for both, when using the OpenSSH `authorized_keys` shim** (`gitgud-ssh`, from `mix gitgud.ssh.authorized_keys`), which runs under sshd rather than the node | random per boot |
254 +| `GITGUD_HOOK_URL` / `GITGUD_PREHOOK_URL` / `GITGUD_AUTH_URL` (env, hook side) | Where `gitgud-hook`, `gitgud-prereceive` and `gitgud-ssh` reach the node | `http://127.0.0.1:4000/_hooks/post-receive` / `…/_hooks/pre-receive` / `…/_ssh/resolve` |
255 +| `GITGUD_PREHOOK_FAIL_OPEN` (env, hook side) | `1` lets a push through when the pre-receive (branch protection) backend is unreachable or answers with an error — a 403 from a missing or wrong hook secret included, so on an OpenSSH-shim install without `GITGUD_HOOK_SECRET` it bypasses branch protection. Off means fail closed | unset (fail closed) |
256 +| `GITGUD_IMPORT_ALLOW_PRIVATE` (env) | `1`/`true`/`yes` lets imports, mirrors and upstream sync reach private, loopback and link-local addresses (`GitGud.OutboundUrl` `allow_private: [:import]`). The scheme rule and the no-local-paths rule still apply | unset (public addresses only) |
257 +| `GITGUD_WEBHOOK_ALLOW_PRIVATE` (env) | The same for webhook delivery (`:webhook`). Webhooks pointing at a LAN host stop delivering until this is set | unset |
258 +| `GITGUD_FEDERATION_ALLOW_PRIVATE` (env) | The same for ActivityPub actor fetches and inbox delivery (`:federation`) | unset |
259 +| `GitGud.Workflows.Workers.StaleJobReaper` `stale_after_minutes` | A `running` job whose runner hasn't sent an `UpdateTask` or `UpdateLog` (`heartbeat_at`) for this long is re-queued | `5` |
251 260 | `GitGud.Packages.Token` `private_key_pem` | RS256 PEM that signs OCI registry Bearer tokens (env: `GITGUD_REGISTRY_PRIVATE_KEY`) | `nil` (registry auth disabled) |
252 261 | `GitGud.Packages.Token` `issuer` / `audience` / `key_id` / `ttl_seconds` | JWT claim defaults zot must agree on | `gitgud-registry` / `container_registry` / `gitgud-registry-1` / `300` |
253 262 | `:package_formats` | Which registry formats this instance serves. A format left out 404s | all of `pypi` / `hex` / `cargo` / `deb` |
@@ -267,6 +276,56 @@ To open the federation to the whole fediverse rather than an allowlist:
267 276 config :git_gud, GitGud.Federation, federation_mode: :open
268 277 ```
269 278
279 +## Security model, and upgrading
280 +
281 +Who may do what is decided in the contexts and re-checked in every
282 +LiveView event handler, not only by which buttons a page draws: a page
283 +left open after its user lost a right (demoted, banned, sudo window
284 +lapsed) can't use it, and an id sent by the client that isn't this
285 +page's own — another repository's label, webhook, deploy key, report
286 +target — is ignored rather than acted on or confirmed to exist.
287 +Endpoints answer a private repository the caller can't read with the
288 +same 404 as a missing one. Job tokens, runners and caches are each
289 +held to their own job, repository and entry (see [CI](#ci--forgejo-runner)).
290 +Outbound requests to URLs a user or a remote server chose — imports,
291 +mirrors, upstream sync, webhooks, ActivityPub — go through
292 +`GitGud.OutboundUrl`, which refuses private, loopback and link-local
293 +addresses, follows no redirects and pins the address it checked.
294 +
295 +Behaviour that changed under an existing install, as of the October
296 +2026 hardening (slice 78):
297 +
298 +- **Only writers' reviews count.** Approvals and "changes requested"
299 + from someone without write access to the target no longer count
300 + toward the per-repo merge rules or a protected branch's required
301 + approvals; federated reviews don't count either. A PR that met its
302 + approval count with outside reviews may stop being mergeable.
303 +- **Private and internal repositories and organizations no longer
304 + federate.** Their actors and inboxes 404, existing remote followers
305 + stop receiving their activity, and inbound Follow / Offer / Create /
306 + Like / Dislike aimed at one is rejected.
307 +- **Webhooks and imports to private addresses are blocked** unless
308 + `GITGUD_WEBHOOK_ALLOW_PRIVATE` / `GITGUD_IMPORT_ALLOW_PRIVATE` (and,
309 + for federation on a LAN, `GITGUD_FEDERATION_ALLOW_PRIVATE`) is set.
310 + Existing webhooks pointing at a LAN host stop delivering until then.
311 +- **`ZOT_WEBHOOK_SECRET` is required.** Without it the zot webhook
312 + refuses everything, so the package list stops mirroring zot.
313 +- **Hook endpoints need the hook secret.** `/_hooks/*` and
314 + `/_ssh/resolve` refuse requests without `x-gitgud-hook-token`. The
315 + embedded SSH daemon and smart HTTP pass it automatically; an
316 + OpenSSH `authorized_keys` install (`gitgud-ssh`) needs
317 + `GITGUD_HOOK_SECRET` set to the same value for the node and for sshd,
318 + or pushes over it are refused.
319 +- **`branches: ['*']` no longer matches nested branches** in workflow
320 + filters (`*` stops at `/`, as on GitHub; use `'**'`), and `!pattern`
321 + now excludes. Branch-protection and required-check patterns are
322 + unchanged: their `*` still crosses `/`.
323 +- **The job token ends with its job** (plus five minutes) and follows
324 + `permissions:`; a token stored somewhere for later use stops working.
325 +- **Merging a PR takes write access**, labels are an admin's (or the
326 + author's on their own issue or PR), and closing an issue is its
327 + author's or a triager's — anyone with read access used to be able to.
328 +
270 329 ## Storage backends
271 330
272 331 `GitGud.Storage` is a four-method behaviour
@@ -289,6 +348,10 @@ Adding a third (Backblaze, R2, local minio) is one `@behaviour` impl.
289 348 GitGud speaks ForgeFed over ActivityPub with HTTP Signatures
290 349 (draft-cavage-12). It is **allowlist-mode by default** — set
291 350 `federation_mode: :open` to accept deliveries from arbitrary instances.
351 +Only public repositories and organizations have actors; internal and
352 +private ones never federate. Actor fetches and inbox deliveries go
353 +through the outbound-URL guard, so a peer on a private network needs
354 +`GITGUD_FEDERATION_ALLOW_PRIVATE`.
292 355 The federation admin LiveView at `/admin/federation` exposes:
293 356
294 357 - recent deliveries (filterable by status)
@@ -381,8 +444,8 @@ against a matrix context pinned to that leg's single value.
381 444 result, and the legs' outputs merged. `fail-fast` is on by
382 445 default: the first leg to fail cancels its siblings, running ones
383 446 included, as on GitHub (a running leg's runner is told on its next
384 −report, and still uploads its log). `max-parallel` is not implemented — every leg is claimable as
385 −soon as it exists.
447 +report, and still uploads its log). `strategy.max-parallel` caps how many
448 +legs run at once; otherwise every leg is claimable as soon as it exists.
386 449
387 450 ### OCI registry
388 451
@@ -647,7 +710,9 @@ See [ROADMAP.md](./ROADMAP.md). Highlights:
647 710 - Native runner (low priority while the external one works)
648 711 - Code search (Postgres FTS handles issues/PRs; code search is its own beast)
649 712 - RFC 9421 (HTTP Messages) signatures when peers upgrade from draft-cavage-12
650 −- Reputation aging for new federated hosts
713 +- Reputation aging for new federated hosts, and the federation hardening still open: auto-accepted follows, a signature replay cache, activity ids checked against the actor's host
714 +- Artifacts as the stock actions speak them — `actions/upload-artifact@v3` (v1 API shape) and `@v4` (the Twirp `ArtifactService`); and `actions/cache@v4`'s cache service v2
715 +- `environment:` (environment secrets, protection rules), OIDC tokens (`id-token: write`) and `on: release` — no environments, issuer or releases to back them yet
651 716 - Namespace-wide indexes (`_all`) exist only for apt so far — the
652 717 callback is optional on the Format behaviour, so PyPI and Cargo can
653 718 opt in without new routing
@@ -657,7 +722,7 @@ See [ROADMAP.md](./ROADMAP.md). Highlights:
657 722 every consumer that pinned the old one
658 723
659 724 See [PROGRESS.md](./PROGRESS.md) for the slice-by-slice history of how
660 −the forge was built, and what each shipped — currently through slice 75.
725 +the forge was built, and what each shipped — currently through slice 78.
661 726
662 727 ## Project conventions
663 728
modified ROADMAP.md
+18 −10
@@ -9,15 +9,19 @@ See [PROGRESS.md](./PROGRESS.md) for what's done.
9 9
10 10 ## Now — in flight
11 11
12 −Nothing in flight. Slice 75 (package retention + disk usage views)
13 −closed most recently, on top of slice 74 (the in-browser merge editor)
14 −and slice 73 (import adoption, reconcile-mode upstream sync, the
15 −remaining gix NIF ops).
12 +Nothing in flight. Slice 78 (security hardening: page handlers re-check
13 +rights, writers-only reviews, 2FA proof and single-use TOTP, hook
14 +secret, outbound-URL guard, public-only federation) closed most
15 +recently, on top of slice 77 (every plan in
16 +[docs/plans/ci-gaps.md](docs/plans/ci-gaps.md)) and slice 76 (registry
17 +access, cross-repo CI).
16 18
17 19 Federation feature set is closed through slice 42 (reviews over AP
18 20 shipped in slice 38, suggested-blocks in slice 38, actionable reports
19 −queue in slice 42). Remaining federation items are discretionary
20 −(RFC 9421, reputation aging).
21 +queue in slice 42); slice 78 restricted it to public repositories and
22 +tightened signatures and actor fetches. The open hardening decisions
23 +are under **Soon**; the rest is discretionary (RFC 9421, reputation
24 +aging).
21 25
22 26 ## Next — small, high impact
23 27
@@ -25,6 +29,8 @@ queue in slice 42). Remaining federation items are discretionary
25 29 |---|---|---|
26 30 | `${{ gitgud.token }}` in CI + docs | From **G Notes** below. The per-job token is ours, not GitHub's, and every registry and the OCI endpoint now accept it — the name should say so. Keep `${{ github.token }}` working: it is what `forgejo-runner` injects as `GITHUB_TOKEN`, and every existing workflow uses it | small |
27 31 | Credentials for scheduled upstream sync | Slice 73 ships the scheduler, anonymous only: a private upstream reports "needs credentials" and waits for a human. Storing a per-repo credential is the open question (scope, rotation, who may read it) — `UpstreamSync.credentials_for/1` is the single seam it plugs into | small-medium |
32 +| Artifacts the stock actions can use | ci-gaps #23. The v1 "pipelines" API here doesn't match `@actions/artifact`: forgejo-runner points `ACTIONS_RUNTIME_URL` at `/api/actions_pipeline/` (not mounted) and `upload-artifact@v3` sends chunked `PUT`s with `itemPath`. `@v4` is the Twirp `ArtifactService` under `ACTIONS_RESULTS_URL` with signed blob URLs; Forgejo's `routers/api/actions/artifactsv4.go` is the reference. The plan's "Out" section is the starting point | medium |
33 +| Verify reusable workflows on a real runner | `jobs.<id>.uses: ./.github/workflows/x.yml` / `on: workflow_call` are handed to the runner untested (ci-gaps "No plan"). Test first; plan only if act doesn't expand them | small |
28 34 | Namespace-wide indexes beyond apt | `namespace_index/3` is optional on `GitGud.Packages.Format` and only `Deb` implements it. A namespace-wide PyPI index is the same shape and needs no new routing | small-medium |
29 35
30 36 ## Soon — medium investments
@@ -32,6 +38,7 @@ queue in slice 42). Remaining federation items are discretionary
32 38 | Item | Why | Size |
33 39 |---|---|---|
34 40 | Import issues / PRs / wikis alongside git data | Slice 59 imports git data only. Issues need author mapping for users who don't exist locally; PRs need ref rewriting | large |
41 +| Federation hardening decisions | Left open by slice 78, each a maintainer call: follows are auto-accepted, which makes the `followers` and `approved` interaction policies only as strict as "anyone who asked" (manual approval, or follows gated by the policy); the inbox rate limiter keys on the host a request claims before its signature is checked, and rejected bodies are still stored; no replay cache for signatures inside the clock-skew window; activity ids aren't checked against the actor's host | small-medium each |
35 42 | Package signing key rotation | Keys mint themselves on first use and are re-wrapped by the `GitGud.Secrets` master-key rotation, but there is no "roll the keypair" flow — because rolling it breaks every consumer that pinned the old one. Needs a publish-both-then-retire story before it is safe | medium |
36 43
37 44 ## Eventually — bigger / discretionary
@@ -42,8 +49,9 @@ queue in slice 42). Remaining federation items are discretionary
42 49 | RFC 9421 (HTTP Messages) signature | When peers upgrade from draft-cavage-12 | small (mostly mechanical) |
43 50 | Reputation aging for new federated hosts | Strictest policy until N days clean; auto-promote | small-medium |
44 51 | `act_runner` (older Forgejo runner) explicit support | Protocol is identical; verify against a specific binary version | small |
45 −| `strategy.max-parallel` | Matrix builds shipped in slice 72; every leg is claimable the moment it exists, so a wide matrix can saturate a small runner pool. Needs a claim-time count of in-flight legs per `(run, job_id)` | small-medium |
46 −| Per-job concurrency (`concurrency:` groups) | Cancel-in-progress and named concurrency groups across runs; unrelated to the matrix work despite sharing a roadmap line previously | medium |
52 +| `environment:`, OIDC, `on: release` | No environments (secrets, protection rules, deployments), no OIDC issuer for `id-token: write`, no releases. Each is a feature first; the workflow keys pass through to act today and do nothing | large each |
53 +| Cache service v2 (`actions/cache@v4`) | Only worth it once jobs reach this cache: a default forgejo-runner (`cache.enabled`) runs its own and ignores the URL gitgud sends | medium |
54 +| `github.repository_id` in the job | Sent in the task context, but act doesn't map it, so it reads blank. Needs a runner-side change or an env fallback | small |
47 55 | Code search (Meilisearch or similar) | Postgres FTS covers issues/PRs; code search is its own beast | large |
48 56
49 57 ## Explicitly declined
@@ -62,9 +70,9 @@ These are documented in the auto-memory under `project-git-gud-moderation-deferr
62 70 | Rename detection in the merge editor | `merge_analysis/4` reports what git reports, and git's rename detection is on by default — but the editor renders a rename conflict as the paths it names rather than as "this file moved". Worth a pass once someone hits one |
63 71 | Test fixture cleanup | `tmp/test/repos/` wiped on suite start; consider per-test isolation if parallelism grows |
64 72 | Documentation as code grows | This file + PROGRESS.md; append on each slice close |
65 −| `mix precommit` enforces warnings-as-errors | **Currently red**, and has been for a while — 17 sites across 14 files, in three families: unreachable clauses after a type got narrowed (`duration/2`, `format_deny/1`, `actor_name/1`, …), one unused `require Logger`, and genuine type mismatches where `attach_label/2` / `detach_label/2` / `check_review_block/3` are handed a map where a struct is expected. None is a runtime bug today, but the gate is worthless while it is red, and each new warning hides behind the existing noise. Worth one pass with `mix compile --force --warnings-as-errors` |
73 +| `mix precommit` enforces warnings-as-errors | **Currently red**, and has been for a while — 17 sites across 14 files when last counted, in three families: unreachable clauses after a type got narrowed (`duration/2`, `format_deny/1`, `actor_name/1`, …), one unused `require Logger`, and genuine type mismatches where `attach_label/3` / `detach_label/3` / `check_review_block/3` are handed a map where a struct is expected (24ca95f's virtual `:labels` field on `PullRequest` took its count of compile warnings from 23 to 15, so some of these may be gone; recount before starting). None is a runtime bug today, but the gate is worthless while it is red, and each new warning hides behind the existing noise. Worth one pass with `mix compile --force --warnings-as-errors` |
66 74 | `native/gitgud_gix/target/` + `priv/native/gitgud_gix.so` are tracked | ~5 MB of build output that churns on every compile. `.gitignore` now covers the NIF crates, but these predate it and stay tracked until someone `git rm --cached`s them |
67 −| Operator setup doc | A first-deploy README covering compose, env vars, hooks, runner registration, allowlist seeding |
75 +| Operator setup doc | A first-deploy README covering compose, env vars, hooks, runner registration, allowlist seeding. The README's configuration table and "Security model, and upgrading" section now cover the env vars and the hook secret; a walkthrough is still missing |
68 76 | SSH deploy keys over embedded daemon | v1 omits this; deploy keys still work via external SSH wrapper. Wire deploy-key auth into `KeyApi` |
69 77 | SSH stderr → channel stderr | Currently git's stderr lands on BEAM's stderr (iex console). Forward via a wrapper script so the client gets `remote:` messages |
70 78
modified docs/plans/ci-gaps.md
+11 −0
@@ -1,5 +1,16 @@
1 1 # CI gaps against GitHub / Forgejo Actions
2 2
3 +> **Executed** (2026-10-17/19): every plan below landed — artifacts and
4 +> cache ff3668c, job token a9f5a09, runner context e9900fa, job graph
5 +> 15e553d, cancellation and concurrency 6b5083e, checks b812854, filter
6 +> patterns 91138c0, matrix 6b0b893, triggers 2cafbbf 9b97d32 ad37081
7 +> 587faf4 8a80762 a9bbffe, fork pull requests 1b35c31 a5b815d — with
8 +> follow-ups in 5e43417 (server-side ref moves get push events),
9 +> eaf7cde (re-runs), dcb7d88 (runner job ownership, cache reserving
10 +> job) and abee805 (stale-job reaper heartbeat). Still open: #23's
11 +> artifacts v3/v4, and the "No plan" rows. See PROGRESS.md slice 77.
12 +> The table below is the audit as it stood, kept as a record.
13 +
3 14 Status: audit of 2026-10-17, on master at bfce1e7 (after the path-filter,
4 15 `event_name` and `pull_request` work). Each gap was checked in the code;
5 16 the evidence column names the file and function. What forgejo-runner

Parents: abee805