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 onlydeploy.yml.ci.yml,ci-nix.ymlandagent-tools.ymlnow run on GitHub against the public repo;.forgejo/workflows/holds one file. The dividing line is the credential and nothing else:NIXOS_PIN_TOKENgrants write to the privateCloonar/nixosrepo ongit.cloonar.comand 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
schedulepoll that reads its rev from GitHub.push: mainno 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 noactions/checkoutat all: it resolves GitHub main's HEAD withgit ls-remote, then readscontainers/agent-tools/versions.envandnix/module.nixout 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, cloneCloonar/nixos, apply the sed, and stop ongit 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 aftertest-configurationis green. Onedeploy-nixos-pinconcurrency group withcancel-in-progress: falseserializes runs, because a bump run (probe wait plustest-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-toolsunder the built-inGITHUB_TOKEN. No operator-provisioned registry secret survives —FORGE_REGISTRY_TOKENandFORGE_REGISTRY_USERare deleted with the Forgejo workflow that read them, along with the fail-loud provisioning-instructions preamble that guarded their absence. The publish job'spermissions: contents: read+packages: writeis the entire auth story, so a fresh clone of the public repo can cut a release with nothing to provision and nothing to rotate.REGISTRYis hardcodedghcr.io(GitHub's container registry is a different host fromgithub.server_url, so there is nothing in the Actions context to derive it from) whileOWNERstays derived fromgithub.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 upstreamdocker.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_TOKENcannot 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 deniedsethostnameoutright. That environment cost a stack of compensations: a hand-writtenstorage.confforcingvfs,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'subuntu-latestis 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 runspodman version/podman infoso 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'sSMOKE_EXTRA_RUN_ARGShook 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-checkalways reportable. The required checks arenative(ADR-0023) plusflake-check. On GitHub a required check that never runs leaves the PR pending on "Expected" forever — which a path-gatedci-nixwould do on every docs-only PR, wedging it.ci-nix-noop.ymlcarries the same workflowname: ci-nixand the same job idflake-checkwith the inversepaths-ignoretrigger and a body that echoes and exits 0, so the check always reports from one workflow or the other. Itsconcurrencygroup is a literal prefix rather than${{ github.workflow }}: the expression expands to the sharedname:, which would put both workflows in one group and let the later starter cancel the earlier — and a cancelled run reports a cancelledflake-check, precisely the wedge the twin exists to prevent. The honest subtlety:pathsandpaths-ignoreare not strict complements, so a PR touching both a gated and an ungated file satisfies both filters, runs both workflows, and reports two greenflake-checks — harmless, since the twin always exits 0. The case that would wedge a PR cannot occur: if no changed file matchespaths, then every changed file also fails to matchpaths-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_requestby structure —permissions: contents: read, and the job body references nosecrets.*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 isif: github.event_name == 'push' || github.event_name == 'workflow_dispatch', sopull_requestcannot reach the only job that ever holdspackages: write. All four workflows carry aconcurrencygroup (none did on Forgejo), and agent-tools'cancel-in-progressis${{ 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 privateCloonar/nixosrepo — 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.compull 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, andgithub.shaon 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.envand the default dev-image digest fromnix/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-nixunconditional, soflake-checkalways 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-checkfrom the required set and keep onlynative. Rejected: this is ADR-0023's one named correctness trap. A dependency bump goes green on the native gate (livego mod download) while stalingnix/package.nix'svendorHash, 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:packagesand 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 thingGITHUB_TOKENcannot 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-scmis 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_dispatchit 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-toolspackages must stay alive until theCloonar/nixospin has advanced past this rev. A host'stoolsImagescome 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_dispatchremains 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-checkruns. Both green, both required-satisfying, one of them the no-op. Harmless and deliberate — the alternative complement-exactness is not somethingpaths/paths-ignorecan 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.
OWNERderives fromgithub.repository_owner, so a fork's publish job pushes to its ownghcr.io/<owner>/agent-toolsunder its ownGITHUB_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.ioremains on the deploy's probe dependencies (ADR-0056) and is now joined byghcr.ioandgithub.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.