Compass gateway image publish lane
Tracking: RIG-4209 (decision: option B, a compass publish lane). Consumer: RIG-2862 (the stack’s supervised gateway child, compass PR #1382).
Problem / Intent
Section titled “Problem / Intent”PR #1382 (unmerged) makes the installed stack run the LLM gateway as a podman
child. In that PR, DefaultGatewayImage in go/internal/stack/gateway_image.go
is "", so compass-stack up needs --gateway-image or --gateway-external.
No published gateway image exists. The gateway is the auth-gateway serve
command of the RigelBuild/oh-my-pi fork, booted by
packages/coding-agent/src/cli/gateway-boot.ts and packaged by the fork’s
Dockerfile.gateway. This record makes compass build that image from a pinned
fork commit, publish it to GHCR by digest, and hand the digest to a reviewed
pin of DefaultGatewayImage. This is the same pin rule as
DefaultCollectorImage.
Approach
Section titled “Approach”Add a new lane, tools/gateway-image/, that follows the shape of the runner and
guest lanes: a pure core with tests and a thin I/O shell, both in Bun
TypeScript. A new publish-gateway-image job in release.yml drives it. The
lane makes six decisions.
-
Fork pin.
tools/gateway-image/fork-pin.jsonholds{ "repo": "https://github.com/RigelBuild/oh-my-pi.git", "commit": "<40-hex>" }. A bump is a reviewed compass PR that edits this file. The fork is public, so CI fetches it with no credential:- Delete and recreate
<out>/src, rungit initthere, and add the pin’srepoas remoteorigin. Every build starts from an empty checkout, so a repeat run never reuses stale state. git fetch --depth=1 origin <commit>, then check outFETCH_HEAD.- Fetch fork
mainwith--filter=tree:0and rungit merge-base --is-ancestor. Together they assert that the commit is on forkmain.
The ancestry check refuses a pin on an unmerged PR branch. That branch could be force-pushed away, and its code has no fork review. The fork has no LFS files and no submodules, so a shallow checkout is the complete build context. Build from source, not npm: fork npm releases wait on RIG-3149 (RIG-4209).
- Delete and recreate
-
Two-stage BuildKit build. The fork ships two Dockerfiles, and
Dockerfile.gatewaystartsFROM ${PI_BASE}(defaultoh-my-pi/pi:dev). The lane runs two rootlessbuildctlsolves on one buildkitd:- Stage 1: the fork
Dockerfilewith targetpi-runtime, exported to a local OCI layout. This stage compiles the Rust natives addon. - Stage 2:
Dockerfile.gateway, with the base supplied as a named context:--oci-layout pibase=<layout> --opt context:oh-my-pi/pi:dev=oci-layout://pibase@<digest>.
The base never leaves the runner. Nothing is published except the gateway image. Both solves use
SOURCE_DATE_EPOCH=1,rewrite-timestamp=true, and platformlinux/amd64, as the runner lane does. The stage-2 output also setsoci-mediatypes=true, the media type the runner lane states for its pushed image. A local probe on 2026-10-03 at fork commit91ad19fbe19c(PR #108 head) tested the mapping with buildkit 0.32.2. Two stage-2 solves gave the same digest, and that digest equalscontainerimage.digestin the metadata file. - Stage 1: the fork
-
Push the bytes that were smoked. The guest lane pushes with
skopeo copyfrom a local layout. The gateway lane copies that shape, not the runner lane’s re-solve with the push exporter. The order is:- Build once to an OCI layout, then secret-scan the image config. Both
happen in
build.ts, so nothing unscanned is ever booted. - Smoke-boot the image (item 4).
- Run
skopeo copy --preserve-digests oci:<layout> docker://<repo>:git-<sha12>. - Re-read the manifest from the registry and assert that its digest equals the local digest.
No second build exists, so a reproducibility drift cannot publish a digest that was never smoked. A tag that already holds the same digest is a no-op. A tag that holds a different digest aborts the run. That is the guest lane’s
tagDispositionrule.A re-run does not reliably repair a failure after the push. The fork
Dockerfilepulls base images by tag and downloads tools without pins, so a fresh build of the same pin can differ, and its tag then aborts as “already holds”. That abort is the intended outcome: a published:git-<sha12>is never overwritten. Recovery is a new compass commit, which gets a new tag. A failed digest check (exit 5) leaves the tag on bytes nobody pinned, so nothing consumes it. - Build once to an OCI layout, then secret-scan the image config. Both
happen in
-
Smoke before push. The lane loads the image into podman with
skopeo copy oci:<layout> docker-archive:<tar>and thenpodman load -i. The bareubuntu-latestrunner denies the nested unshare that a direct copy into containers-storage needs. Thee2ejob inci.ymldocuments this and loads its seed image the same way. Then the lane boots the image the way the stack runtime contract does (RIG-2862):- a 0600 token file mounted at
/run/compass/gateway.token; - port
4000published to loopback; - the image’s own entrypoint and CMD.
The checks are:
/healthzanswers 200 with{"ok":true,…}within 30 s. This is the budget ofwaitGatewayin the stack./v1/modelswithout a token answers 401./v1/modelswith the mounted token answers 200 with{"object":"list",…}. This proves the gateway accepts the token file, not just that it rejects requests.podman stop -t 25ends with exit code 143.
auth-gateway serveexits 1 whenOMP_AUTH_BROKER_URLis not set. (A local run at the same commit confirmed this, and the T1 comment ingateway-boot.tssays so.) The smoke therefore starts a broker from the same image first:- The broker runs
omp auth-broker serve --bind=0.0.0.0:8765on a private podman network. - The lane waits for its
/v1/healthz. - It reads the broker’s minted token with
podman execfrom/tmp/.omp/auth-broker.token. The image setsHOME=/tmp, so the config root is/tmp/.omp. - It passes
OMP_AUTH_BROKER_URLto the gateway, andOMP_AUTH_BROKER_TOKENas a name-only-ewhose value comes from the environment, which keeps it off argv.
A local run of this broker + gateway pair gave
/healthz200 after 2.5 s, 401 without a token or with a wrong one, 200 with the mounted token, and exit 143 on SIGTERM. - a 0600 token file mounted at
-
No reuse of runner-image publish-core.
secretConfigViolationsin that file rejects two benign names in the gateway config:GPG_KEY, a public-key fingerprint set by thepythonbase image;COMPASS_GATEWAY_TOKEN_FILE, a path.
Adding an allowlist to it would change a gated lane that gains nothing from the change. There are no cross-tool imports today: the guest lane copied the shape, and only
../toolchain/is shared. So the gateway core carries its own copy of the scan, with an exact-name allowlist of those two names, and copies the few helpers it needs (buildTag,digestRef, theEXITnumbering). -
Tags and pin. The lane writes only
:git-<sha12>, wheresha12is the compass commit. It writes no:latestand runs no:vX.Y.Zretag. The stack consumes a digest compiled into the release binary, so a version tag on the image would add nothing. The digest reachesDefaultGatewayImagethrough a reviewed manual pin PR, the same asDefaultCollectorImage. Renovate cannot ordergit-<sha12>tags, and no CI identity here may open PRs. The job writesrepo@sha256:…to the step summary, and the pin PR copies it from there. A compass commit maps to its fork commit through thefork-pin.jsonat that compass commit, so the image needs no extra labels.
Change detection uses a new GATEWAY_IMAGE_CLOSURE_PATHS set
(tools/gateway-image/** and .github/workflows/release.yml) and the same
push-diff gate step as publish-runner-image. Every fallback of that gate errs
toward publishing. The pin is a file inside tools/gateway-image/, so a fork
bump is a closure change by construction.
Alternative considered: run the build in the fork’s own CI and only pin the result in compass (RIG-4209 option A). Matt ruled for option B.
Global Constraints
Section titled “Global Constraints”- Bun TypeScript only (the no-bash-gate rule). Keep the pure core (
core.ts+core.test.ts) apart from the I/O shells. Import nothing from anothertools/*lane;../toolchain/is the only shared import. - Build with rootless BuildKit through
buildctl. Never usedocker buildand never mount a host docker socket. Buildlinux/amd64only. UseSOURCE_DATE_EPOCH=1andrewrite-timestamp=trueon every output. - The deployed reference is always
repo@sha256:<64-hex>. A tag only addresses a build. The lane writes:git-<sha12>and no other tag.sha12is the first 12 characters of the compass commit (cut -c1-12, never--short). - Repo:
ghcr.io/rigelbuild/compass-gateway(an assumption, OQ-1). fork-pin.jsoncommitis 40 lowercase hex characters and an ancestor of RigelBuild/oh-my-pimain.- Exit codes, numbered as in the runner and guest lanes: usage 2, secretFound 3, pushFailed 4, digestMismatch 5, badLayout 6, plus smokeFailed 7 and badPin 8.
- No retries anywhere. A fault before the push fails the step, and the remedy is a re-run. A fault after the push is recovered by a new compass commit, never by overwriting the tag (Approach item 3).
- Prerequisite: fork PR #108 and its base PR are merged to fork
main. Until then no commit satisfies the ancestry rule. - compass is public. Cite only compass, the public fork, and Linear IDs.
T1 — Lane scaffold and pure core
Section titled “T1 — Lane scaffold and pure core”Create tools/gateway-image/ with package.json (@compass/gateway-image,
private), tsconfig.json, biome.json, and moon.yml. Copy the guest lane’s
typecheck / test / ci tasks. Its build, smoke, and publish tasks
use runInCI: false. Also add:
fork-pin.json;core.tsandcore.test.ts;- the
gateway-image: 'tools/gateway-image'entry in.moon/workspace.yml; /tools/gateway-image/out/in.gitignore.
Interfaces (core.ts):
EXIT— the constant described in Global Constraints.type ForkPin = { repo: string; commit: string };parseForkPin(text: string): ForkPin. It throws on unknown keys, on arepoother thanhttps://github.com/RigelBuild/oh-my-pi.git, and on acommitthat is not 40 lowercase hex characters.BASE_CONTEXT = "oh-my-pi/pi:dev", which must equal the default ofARG PI_BASEinDockerfile.gateway.PLATFORM = "linux/amd64".SOURCE_DATE_EPOCH = 1.baseBuildArgs(forkDir: string, ociDir: string): string[]— the stage-1buildctlargv: frontenddockerfile.v0,filename=Dockerfile,target=pi-runtime, platform,build-arg:SOURCE_DATE_EPOCH, outputtype=oci,dest=<ociDir>,tar=false,rewrite-timestamp=true.gatewayBuildArgs(forkDir: string, baseOciDir: string, baseDigest: string, ociDir: string, metadataFile: string): string[]— the stage-2 argv withfilename=Dockerfile.gateway,--oci-layout pibase=<baseOciDir>, andcontext:oh-my-pi/pi:dev=oci-layout://pibase@<baseDigest>. The output is the same OCI spec withoci-mediatypes=true, plus--metadata-file.layoutDigest(indexJson: string): string— the single sha256 manifest an OCI index names. Any other shape throws.SECRET_NAME_ALLOWLIST = ["GPG_KEY", "COMPASS_GATEWAY_TOKEN_FILE"];secretConfigViolations(config: { env: readonly string[]; labels: Readonly<Record<string, string>> }): string[]— the runner lane’s pattern, with names in the allowlist skipped only on an exact match.buildTag(repo: string, sha12: string): string,digestRef(repo: string, digest: string): string,tagDisposition(probe: { exitCode: number; stdout: string; stderr: string }, localDigest: string): { action: "publish" | "skip" | "abort"; reason?: string }— the guest lane’s semantics, copied.healthzOk(status: number, body: string): boolean— true only when the status is 200 and the JSON body hasok === true.modelsListOk(status: number, body: string): boolean— true only when the status is 200 and the JSON body hasobject === "list".
Tests: for each rejection in parseForkPin, a test shows it fails. A real
secret name such as OMP_AUTH_BROKER_TOKEN is still flagged. A near-miss of
an allowlisted name (GPG_KEY_X) is still flagged. An existing tag with a
different digest aborts. An ambiguous probe aborts.
T2 — Build and smoke shells
Section titled “T2 — Build and smoke shells”Interfaces:
bun tools/gateway-image/build.ts [--out <dir>](defaulttools/gateway-image/out). It needsBUILDKIT_HOST. It readsfork-pin.json, recreates<out>/src, and fetches the commit there (Approach item 1). It asserts ancestry against forkmainand exits 8 if the commit is not an ancestor. Then it runs stage 1 into<out>/base-ociand stage 2 into<out>/oci. It asserts thatcontainerimage.digestin the metadata equalslayoutDigest(<out>/oci/index.json)and exits 6 if they differ. It scans the<out>/ociimage configEnvandLabels, and exits 3 if any name is flagged. It prints the digest on stdout.bun tools/gateway-image/smoke.ts [--out <dir>]. It needsskopeoandpodman. It loads<out>/ocithrough adocker-archivetar withpodman load, then runs the broker + gateway smoke from Approach item 4. It names containers and the network with a per-run suffix and always tears them down. It exits 0 on pass and 7 on any failed check.
Test cycle: on a host with rootless buildkitd and podman, run build.ts
twice; the two digests are the same. This only shows the stage-2 mapping is
deterministic on one warm daemon; it does not prove a cold rebuild matches
(Approach item 3). smoke.ts passes. To show the smoke can fail, run it twice
more: once with the broker env withheld, and once with a different token
mounted than the one sent. Both must exit 7.
T3 — Publish shell and release job
Section titled “T3 — Publish shell and release job”Interfaces:
bun tools/gateway-image/publish.ts --repo <repo> --sha <sha12> [--out <dir>].<sha12>is the first 12 characters of the compass commit (Global Constraints). It honoursREGISTRY_AUTH_FILE. In order, it:- Probes the tag and applies
tagDisposition. - Runs
skopeo copy --preserve-digests oci:<out>/oci docker://<tag>. - Re-reads the tag with
skopeo inspect --raw, and exits 5 if the digest differs from the local one. - Prints
digestRefon stdout.
- Probes the tag and applies
.github/workflows/release.ymlchanges:- A new env block,
GATEWAY_IMAGE_CLOSURE_PATHS, withtools/gateway-image/**and.github/workflows/release.yml. - A new job,
publish-gateway-image, that copiespublish-runner-image:- permissions
contents: read,packages: write; - concurrency group
publish-gateway-image,cancel-in-progress: false,queue: max; if: github.ref == 'refs/heads/main'andtimeout-minutes: 90.
- permissions
- Its steps are:
- the gate step over the new set;
- pinned bun and skopeo from
tools/toolchain/; - rootless buildkitd;
REGISTRY_AUTH_FILEwithskopeo login(the guest job’s login);build.ts,smoke.ts, thenpublish.ts --repo ghcr.io/rigelbuild/compass-gateway --sha <sha12>;- the ref appended to
GITHUB_STEP_SUMMARY.
- A new env block,
Test cycle: core.test.ts covers every branch of the publish decision. The
first main push after merge publishes the image. Then skopeo inspect --raw
on :git-<sha12> must give the digest the summary shows.
Out of scope — the first pin
Section titled “Out of scope — the first pin”Pinning DefaultGatewayImage is the stack lane’s change, not this lane’s.
It waits on PR #1382 merging and on RIG-4251: how the stack gives the
gateway its broker credentials. auth-gateway serve exits 1 without
OMP_AUTH_BROKER_URL, and PR #1382 passes only the three COMPASS_GATEWAY_*
vars. So a pinned default cannot boot until RIG-4251 is decided. This lane
builds and publishes the same image under every RIG-4251 option.
What this lane hands that pin:
- the
repo@sha256:…ref in thepublish-gateway-imagestep summary; - a bump procedure for the doc comment, in the style of
collector_image.go: editfork-pin.json, merge, copy the ref from the summary, open a pin PR; - provenance: the compass commit, and through its
fork-pin.json, the fork commit.
Before the first pin, Matt sets the GHCR package to public once, as for the compass-agent image, so the stack pulls with no registry login.
- T1 —
tools/gateway-image/scaffold,fork-pin.json,core.tsand tests, moon and.gitignoreregistration - T2 —
build.ts(fork fetch, ancestry check, two-stage solve) andsmoke.ts(broker + gateway boot,/healthz, 401 without the token, 200 with it, exit 143) - T3 —
publish.tsand thepublish-gateway-imagejob withGATEWAY_IMAGE_CLOSURE_PATHS - DECISIONS.md rows DL-386 and DL-387 in the same PR as this record
Open Questions
Section titled “Open Questions”- OQ-1 — Package name and visibility. Not load-bearing.
ghcr.io/rigelbuild/compass-gatewayis the RIG-4209 proposal, not yet confirmed. Public visibility follows the compass-agent image ruling. Renaming before T3 lands costs one string.