neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Docs: README, ROADMAP and PROGRESS after the CI and security work
876aa0b · Gabriel Morell · 2026-10-19 20:32
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