Commits
a0aa462
.forgejo
android
assets
cli
config
docs
lib
native
packaging
priv
rel
scripts
security-review
test
.credo.exs 1.2 KiB
.dockerignore 1.8 KiB
.formatter.exs 225 B
.gitignore 2.2 KiB
.gitlab-ci.yml 475 B
AGENTS.md 25.6 KiB
app-secrets.example.yml 1.3 KiB
compose.yml 374 B
Dockerfile 7.3 KiB
git 0 B
LICENSE.md 33.5 KiB
mix.exs 5.2 KiB
mix.lock 65.1 KiB
PROGRESS.md 172.7 KiB
README.md 74.6 KiB
ROADMAP.md 8.0 KiB
security-review-ci.md 5.6 KiB
smtp-secret.example.yml 2.9 KiB

GitGud !?

CI status audit status license top language install gitgud install gg

A self-hosted code forge built on Phoenix 1.8 — git over HTTPS + SSH, issues, pull requests, wikis, ForgeFed federation, CI via the Forgejo Actions runner protocol, LFS storage, and private package registries for containers, Python, Elixir, Rust and Debian. Designed to embed the right external tools (Forgejo runner, Garage, zot) rather than reinvent them.

Status: development. The forge is feature-complete enough to host its own development and federate with other ForgeFed-speaking instances, but it has not been hardened for public-facing production use. Stand it up on private infrastructure, evaluate the threat model for your situation, and contribute patches if anything’s off.

What’s in the box

  • Git server
    • Smart-HTTP at /<owner>/<name>.git/* (anonymous read on public repos, basic-auth elsewhere)
    • Embedded SSH daemon with DB-backed key auth (no system git user, no authorized_keys file)
    • Server-side post-receive hook fans out to commit-backfill workers; branches the forge moves itself (pull request merges, applied suggestions, resolved conflicts, fork syncs) go through the same workers, so they get the push run, webhook and caches a push would
    • Git LFS (Batch API) over pluggable object storage
  • Repo browsing
    • Tree, blob, commit, log views with deep linking and a shared repo header on every subpage (title + Issues/Labels/Forks/Compare/Packages/Actions/Settings strip + Fork button)
    • Clone-URL copy widget on the repo show page — three-state expanding pill (HTTP/SSH toggle → copy button → inline URL text), one-click clipboard with execCommand fallback
    • Line-level diff renderer with Lumis-highlighted hunks on PR show, commit show, and compare; parsed diffs cached per (base, head). Lazy per-file expansion — large diffs keep hunks out of the DOM until you open the file
    • Syntax highlighting via Lumis (tree-sitter, Rust NIF) — cached per-blob
    • Full GFM markdown with cross-references (#42, #x2a hex, owner/repo#42), GitHub-style alerts, footnotes, task lists, fenced-code syntax highlighting — cached per-render
    • Language stats: 0.3em GitHub-style bar on the repo page driven by a per-commit Oban job; click for /stats/languages (full table) and /stats/committers (commits + churn per author)
    • Per-user editor theme (curated subset of Lumis themes — Dracula, Solarized, Monokai, Tokyo Night, Catppuccin, etc) — separate from the daisyUI page theme
  • Discovery
    • Dashboard at / for signed-in visitors — PRs waiting on your review (asked of you first, maintainer inference as a fallback), your open PRs and issues, CI, pinned and recently-pushed repos, org invitations. Logged-out visitors get the landing page
    • Org dashboard at /orgs/:handle/dashboard — the same panels narrowed to one organization, and reframed: the landing page is about your work, an org’s is about the org’s, so everyone’s open PRs and issues show alongside yours. Scoped by the org’s visibility rules, so a panel never surfaces a repo you couldn’t have opened yourself
    • Topic tags — up to seven per repo, plus one derived automatically from its primary language and kept current by the stats worker. Browse at /tags, filter the repo listing by them
    • Syms — stars, but with a configurable set of symbols; instance-wide rankings at /syms
    • Repository and organization listings as cards with filter, sort and scope, paginated and deep-linkable (/repositories/2, /orgs/page/2)
  • Collaboration
    • Issues + comments with Write/Preview tabs, label picker, query-string autofill
    • Quote-reply button on every comment — appends > -prefixed source with attribution
    • 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
    • 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
    • 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
    • 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
    • Line comments anchored to a file and line, pinned to the head they were written against
    • 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
    • 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
    • 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
    • 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
    • Forks + fork-sync (fast-forward CAS) + compare-any-two-refs view
    • 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
    • Branch protection, deploy keys, webhooks, wikis (editing a wiki page takes write access)
    • 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
  • Profiles
    • 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.
    • 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.
    • 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.
    • 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.
  • Mobile
    • Android client in 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
    • gg CLI in 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)
    • 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
    • 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
  • Account + org governance
    • Registration modes: :open / :invite_only (default) / :closed, configurable per-instance
    • Invites — single-use tokens with create / list / revoke per user; UI at /users/settings/invites
    • Org visibility: public / internal / private. Repos can’t be more open than their org; tightening an org cascades to over-public repos in one transaction. Public orgs are accessible to anonymous viewers; private orgs require membership.
    • Org membership by invitation at /orgs/:handle/settings/members — an admin invites by handle or email and the invitee accepts from their dashboard; nobody is added to an org without agreeing. Withdraw a pending invite, change roles, remove members, with a last-admin guard so an org can never end up admin-less
    • Audit log at /orgs/:handle/settings/audit, per repository at /r/:owner/:name/settings/audit, and for instance admins at /admin/audit — everything, plus federation blocks, instance settings and registration invites, and the only view with each entry’s address and user agent. Every account has its own security log at /users/settings/security-log (sign-ins by method, failed ones, sign-outs, re-authentication, password and email changes, two-factor, security keys, tokens, SSH keys, and what an org did about you), showing the address of your own entries only. Every audit page takes new entries live, at the top of its first page. Also over the mobile API with a token carrying audit:read (ticked by hand; in no bundle), and from the CLI as gg audit — who changed which CI secret or variable; who was invited, promoted or removed; who created, forked, reconfigured or deleted a repository, and what the org’s own settings became; branch protection, deploy keys, webhooks (by host only), registry tokens and job-token grants; runners minted, registered and removed, and fork runs approved; bans and report decisions. Written in the same transaction as the change so a change can’t happen unrecorded. Never the value. For org admins and instance admins (and a personal repository’s owner), in sudo mode; filters are path segments, by category, person and repository (…/audit/secrets/actor/alice/repo/web/before/<id>), and a deleted account’s or repository’s entries can still be picked out by its old name. More of the org’s history joins it slice by slice (docs/plans/org-audit-log.md); entries are kept forever unless GitGud.Audit retention_days is set
  • Moderation for owners
    • Org and repo admins get their own queue at /orgs/:handle/settings/moderation and /r/:owner/:name/settings/moderation — reports about their content, which previously only an instance admin could see
    • Scoped bans on local users: a repo admin can stop someone acting in their repo, an org admin across every repo the org owns. Neither reaches further; suspending an account instance-wide stays an instance admin’s power
    • Bans stop writing — opening issues and PRs, commenting, reviewing — and fold away what the person already wrote in that scope. Hiding happens at render time, so lifting a ban restores everything without rewriting a row
    • 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
  • Auth & MFA
    • Magic-link login + email/password
    • 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
    • WebAuthn / FIDO2 — hardware security keys (YubiKey, Titan), platform authenticators (Touch ID / Windows Hello), browser passkeys
    • Recovery codes shared across factors; instance-level enforcement with N-day grace period
    • 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
  • Federation (ForgeFed / ActivityPub)
    • 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
    • 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
    • Push / ticket / merge / comment activities outbound; per-task hourly key refresh of stale remote actors
    • Inbound: Follow, Offer (federated PR), Update, Reject, Create(Note) comments, Like/Dislike as PR reviews
    • Outbound Offer from the PR new page; outbound Like/Dislike on local reviews of federated PRs
    • 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
    • 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.
    • mix gitgud.federation.suggest_blocks — peer block lists become a review queue, not enforcement (cascades are deliberately declined)
  • CI
    • Speaks the Forgejo Actions runner protocol (Twirp) — point any modern forgejo-runner at it; protocol path autodetected (ping.v1, runner.v1, with/without _apis/)
    • Repo-scoped + org-scoped runner registration with one-shot tokens minted from the settings UI
    • 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
    • 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
    • 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
    • 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.
    • 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
    • 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
    • on: workflow_dispatch from the Actions page (“Run workflow”, for anyone who can write to the repository: pick a branch and fill in the inputs) or the Gitea/GitHub API (POST /api/v1/repos/:owner/:name/actions/workflows/:file/dispatches, a ci:write token or a job token). Declared inputs: are checked as GitHub checks them — an undeclared input, a missing required one, a choice outside its options, a boolean that isn’t true/false or a number that isn’t numeric answers 422 — and every declared input, defaults applied, reaches the job as inputs.* and github.event.inputs.* (strings)
    • on: schedule (- cron:, five-field cron in UTC): read from the default branch’s latest commit and run there, whichever branch the workflow file was pushed to; github.event.schedule is the cron that fired. Crons that can fire more often than every five minutes (GitHub’s minimum), @reboot and ones that don’t parse are ignored with a warning in the log. Archived repositories don’t run their schedules
    • on: create / on: delete when a branch or tag is created or deleted (branches: / tags: filters don’t apply, as on GitHub). create runs the new ref’s workflows at its commit, alongside its push run; delete runs the default branch’s workflows at its head, since the ref is gone (nothing runs when the default branch itself is deleted). github.event.ref is the short name, github.event.ref_type branch or tag
    • on: workflow_run (workflows: by name:, types: completed / requested (the default) / in_progress, branches: on the triggering run’s head branch): runs the default branch’s workflows at its head, whether the triggering run succeeded or failed (github.event.workflow_run.conclusion); chains stop three levels deep. The workflow_run webhook fires for every finished run, failed ones included
    • [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
    • 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 /
    • 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
    • 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
    • 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, and the job concludes warning (an amber ⚠ badge) rather than failed; any other expression there counts as false. A job can also conclude warning though it succeeded, by setting the output gitgud-conclusion to warning (outputs: { gitgud-conclusion: ${{ steps.scan.outputs.conclusion }} }; any case, surrounding whitespace ignored, any other value ignored) — a gitgud extension GitHub and Forgejo read as an ordinary output, so there the job just passes. A job that failed without continue-on-error stays failed whatever its outputs say. Dependents see a warning job’s needs.<id>.result as success, as GitHub does for continue-on-error; a run whose jobs passed, were skipped or warned concludes success, and its page says how many warnings it had. The mobile API (and gg ci) report the job’s status as warning; the workflow_run webhook carries the run’s conclusion, success (there’s no per-job webhook, so nothing says GitHub’s neutral). A re-run starts the job’s outputs afresh. Skipped jobs don’t fail a run. Unlike GitHub, cancelling a run cancels its waiting jobs too, always() or not
    • 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
    • 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 ”); 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
    • 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; warning is warning (Forgejo’s state, an amber pill); 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
    • 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 passed: success, or warning (a skipped job reports 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
    • 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
    • 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
    • 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 PUTs with itemPath, container item listing). @v4 (the Twirp ArtifactService under ACTIONS_RESULTS_URL) isn’t supported
    • Repo-scoped + org-scoped secrets/variables (AES-256-GCM at rest, key rotation)
  • Storage
    • Pluggable GitGud.Storage behaviour
    • GitGud.Storage.Disk (default) — single-node dev, single-node prod, NFS-shared multi-node
    • GitGud.Storage.Garage — S3-flavored (Garage)
    • Add another with one @behaviour impl
  • Package registries — several formats served by the forge on one substrate, plus OCI in zot
    • PyPI (PEP 503 + PEP 691) — pip install --index-url …/simple/, twine upload. Both serialisations off one data structure, negotiated on Accept with real q-values; PEP 592 yank state; name normalisation applied before every lookup
    • Hex (registry v2) — mix hex.repo add + {:dep, "~> 1.0", repo: "…"}. Protobuf encoded per request out of Postgres rather than rebuilt by mix hex.registry build, so publishing is an insert instead of O(everything)
    • Cargo (sparse index) — 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
    • apt (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
    • 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
    • OCI hosted in zot — 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
    • 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)
    • 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
    • Browseable at /r/<owner>/<name>/packages, grouped by format, with paste-ready install and publish instructions for both a terminal and a workflow

Architecture

                ┌───────────────────────────────────────────┐
                │           Phoenix endpoint (web)          │
   git+ssh ───► │  • smart-HTTP plug   • LFS plug           │
   git+https ─► │  • LiveView UI       • AP inbox+outbox    │
                │  • runner Twirp API  • storage controller │
                └───┬────────────┬───────────┬─────────────┘
                    │            │           │
            ┌───────▼──────┐ ┌───▼─────┐ ┌───▼─────────────┐
            │  Postgres    │ │  Oban   │ │  GitGud.Cache   │
            │  + git_refs/ │ │  jobs   │ │  (Nebulex/ETS)  │
            │  git_commits │ │         │ │                 │
            │  cache       │ └─────────┘ └─────────────────┘
            └──────────────┘
                    │
            ┌───────▼──────┐                ┌──────────────────┐
            │  gix (NIF)   │      ┌──────► │  GitGud.Storage   │
            │  + git CLI   │      │        │  Disk / Garage    │
            │  fallback    │      │        │  LFS + CI blobs   │
            └──────────────┘      │        │  + package bytes  │
                    │             │        └──────────────────┘
                    │             │
            ┌───────▼─────────────┴──────────────────────────┐
            │             priv/repos/<owner>/<name>.git      │
            │             priv/storage/<bucket>/<key>        │
            └────────────────────────────────────────────────┘

            ┌────────────────────────────────────────────────┐
            │  gitgud_apt (NIF) — OpenPGP signatures for the  │
            │  apt Release, plus the xz/zstd OTP can't do     │
            └────────────────────────────────────────────────┘

Embed, don’t reinvent. The stance is to delegate to mature external tools where they exist:

  • forgejo-runner for CI execution (we just speak its protocol)
  • Garage (or any S3) for production object storage
  • zot for the OCI registry — deployed alongside via zot.yml; the forge owns /v2/token auth and a Postgres mirror of registry metadata, zot owns the manifests + blobs
  • gix (Rust) for git operations via a Rustler NIF, with a git CLI fallback for anything not yet wired through the NIF
  • sequoia-openpgp (Rust) for the OpenPGP signatures apt requires — OTP has no OpenPGP at all, and this is the one place in the registries that needed a NIF

The same stance is why the other four registries are not embedded. The OCI distribution spec is a genuine protocol — manifests, blob dedup, cross-mounting, GC, conformance — so zot earns its place. An apt repository is static files over plain HTTP; a PyPI simple index is two documents; a Cargo sparse index is a tree of newline-delimited JSON. There is no daemon to embed, so they’re served directly:

GitGud.Packages.Format      # index/2, store/3, content_type/1
  ├── Formats.Pypi          #   + normalize/1        — PEP 503 folds -_.
  ├── Formats.Hex
  ├── Formats.Cargo
  └── Formats.Deb           #   + namespace_index/3  — the _all repository

Everything the four share already existed — blob storage (GitGud.Storage), namespacing (Package / PackageVersion / PackageFile), auth tokens, a UI. What differs per format is only the index shape and the upload framing, which is what the behaviour captures. One dispatcher at /pkg/:format/:owner/:name/*rest resolves the repository, authenticates, authorizes, and hands the rest to the adapter.

Quick start (development)

# Postgres expected on localhost:54328 (see config/dev.exs)
mix setup            # deps, db, migrations, esbuild + tailwind install
iex -S mix phx.server

Open http://localhost:4000 and register. The embedded SSH daemon listens on port 2222 — once you’ve added a key under /users/settings/ssh-keys, you can:

git remote add origin ssh://<handle>@localhost:2222/<owner>/<repo>.git
git push -u origin master

<handle> is the local-part of your registered email (Gerrit-style identity carrier — the only way the SSH channel handler can identify you between is_auth_key and the exec callback).

Seeding something to click on

mix gitgud.seed.conflicts            # --owner <handle>, --prefix <name>

Builds an unmergeable pull request carrying one of each kind of conflict (content, add/add, modify/delete, binary) and a repository linked to a local stand-in upstream that has diverged — the two things the merge editor exists for. Dev only; runs beside mix phx.server without fighting it for ports, and re-running replaces what the last run made.

Configuration knobs

These all live in config/*.exs. Key ones:

Knob What Default
GitGud.Storage impl Object storage backend GitGud.Storage.Disk
GitGud.Storage.Disk root Where Disk writes blobs priv/storage
GitGud.Ssh.Server enabled Embedded SSH daemon false (dev: true)
GitGud.Ssh.Server port SSH daemon port 2222
:registration_mode :open / :invite_only / :closed :invite_only
GitGud.Federation federation_mode :allowlist or :open :allowlist
GitGud.Cache Nebulex cache sizing 50k entries / 200MB
:syms Symbols repos can be marked with; [] disables the feature ["⭐", "🚀", "💔", "⁉️", "❤️"]
GitGud.Accounts.TwoFactor required / grace_period_days Instance-wide MFA enforcement + grace window false / 14
GitGud.Workflows.Workers.CacheEviction max_age_days / per_repo_max_mb actions/cache pruning knobs 90 / 500 / dry_run: false
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)
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
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
GITGUD_PREHOOK_FAIL_OPEN (env, hook side) 1 lets a push through when the pre-receive (branch protection) backend is unreachable — curl missing, connection refused or timed out — or answers 5xx. Never on a 4xx such as a 403 from a missing or wrong hook secret, and never when GITGUD_HOOK_SECRET is unset. The node logs a warning at boot while it is set unset (fail closed)
GITGUD_TRUSTED_PROXIES Comma-separated CIDRs of reverse proxies whose X-Forwarded-For is believed. The client address (for rate limits and logs) is the first hop, from the right, that isn’t one of them loopback, 10/8, 172.16/12, 192.168/16, fc00::/7
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)
GITGUD_WEBHOOK_ALLOW_PRIVATE (env) The same for webhook delivery (:webhook). Webhooks pointing at a LAN host stop delivering until this is set unset
GITGUD_FEDERATION_ALLOW_PRIVATE (env) The same for ActivityPub actor fetches and inbox delivery (:federation) unset
GitGud.Audit retention_days Audit log entries older than this many days are pruned daily. Config only, not an admin setting, so shortening the trail takes a deploy; boot refuses anything but nil or a positive integer nil (kept forever)
GitGud.Advisories source Dependency alerts: :mirror keeps OSV’s dumps on the instance (nothing leaves it), :api asks api.osv.dev (which sees each scan’s package names and versions), :off turns the feature off whatever orgs chose. Orgs and personal repositories still turn it on themselves :mirror
GITGUD_OSV_SCANNER (env) → GitGud.Advisories scanner Where osv-scanner is. Not bundled: otherwise /usr/libexec/gitgud/osv-scanner, then PATH. Without one, nothing is scanned and the Security page says so. On Kubernetes, an init container copies it from ghcr.io/google/osv-scanner (pin the digest) into an emptyDir — see docs/plans/dependency-security.md unset
GITGUD_OSV_DB_DIR (env) → GitGud.Advisories db_dir Where the :mirror source keeps OSV’s dumps, per ecosystem (Hex + crates.io ≈ 5 MB; npm ≈ 210 MB on disk, ~600 MB of memory while a scan reads it) .osv-db in the repositories directory
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
GitGud.Packages.Token private_key_pem RS256 PEM that signs OCI registry Bearer tokens (env: GITGUD_REGISTRY_PRIVATE_KEY) nil (registry auth disabled)
GitGud.Packages.Token issuer / audience / key_id / ttl_seconds JWT claim defaults zot must agree on gitgud-registry / container_registry / gitgud-registry-1 / 300
:package_formats Which registry formats this instance serves. A format left out 404s all of pypi / hex / cargo / deb
:package_registry_url Absolute base URL the registries put in index documents (behind a proxy that rewrites the host) endpoint URL
GITGUD_SECRETS_MASTER_KEY (env) → GitGud.Secrets master_key AES-256-GCM key for everything encrypted at rest: CI secrets, TOTP secrets, import tokens, package signing keys’ private halves. Base64 of exactly 32 random bytes (openssl rand -base64 32). Required in prod: boot refuses a missing, malformed or all-zero key dev/test: the all-zero key (public — never use it)
GITGUD_SECRETS_KEY_ID (env) → GitGud.Secrets primary_key_id Id stored with every row the key above encrypts. Anything but default, which names the old all-zero key; change it whenever the key changes k1
GITGUD_SECRETS_OLD_KEYS (env) → GitGud.Secrets master_keys Earlier keys kept readable after a rotation, id:base64,id:base64. Rows under them are re-encrypted with the primary key by bin/migrate. The all-zero key is always registered as default (decrypt only) so rows written before keys came from the environment keep working unset
GITGUD_RUNNER_JWT_SECRET (env) → GitGud.Workflows.RunnerJwt secret HS256 secret that signs CI job tokens (${{ github.token }}). At least 32 bytes (openssl rand -base64 48). Required in prod: boot refuses a missing, short or dev-default value. Changing it invalidates the tokens of jobs running at the time dev/test: a fixed string in config/config.exs (public — never use it)

To swap to S3-flavored storage in prod:

config :git_gud, GitGud.Storage, impl: GitGud.Storage.Garage
config :ex_aws, :s3, scheme: "http://", host: "garage.internal", port: 3900, region: "garage"

To open the federation to the whole fediverse rather than an allowlist:

config :git_gud, GitGud.Federation, federation_mode: :open

Security model, and upgrading

Who may do what is decided in the contexts and re-checked in every LiveView event handler, not only by which buttons a page draws: a page left open after its user lost a right (demoted, banned, sudo window lapsed) can’t use it, and an id sent by the client that isn’t this page’s own — another repository’s label, webhook, deploy key, report target — is ignored rather than acted on or confirmed to exist. Endpoints answer a private repository the caller can’t read with the same 404 as a missing one. Job tokens, runners and caches are each held to their own job, repository and entry (see CI). Outbound requests to URLs a user or a remote server chose — imports, mirrors, upstream sync, webhooks, ActivityPub — go through GitGud.OutboundUrl, which refuses private, loopback and link-local addresses, follows no redirects and pins the address it checked.

Behaviour that changed under an existing install, as of the October 2026 hardening (slice 78):

  • Sign-ins, sign-in emails, sign-ups and runner polls are rate limited (GitGud.RateLimit, per node). Ten wrong passwords per account or fifty per client address in 15 minutes lock out password sign-in — on the login form and Basic auth for git (answered 429) and the package registries — until the window passes; email links still work. Five sign-in emails per address and twenty per client an hour; ten sign-ups per client an hour; twenty bad runner registration tokens per client in 15 minutes; a runner polling FetchTask more than 120 times a minute is told there is no work. Tokens (ggp_…, job tokens) are not limited. Behind a proxy outside the private ranges, set GITGUD_TRUSTED_PROXIES, or every client shares the proxy’s limit.

  • Runner registration tokens are single-use and expire. A repository or organization registration token now registers one runner and lapses after 24 hours (registration_token_ttl_seconds); tokens minted before the upgrade lapse a day after its migration. The instance-wide GITGUD_RUNNER_REG_TOKEN stays reusable, for pools.

  • Only writers’ reviews count. Approvals and “changes requested” from someone without write access to the target no longer count toward the per-repo merge rules or a protected branch’s required approvals; federated reviews don’t count either. A PR that met its approval count with outside reviews may stop being mergeable.

  • Private and internal repositories and organizations no longer federate. Their actors and inboxes 404, existing remote followers stop receiving their activity, and inbound Follow / Offer / Create / Like / Dislike aimed at one is rejected.

  • Webhooks and imports to private addresses are blocked unless GITGUD_WEBHOOK_ALLOW_PRIVATE / GITGUD_IMPORT_ALLOW_PRIVATE (and, for federation on a LAN, GITGUD_FEDERATION_ALLOW_PRIVATE) is set. Existing webhooks pointing at a LAN host stop delivering until then.

  • ZOT_WEBHOOK_SECRET is required. Without it the zot webhook refuses everything, so the package list stops mirroring zot.

  • Hook endpoints need the hook secret. /_hooks/* and /_ssh/resolve refuse requests without x-gitgud-hook-token. The embedded SSH daemon and smart HTTP pass it automatically; an OpenSSH authorized_keys install (gitgud-ssh) needs GITGUD_HOOK_SECRET set to the same value for the node and for sshd, or pushes over it are refused.

  • branches: ['*'] no longer matches nested branches in workflow filters (* stops at /, as on GitHub; use '**'), and !pattern now excludes. Branch-protection and required-check patterns are unchanged: their * still crosses /.

  • The job token ends with its job (plus five minutes) and follows permissions:; a token stored somewhere for later use stops working.

  • Merging a PR takes write access, labels are an admin’s (or the author’s on their own issue or PR), and closing an issue is its author’s or a triager’s — anyone with read access used to be able to.

  • Production no longer boots on the built-in keys. Up to now config/config.exs shipped the secrets master key (all zeros) and the job-token signing secret, and nothing in prod replaced them: anyone who read the source could forge a job token with any permissions for any running job, and decrypt every stored CI secret, TOTP secret, import token and package signing key with a database dump. Prod now requires GITGUD_RUNNER_JWT_SECRET, GITGUD_SECRETS_MASTER_KEY and (optionally, default k1) GITGUD_SECRETS_KEY_ID — see Configuration knobs. Order to deploy it in:

    1. Generate both values (openssl rand -base64 48 for the JWT secret, openssl rand -base64 32 for the master key) and store them where the deployment reads secrets (a k8s Secret; /etc/gitgud/gitgud.env for the .deb — a conffile, so an upgraded install keeps its old file and won’t boot until they’re added).
    2. Give them to both the migrate step and the app, before the new release rolls out — the previous release ignores them, so this is safe to do first; the new one won’t boot without them. bin/migrate now re-encrypts every row still under the old key with the new one after running migrations (GitGud.Secrets.rewrap_all/0, also bin/git_gud eval 'GitGud.Release.rewrap_secrets()'); it prints counts only, and is a no-op once everything is moved.
    3. Deploy: migrate first, then the app. The old all-zero key stays registered as default for decrypting only, so nothing is lost if a row is written under it during the rollout — the next migrate moves it. Pods still running the previous release can’t read rows already moved, so CI secrets and TOTP may fail on them until the rollout finishes.
    4. Jobs running during the deploy lose their job token (it was signed with the old secret) and need a re-run.

    Anything encrypted under the all-zero key should be treated as disclosed: rotate the CI secrets themselves (API tokens, deploy keys) if the database or a backup of it may have left your hands. To rotate the master key later, set a new key and id and move the old pair into GITGUD_SECRETS_OLD_KEYS; the next migrate re-encrypts.

Storage backends

GitGud.Storage is a four-method behaviour (put / get / delete / presign_url). It backs LFS objects, CI artifacts and package bytes — the latter under content-addressed keys, so re-publishing identical bytes costs nothing. Two implementations ship:

  • Disk — filesystem-backed. Presigned URLs route through a local StorageController that verifies a Phoenix.Token. Works for single-node dev, single-node prod, and multi-node prod over a shared NFS mount. No additional services required.
  • Garage — S3 via ExAws.S3. Real presigned URLs. Use for any multi-node prod that doesn’t want to share a filesystem.

Adding a third (Backblaze, R2, local minio) is one @behaviour impl.

Federation

GitGud speaks ForgeFed over ActivityPub with HTTP Signatures (draft-cavage-12). It is allowlist-mode by default — set federation_mode: :open to accept deliveries from arbitrary instances. Only public repositories and organizations have actors; internal and private ones never federate. Actor fetches and inbox deliveries go through the outbound-URL guard, so a peer on a private network needs GITGUD_FEDERATION_ALLOW_PRIVATE. The federation admin LiveView at /admin/federation exposes:

  • recent deliveries (filterable by status)
  • quarantine queue for policy_approved interactions
  • instance + actor block CRUD
  • allowlist CRUD with mode toggle
  • open report queue with dismiss / action / forward-to-source actions (instance-wide; org and repo admins see the reports scoped to their own content on their own moderation pages)

Federated PRs (inbound Offer) materialize as local PR rows with source_actor_id pointing at the remote actor and no author_id. Update / Reject / comment-via-Create(Note) follow the same shape.

Outbound Offer (local PR against a remote repo) is available from the PR new page via the “Send to a remote repository” toggle — enter the target repo’s AP actor URL.

What’s declined by design:

  • Defederation cascades (auto-blocking based on peer block lists) — becomes a coordinated-harassment vector; ForgeFed lacks signed block-list semantics
  • Automated content classification (ML) — drift, bias, opaque appeals; use as a signal feeding admin queue at most

Caching

GitGud.Cache is Nebulex (local ETS adapter) started under the app supervisor. Used for:

  • syntax-highlighted source blobs (key: sha256(content) + lang + theme)
  • rendered markdown (key: sha256(body) + theme)

Keys are content-hash based, so entries are reusable across repos and refs and never go stale. To partition or replicate across a cluster, swap the adapter — call sites don’t change.

CI / Forgejo runner

Point a forgejo-runner at /api/actions/_apis/runner.v1.*. Register via RunnerService/Register; the runner is then assignable to a repo / org pool. On each job poll we mint an HS256 JWT per task — the job’s ${{ github.token }} — that gates the artifact + cache endpoints under /api/actions/_apis/... and authenticates the job to git, the package registries and the API, within the workflow’s permissions: and only while the job runs.

The registration UI (/r/<owner>/<name>/settings/runners or /orgs/<handle>/settings/runners) emits ready-to-paste snippets for Native binary, Docker, Podman, Podman Quadlet, and Kubernetes — all built around the official forgejo-runner generate-config → register → daemon --config runner-config.yml flow per the Forgejo install docs.

The runner is external. A native runner is on the roadmap but the external one works today and is well-tested by the Forgejo community.

Matrix builds across architectures

Label each runner with what it is, in the registration UI’s labels field — self-hosted,arm64,linux on the Pi, self-hosted,amd64,linux on the x86 box — then let the matrix pick:

jobs:
  build:
    strategy:
      matrix:
        arch: [amd64, arm64]
    runs-on: [self-hosted, "${{ matrix.arch }}"]
    steps:
      - uses: actions/checkout@v4
      - run: make build ARCH=${{ matrix.arch }}

That is two jobs, claimed by two runners, running at the same time. A job is claimable when the runner carries every label its runs-on lists — GitHub’s rule — so extra labels on a runner never disqualify it and a missing one always does. runs-on: self-hosted matches any self-registered runner, whatever else it carries.

runs-on is the one expression the forge resolves itself, because it decides which runner and there is nobody else to ask yet. Everything else — ${{ matrix.arch }} inside a step — is evaluated by the runner against a matrix context pinned to that leg’s single value.

needs: build waits for every leg, and a downstream job sees one needs.build however many machines produced it: the worst leg’s result, and the legs’ outputs merged. fail-fast is on by default: the first leg to fail cancels its siblings, running ones included, as on GitHub (a running leg’s runner is told on its next report, and still uploads its log). strategy.max-parallel caps how many legs run at once; otherwise every leg is claimable as soon as it exists.

OCI registry

When you want pushes from your workflows (docker/build-push-action, kaniko, etc.) to land in your own registry, deploy zot alongside the app:

mix gitgud.registry.keygen \
  --out-priv registry-priv.pem \
  --out-pub  registry-pub.pem

# private half → elixir-args.gitgud_registry_private_key
# public  half → zot.registry-pub.pem
# (the task prints kubectl one-liners for both)

kubectl apply -f zot.yml

Add GITGUD_REGISTRY_PRIVATE_KEY to the app pod (already wired in app.yml) and a DNS record for your registry hostname pointing at the ingress. Workflows then push to <registry-host>/<owner>/<repo>:<tag> and the auth handshake flows through the forge.

Package registries

Unlike the OCI registry, these need no deployment step at all — no sidecar, no keygen, no DNS. They are served by the app, keys mint themselves on first use, and the packages page prints the exact lines to paste.

/pkg/pypi/<owner>/<repo>/simple/            pip
/pkg/hex/<owner>/<repo>                     mix
/pkg/cargo/<owner>/<repo>/                  cargo  (sparse+…)
/pkg/deb/<owner>/<repo>                     apt
/pkg/deb/<owner>/_all                       apt, whole namespace
/pkg/raw/<owner>/<repo>/manifest.json       raw   (then latest/<file>)
/pkg/_keys/<format>/<owner>                 the namespace's public key

Publishing

From a terminal, with a registry token from /r/<owner>/<name>/settings/registry-tokens:

# Python
twine upload --repository-url https://<host>/pkg/pypi/<owner>/<repo>/ \
  --username __token__ --password "$TOKEN" dist/*

# Rust — after adding [registries.<repo>] index = "sparse+…" to .cargo/config.toml
cargo login --registry <repo> "$TOKEN"
cargo publish --registry <repo>

# Elixir
mix hex.build && curl -H "Authorization: $TOKEN" \
  --data-binary "@$(ls *.tar)" https://<host>/pkg/hex/<owner>/<repo>/publish

# Debian
curl -H "Authorization: $TOKEN" --data-binary @pkg.deb \
  "https://<host>/pkg/deb/<owner>/<repo>/upload?dist=stable&component=main"

From CI, there is no token to create. ${{ github.token }} is minted per job and authorized to write that job’s own repository — the same deal the container registry already offers — unless the workflow’s permissions: leave out packages: write:

- name: Publish to the forge
  run: twine upload --repository-url https://<host>/pkg/pypi/${{ github.repository }}/ dist/*
  env:
    TWINE_USERNAME: __token__
    TWINE_PASSWORD: ${{ github.token }}

Consuming, and the signing keys

Hex and apt both require signatures, and both want the public half pinned by the consumer. Those keys belong to the namespace — an organization, or a user for personal repositories — not to individual repositories, so one pin covers everything that namespace publishes:

curl -fsSL https://<host>/pkg/_keys/deb/acme \
  | sudo tee /etc/apt/keyrings/acme.asc > /dev/null

# one line for every repository acme publishes…
echo "deb [signed-by=/etc/apt/keyrings/acme.asc] https://<host>/pkg/deb/acme/_all stable main" \
  | sudo tee /etc/apt/sources.list.d/acme.list

# …or one repository on its own, verified by the same key
echo "deb [signed-by=/etc/apt/keyrings/acme.asc] https://<host>/pkg/deb/acme/tools stable main" \
  | sudo tee /etc/apt/sources.list.d/acme-tools.list

Both roots are browsable in a browser as well as by apt — dists/ and pool/ list like any mirror’s autoindex, styled with whatever daisyUI theme the viewer has selected. The listing is derived from the paths the repository actually serves, so it cannot offer a link that 404s, and it is built from the same permission-filtered repository set as the index, so it cannot name a repository the viewer couldn’t have fetched directly. apt never requests a directory, so none of this is on its path.

_all can never collide with a repository, because a repository name must start with an alphanumeric. It aggregates every repository in the namespace the caller may read — anonymous sees the public ones, a user sees what they can read — and the Release is signed per request, so a filtered view is still a correctly signed one.

Private registries authenticate the way each ecosystem expects: twine sends Basic with __token__ in the username, mix and cargo send the bare token, apt reads /etc/apt/auth.conf.d/. A 401 carries a Basic challenge, which pip and apt retry against.

Retention, and what the disk is doing

Registries only grow on their own, so each package has a retention policy: keep the newest N versions (24 by default), or keep what fits in a byte budget. A package may set its own, but only a stricter one — the instance policy is both the default and the ceiling.

Pinned versions are never pruned, capped per package (12 by default) so the escape hatch can’t quietly become an off switch. Storage objects are refcounted before deletion, since identical bytes published twice share one content-addressed object; OCI versions delete their manifest in zot first, and keep the mirror row if zot refuses.

Nothing is deleted until an admin says so. The nightly pass runs in reporting mode out of the box and logs what it would free; /admin/packages shows that number, the levers, and a prune-now button next to it. Usage is visible at three levels — per repository on its packages page, per organization at /orgs/:handle/packages, and instance-wide in the admin view.

GitGud ships itself as a .deb

The most honest test of a registry is using it, so CI builds an installable Debian package of the forge and publishes it to the forge’s own apt repository — deb job in .forgejo/workflows/build.yml, no stored credential, the per-job token again.

Two suites are published — trixie and forky — from one build. They are the same bytes under two versions (…+trixie, …+forky), one per pool filename: a trixie-linked release runs on forky, and hexpm/elixir ships no forky-based builder image to compile a native one against. When it does, the suites become real per-suite builds and nothing about the deb lines changes.

The package is a self-contained OTP release (the Erlang runtime is bundled, so no Elixir or Erlang install is needed) plus a systemd unit and one config file:

/opt/gitgud/                 the release — bin/, lib/, releases/, erts-*
/etc/gitgud/gitgud.env       configuration; a conffile, so upgrades keep your edits
/lib/systemd/system/gitgud.service
/var/lib/gitgud/{repos,storage,tmp}   created by postinst, owned by the `gitgud` user

Installing it:

curl -fsSL https://<host>/pkg/_keys/deb/<owner> \
  | sudo tee /etc/apt/keyrings/<owner>.asc > /dev/null
# $(lsb_release -cs) resolves to trixie or forky
echo "deb [signed-by=/etc/apt/keyrings/<owner>.asc] https://<host>/pkg/deb/<owner>/gitgud $(lsb_release -cs) main" \
  | sudo tee /etc/apt/sources.list.d/gitgud.list
sudo apt update && sudo apt install gitgud

sudoedit /etc/gitgud/gitgud.env      # DATABASE_URL, SECRET_KEY_BASE, PHX_HOST,
                                     # GITGUD_RUNNER_JWT_SECRET, GITGUD_SECRETS_MASTER_KEY
sudo /opt/gitgud/bin/migrate
sudo systemctl start gitgud

The package is enabled but not started on install: the shipped config has CHANGEME in it, and starting against that would only produce a crash loop in the journal.

packaging/deb/build-deb.sh is what CI runs, and it takes a release directory, so the same package builds locally:

MIX_ENV=prod mix release
packaging/deb/build-deb.sh _build/prod/rel/git_gud

Provenance is in the control file — every build records the commit it came from — and the version sorts as a pre-release (0.1.0~ci<date>.g<sha>), so a tagged 0.1.0 upgrades over it cleanly.

The apt NIF

apt is the one format that needed native code: it requires an OpenPGP signature on Release, and OTP has no OpenPGP — :public_key speaks PKCS and X.509. native/gitgud_apt binds sequoia-openpgp through Rustler, and also carries xz and zstd, since a .deb’s control member is control.tar.xz on anything since squeeze and :erl_tar knows only gzip.

Its crypto backend is sequoia’s OpenSSL one, which costs exactly one package in the builder image and nothing in the runtime image:

backend builder needs runtime needs crypto
crypto-openssl libclang-dev (libssl-dev already there for gix) libssl — from openssl, named in the Dockerfile constant-time, audited
crypto-nettle (default) libclang-dev, nettle-dev, libgmp-dev libnettle/libgmp — present only transitively, via git constant-time, audited
crypto-rust nothing nothing variable-time, gated behind allow-variable-time-crypto

libclang-dev is there because openssl-sys generates its bindings with bindgen, which dlopens libclang. Every sequoia backend that binds C needs it; only the pure-Rust one avoids it, and that one is the wrong trade for code holding a signing key. The builder is a separate stage, so the runtime image never sees it.

This table is measured, not reasoned: the first version of this NIF built fine locally and then failed in the builder, because the developer’s machine happened to have clang installed. podman build -f Dockerfile . is the only thing that settles it. The compression crates are pure Rust, so they cost nothing either way.

Release tasks

A release ships no Mix, so the one-shot maintenance jobs are functions on GitGud.Release, run through eval in a deployed pod:

kubectl -n git-gud exec deployment/git-gud -- \
  bin/git_gud eval 'GitGud.Release.<task>()'
Task What it does
migrate() Run pending migrations. The one you need on every deploy that ships one
rollback(GitGud.Repo, version) Roll a migration back
promote_admin("you@example.com") Make that account an instance admin — how a fresh install gets its first one (mix gitgud.promote_admin from a checkout). The step-by-step for a new instance, container or .deb, is at /run-your-own
keygen() Mint the RS256 keypair for the OCI token endpoint, printing both PEMs with ===PRIV=== / ===PUB=== delimiters so they can be piped into kubectl create secret without touching a laptop
configure_repos() Backfill the per-repo git config actions/checkout needs (uploadpack.allow*SHA1InWant). New repos get it at creation; this fixes older ones
backfill_language_tags() Derive each repo’s language tag from the stats already computed for it. New stats tag their repo as they’re written, so this is for repos whose stats predate the tag
backfill_fdroid_icons() Fill in the app name and launcher icon the F-Droid index shows, by re-reading published APKs. Both are read at upload, so apps published before the adapter could resolve resources.arsc have neither. Takes all: true for every version rather than each app’s newest, and dry_run: true to report without writing
backfill_licenses() Re-detect every repository’s licence from the licence file at its default branch’s tip. A push refreshes it, so this is for repositories that predate detection or that nobody pushes to
sentry_test() Send a synchronous test event to verify the DSN

All of them are idempotent and safe against a live instance: each starts only what it needs rather than booting the application, which would contend with the running server for its fixed SSH and metrics ports.

mix gitgud.backfill_language_tags and mix gitgud.registry.keygen are the same jobs for a checkout — they share their implementation with the release functions rather than duplicating it.

What’s not done

See ROADMAP.md. Highlights:

  • gix NIF wiring for root_commits, diff and merge_analysis — everything else is native; these still fall through to the Cli backend
  • Rename detection in the merge editor: a rename conflict renders as the paths git names, not as “this file moved”
  • Stored credentials for scheduled upstream sync — the scheduler is anonymous, so a private upstream reports “needs credentials” and waits for a human
  • Avatar file uploads (the theme-aware identicon is the avatar today)
  • Native runner (low priority while the external one works)
  • Code search (Postgres FTS handles issues/PRs; code search is its own beast)
  • RFC 9421 (HTTP Messages) signatures when peers upgrade from draft-cavage-12
  • 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
  • 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
  • environment: (environment secrets, protection rules), OIDC tokens (id-token: write) and on: release — no environments, issuer or releases to back them yet
  • Namespace-wide indexes (_all) exist only for apt so far — the callback is optional on the Format behaviour, so PyPI and Cargo can opt in without new routing
  • Signing-key rotation for Hex and apt. 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

See PROGRESS.md for the slice-by-slice history of how the forge was built, and what each shipped — currently through slice 78.

Project conventions

See AGENTS.md for the conventions and guardrails used when working on this codebase (Phoenix 1.8 patterns, Tailwind v4 with daisyUI, LiveView routing, etc).

mix precommit runs format, compile --warnings-as-errors, and test. CI matches.

License

GitGud is Free Software under the AGPL