Skip to content

Split forge: GitHub Actions becomes CI's home and agent-tools publishes to ghcr.io under the built-in GITHUB_TOKEN, while Forgejo keeps exactly one workflow — the private-infra deploy, now a 5-minute poll that reads its rev from GitHub

Open-sourcing the repository moves the place a contributor's pull request lives to github.com/Cloonar/coding-lab, and a check that cannot report on that PR cannot gate it. Every pipeline this repo had — the native gate and the path-gated nix gate (ADR-0023), the agent-tools build/publish legs (ADR-0051), and the deploy that bumps the coding-lab pin in the private Cloonar/nixos host-config repo (ADR-0054) — lived in .forgejo/workflows/ on git.cloonar.com. One of them cannot move: the pin bump writes to Cloonar/nixos with NIXOS_PIN_TOKEN, a credential whose whole point is that the public repo never holds it.

So the split is not "move CI to GitHub"; it is a dividing line drawn on the credential, with everything else following from it. Public-facing gates and the public artifact go to GitHub, where a fork PR can run them and where the built-in token is enough to publish. The private-infra deploy stays on the forge that already holds its secret — and, because main now only ever moves on GitHub, it stops being triggered by a push and becomes a poll that asks GitHub what to pin. This ADR records that line and the four second-order consequences it forces: a registry change, a runner change, a required-check mechanic GitHub has and Forgejo did not, and a fork-safety posture.

The split, decided:

  • CI moves to .github/workflows/; Forgejo keeps only deploy.yml. ci.yml, ci-nix.yml and agent-tools.yml now run on GitHub against the public repo; .forgejo/workflows/ holds one file. The dividing line is the credential and nothing else: NIXOS_PIN_TOKEN grants write to the private Cloonar/nixos repo on git.cloonar.com and must never become a GitHub secret, so the pin bump stays on the forge that already holds it, and everything a public contributor's PR must be able to run moves to the forge that PR lives on. Moving the deploy too was rejected on exactly that ground: it would put a write credential for private infrastructure into the secret store of a public repository, to save one workflow file's worth of forge.

  • The Forgejo deploy becomes a 5-minute schedule poll that reads its rev from GitHub. push: main no longer fires — main only moves on GitHub, and this side is a mirror, so a push trigger would either never fire or fire on whatever the mirror happened to receive whenever it happened to receive it. A schedule is the only event this forge reliably owns. The job therefore has no actions/checkout at all: it resolves GitHub main's HEAD with git ls-remote, then reads containers/agent-tools/versions.env and nix/module.nix out of an anonymous shallow fetch of exactly that rev, so the pinned sha, the probed agent-tools tags and the probed dev-image digest all come from one consistent commit and the possibly-stale Forgejo checkout is never read. Because 288 runs a day are nearly all no-ops, the pre-existing "pin already current" idempotency check is hoisted ahead of the nix install and the registry probes: resolve the sha, clone Cloonar/nixos, apply the sed, and stop on git diff --quiet — two clones and seconds, instead of a nix realization per poll. The bump path's gate ordering is unchanged: the edit is staged early but pushed last, after every default ref resolves anonymously and after test-configuration is green. One deploy-nixos-pin concurrency group with cancel-in-progress: false serializes runs, because a bump run (probe wait plus test-configuration) can outlive the 5-minute interval and overlapping polls would race each other into a rejected push.

  • agent-tools publishes to ghcr.io/cloonar/agent-tools under the built-in GITHUB_TOKEN. No operator-provisioned registry secret survives — FORGE_REGISTRY_TOKEN and FORGE_REGISTRY_USER are deleted with the Forgejo workflow that read them, along with the fail-loud provisioning-instructions preamble that guarded their absence. The publish job's permissions: contents: read + packages: write is the entire auth story, so a fresh clone of the public repo can cut a release with nothing to provision and nothing to rotate. REGISTRY is hardcoded ghcr.io (GitHub's container registry is a different host from github.server_url, so there is nothing in the Actions context to derive it from) while OWNER stays derived from github.repository_owner, lowercased for OCI, which keeps a fork honest by publishing into its own namespace instead of silently at ours. The scope is agent-tools only: the module's default dev image stays the digest-pinned upstream docker.io/library/buildpack-deps:stable-scm@sha256:07554a82…, which is not our artifact and is deliberately not mirrored. ADR-0054's anonymous-pull verify step is retained and changes role — it was the catch for a Forgejo package quietly following repo/org visibility settings, and it is now the catch for the one irreducibly manual step in this design: GITHUB_TOKEN cannot flip a ghcr package's visibility, so a package is private until a human makes it public, and the verify step's error message says so with the exact click-path.

  • The nested-Docker podman workarounds are deleted, not ported. They existed for one reason: the Forgejo job body executed inside the runner's Docker container — no /dev/fuse, an overlayfs root the default driver cannot mount on, no systemd, and a seccomp profile that denied sethostname outright. That environment cost a stack of compensations: a hand-written storage.conf forcing vfs, BUILDAH_ISOLATION=chroot, BUILDAH_LAYERS=false (under vfs every committed layer is a full filesystem copy, which ran the runner out of disk), an rbound host /proc, --uts=host, plus per-step vfs disk hygiene. GitHub's ubuntu-latest is a full VM with podman preinstalled, so the smoke test now runs podman the way the NixOS hosts run it — default overlay storage, default OCI-runtime isolation, real cgroups, a real /proc — which makes CI a strictly better proxy for production than the workaround stack ever was. What remains is an assertion, not a configuration: an "Ensure podman" step that runs podman version / podman info so a runner-image change that moves the storage driver or default runtime out from under us shows up in the log instead of as a mystery build failure. smoke-test.sh's SMOKE_EXTRA_RUN_ARGS hook survives as an escape hatch for hand-running the test on an unusual podman host; nothing sets it any more.

  • A no-op twin workflow keeps the path-gated flake-check always reportable. The required checks are native (ADR-0023) plus flake-check. On GitHub a required check that never runs leaves the PR pending on "Expected" forever — which a path-gated ci-nix would do on every docs-only PR, wedging it. ci-nix-noop.yml carries the same workflow name: ci-nix and the same job id flake-check with the inverse paths-ignore trigger and a body that echoes and exits 0, so the check always reports from one workflow or the other. Its concurrency group is a literal prefix rather than ${{ github.workflow }}: the expression expands to the shared name:, which would put both workflows in one group and let the later starter cancel the earlier — and a cancelled run reports a cancelled flake-check, precisely the wedge the twin exists to prevent. The honest subtlety: paths and paths-ignore are not strict complements, so a PR touching both a gated and an ungated file satisfies both filters, runs both workflows, and reports two green flake-checks — harmless, since the twin always exits 0. The case that would wedge a PR cannot occur: if no changed file matches paths, then every changed file also fails to match paths-ignore, so the twin runs. Coverage is total.

  • Fork-PR safety is preserved by construction, not by configuration. The agent-tools smoke leg is secret-free on pull_request by structure — permissions: contents: read, and the job body references no secrets.* at all — so a fork PR exercises the full build-and-inject path without a credential existing on that leg to leak. The publish job is if: github.event_name == 'push' || github.event_name == 'workflow_dispatch', so pull_request cannot reach the only job that ever holds packages: write. All four workflows carry a concurrency group (none did on Forgejo), and agent-tools' cancel-in-progress is ${{ github.event_name == 'pull_request' }} — deliberately asymmetric: a superseded PR run is pure waste and should die, but a publish must never be killed mid-flight, since it pushes the providers' tags one after another and a kill between them leaves the registry holding half a release, or skips the anonymous-pull verify that proves the release is consumable.

Status

Accepted. Settled via issue #1 (2026-08-05), as part of open-sourcing the repository.

Amends ADR-0023. The gate split survives intact in substance: the same two gates, the same native job id (still kept stable so the required-check string stays deterministic), the same **/*.nix / flake.lock / go.mod / go.sum path filter, and the same vendorHash rationale for why go.mod/go.sum are non-negotiable triggers. Only the forge changes, plus the twin that GitHub's required-check semantics force. ADR-0023's deploy backstop also stands — test-configuration still rebuilds the full NixOS configuration before anything is pushed — it is now reached by a poll rather than by a push.

Supersedes ADR-0051's registry and secret model. Forgejo package registry + operator-provisioned FORGE_REGISTRY_TOKEN/FORGE_REGISTRY_USER → ghcr.io + the built-in GITHUB_TOKEN with job-level packages: write; the workflow's path moves from .forgejo/workflows/agent-tools.yml to .github/workflows/agent-tools.yml. Everything else in ADR-0051 is untouched: image contents (FROM scratch, one CLI plus static labctl), the /opt/lab injection contract and its PT_INTERP rewrite, versions.env as the single source of truth coupled to the compat records, the two asymmetric legs, and tags-are-human-facing / digest-is-the-pinning-contract. ADR-0054's publish-then-pin ordering and its anonymous-pull-at-every-seam rule are likewise untouched — only the registry the seams point at moved, and the module's toolsImageRepo default (ghcr.io/cloonar/agent-tools) moved with it, which the nixos-module flake check pins. ADR-0056's dev-image default is unchanged in both value and mechanism.

Considered options

  • Move the deploy to GitHub too, so there is one forge. Rejected: it requires NIXOS_PIN_TOKEN — write access to the private Cloonar/nixos repo — as a secret on a public repository. That is the one credential the open-sourcing split exists to keep off GitHub, and no amount of environment gating makes a private-infra write token a safe resident of a public repo's secret store. Two forges is the cheaper price.

  • Keep CI on Forgejo and let GitHub carry only the code. Rejected: a check that runs on a private forge cannot report a status on a github.com pull request, so an outside contributor's PR would be ungated (or gated by a status only insiders can produce). Open-sourcing is worth little if contributors cannot see their PR go green.

  • Keep the deploy on push: main, relying on Forgejo's mirror of GitHub. Rejected: main only moves on GitHub, so this side's push events are mirror events — they may never fire, or fire late, and github.sha on this forge is the mirror's rev, possibly behind GitHub main or a rev whose images were never built. Pinning the fleet to "whatever the mirror happened to hold" is a worse failure than a five-minute delay. The poll makes the mirror irrelevant: only this workflow file is read from the forge, never the rev.

  • Have the poll read the repo from its own checkout instead of fetching the rev from GitHub. Rejected for the same reason, one level deeper: the wait step derives the expected agent-tools tags from versions.env and the default dev-image digest from nix/module.nix, so reading them from a stale mirror would probe the wrong refs and could bump the pin to a rev whose actual defaults were never verified.

  • Make ci-nix unconditional, so flake-check always reports without a twin. Rejected: that pays ADR-0023's ~10 minutes of cold nix closure realization on every PR, undoing the entire reason the split exists. A no-op job that exits in seconds buys the same always-reports property for free.

  • Drop flake-check from the required set and keep only native. Rejected: this is ADR-0023's one named correctness trap. A dependency bump goes green on the native gate (live go mod download) while staling nix/package.nix's vendorHash, so it would merge clean and rot the nix build for the next nix-touching PR and for the deploy. The check must be required; the twin is what makes "required" compatible with "path-gated".

  • Port the nested-Docker podman workarounds to the GitHub runner. Rejected: they encode a constraint (vfs, chroot isolation, rbound /proc, --uts=host) that no longer exists on a full VM, and every one of them makes the smoke test a worse model of the NixOS hosts it is supposed to predict. Carrying compensations for a problem you no longer have is how CI stops resembling production.

  • Provision a GitHub PAT with write:packages and keep the publish job on an operator-managed secret. Rejected: it reproduces the exact thing the migration removes — a human-owned token to create, scope, rotate, and re-provision in every fork — for no capability the built-in token lacks on the push path. The single thing GITHUB_TOKEN cannot do (set package visibility) is a one-time manual act, not a recurring one, and the verify step already catches it loudly.

  • Mirror the default dev image into ghcr alongside agent-tools. Rejected: buildpack-deps:stable-scm is not our artifact, and mirroring it means owning a copy pipeline, a re-pin discipline, and a second thing that can go stale for an image that already pulls anonymously from docker.io. ADR-0056's digest pin plus the deploy probe already cover the failure mode a mirror would address.

Consequences

  • The cutover is a single PR with a knowingly-tolerated red window. Merge, make sure the agent-tools publish has actually run for that rev — workflow_dispatch it if the push's path filter did not — then flip the ghcr package to public, the one step no token can do, and the Forgejo deploy poll self-heals. Until the flip, the poll runs red probing a ref that is private and therefore anonymously unresolvable, and correctly leaves the pin where it is; a poll already in flight picks the flip up inside its 30-minute probe deadline, and the next poll after that resolves and bumps. Nothing about this window ships a broken host: a failed probe means an unmoved pin.

  • The old git.cloonar.com/cloonar/agent-tools packages must stay alive until the Cloonar/nixos pin has advanced past this rev. A host's toolsImages come from the module of the rev it is pinned to, and preflight re-pulls every configured ref on each boot — so hosts on an older pin keep pulling the old registry's refs at every restart. Deleting those packages before the pin moves turns their next preflight into a pull failure that degrades to cache at best. Once the pin has advanced past this rev, they are unreferenced and can go.

  • Deploy latency gains up to five minutes, and a merge is no longer causally connected to its deploy — the poll discovers it. workflow_dispatch remains the manual "don't wait out the interval" knob. In exchange, the deploy is now robust to mirror lag by construction rather than by hoping the mirror is current.

  • A PR touching both a gated and an ungated path reports two flake-check runs. Both green, both required-satisfying, one of them the no-op. Harmless and deliberate — the alternative complement-exactness is not something paths/paths-ignore can express.

  • Nothing in this repo can make a ghcr package public. The visibility flip is a permanent manual step for this repo and for any fork that wants to publish; the verify step is the only thing standing between a private package and a fleet-wide preflight failure, so a red "anonymous pull failed" must be read as check visibility, not retry the build.

  • Forks publish into their own namespace and can release with zero provisioning. OWNER derives from github.repository_owner, so a fork's publish job pushes to its own ghcr.io/<owner>/agent-tools under its own GITHUB_TOKEN — it cannot push at ours, and it needs no secret from us.

  • The forge topology is now a fact operators must know. Checks, logs, and the required-status story live on GitHub; the pin bump, its NIXOS_PIN_TOKEN, and its poll cadence live on Forgejo. Anyone debugging "why hasn't my merge deployed" looks at a workflow on a different host from the one that ran the PR's checks.

  • docker.io remains on the deploy's probe dependencies (ADR-0056) and is now joined by ghcr.io and github.com — the poll resolves a GitHub ref and fetches a GitHub rev before it probes anything. All three are anonymous reads, which keeps the deploy's own credential surface exactly one token wide.