Skip to content

feat(guest-image): publish the guest triple as a non-runnable OCI artifact - #1268

Open
rigel-mintaka wants to merge 4 commits into
mainfrom
compass-managed/rig-3788-guest-artifact-publish
Open

rigel-mintaka wants to merge 4 commits into
mainfrom
compass-managed/rig-3788-guest-artifact-publish

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

The three guest assets (kernel, erofs rootfs, initrd) publish as one OCI
artifact to ghcr.io/rigelbuild/compass-guest-image, reusing the runner-image
lane's shape: a pure unit-tested core does every mapping and fail-closed
check, the push is digest-asserted, and the deployable reference is
repo@sha256:... because GHCR has no server-side tag immutability.

Nothing can run an erofs blob, so the manifest says so structurally: an
image manifest carrying artifactType application/vnd.compass.guest-image.v1,
the OCI 1.1 empty config, and three layers with their own media types. A
runtime handed this reference refuses at the config media type.

Two properties a local layout cannot demonstrate, so they are enforced in
code and covered by tests rather than assumed:

  • Each layer descriptor carries the asset's real byte length. A registry
    validates the declared size against the blob it receives, and the local
    skopeo copy path reads the blob without ever comparing, so a placeholder
    size passes every local check and is rejected only at push.
  • The manifest carries schemaVersion 2, which is required and likewise
    unenforced locally.

Per-asset annotations key on the filename a materialiser writes, not the nix
store basename: verifyImages looks its sha256sum manifest up by
filepath.Base, and a store basename carries a content hash that would never
match. The annotation block is therefore directly usable as the Runner's
--microvm-image-manifest.

Publishing needs network and GHCR credentials, so it runs in a release
workflow job gated on GUEST_IMAGE_CLOSURE_PATHS and never in the moon gate,
whose cost stays the existing guest-image build.

GHCR's acceptance of the empty-config artifact form remains unverified: it
cannot be proven without a real push, and the design record expects a
scratch-tag push to settle it before the production job first runs.

Refs RIG-3788

@linear-code

linear-code Bot commented Sep 17, 2026

Copy link
Copy Markdown

RIG-3788

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Compass engineering docs preview: https://compass-managed-rig-3788-gue.compass-eng-docs.pages.dev

Deployed from compass-managed/rig-3788-guest-artifact-publish at b2c0666.

An error occurred while trying to automatically change base from compass-managed/rig-3787-derive-rootfs to compass-managed/rig-3786-agent-image-pin September 17, 2026 02:29
…ifact

The three guest assets (kernel, erofs rootfs, initrd) publish as one OCI
artifact to ghcr.io/rigelbuild/compass-guest-image, reusing the runner-image
lane's shape: a pure unit-tested core does every mapping and fail-closed
check, the push is digest-asserted, and the deployable reference is
`repo@sha256:...` because GHCR has no server-side tag immutability.

Nothing can run an erofs blob, so the manifest says so structurally: an
image manifest carrying artifactType application/vnd.compass.guest-image.v1,
the OCI 1.1 empty config, and three layers with their own media types. A
runtime handed this reference refuses at the config media type.

Two properties a local layout cannot demonstrate, so they are enforced in
code and covered by tests rather than assumed:

- Each layer descriptor carries the asset's real byte length. A registry
  validates the declared size against the blob it receives, and the local
  `skopeo copy` path reads the blob without ever comparing, so a placeholder
  size passes every local check and is rejected only at push.
- The manifest carries schemaVersion 2, which is required and likewise
  unenforced locally.

Per-asset annotations key on the filename a materialiser writes, not the nix
store basename: `verifyImages` looks its sha256sum manifest up by
filepath.Base, and a store basename carries a content hash that would never
match. The annotation block is therefore directly usable as the Runner's
--microvm-image-manifest.

Publishing needs network and GHCR credentials, so it runs in a release
workflow job gated on GUEST_IMAGE_CLOSURE_PATHS and never in the moon gate,
whose cost stays the existing guest-image build.

GHCR's acceptance of the empty-config artifact form remains unverified: it
cannot be proven without a real push, and the design record expects a
scratch-tag push to settle it before the production job first runs.

Refs RIG-3788
… auth

The publish job resolved skopeo as `nixpkgs#skopeo`, which reads the mutable
flake registry: a privileged `packages:write` job would run whatever upstream
build the registry pointed at that day. Resolve it from the shared pinned
helper instead, the same loop the sibling publish jobs use — and the helper
is what the rest of the repo already depends on, because these lanes need the
fork's patched skopeo, not any skopeo.

Auth moves to the house pattern at the same time. A hand-written docker
config carried the credential as base64 in a file this job assembled itself;
`skopeo login --password-stdin` against a pinned `REGISTRY_AUTH_FILE` keeps
the token off argv without that step. The pin is load-bearing: the login and
the lane's own skopeo calls are separate processes, and skopeo's default
creds location is environment-dependent on hosted runners, so a mismatch
greens the login and then 401s the push. The lane threads `--authfile`
through all three of its calls when the variable is set, and omits the flag
entirely when it is not, so a local dry run still needs no credentials.

Refs RIG-3788
…h shell

Two design findings on the publish lane.

The shell carried its own copy of the nix invocation, so the command that
realises the published assets sat beside the gate's `build` task with nothing
keeping the two in step: a flag or attr change on either side would publish a
triple the gate never built. It now runs that task, leaving one realization
boundary. moon frames task output with its own banner and summary lines, so
the store paths are selected rather than read by position.

The tests covered the pure core's return values and nothing the shell
observably does, so a regression in path assembly, stdout, or the
digest-assertion wiring would have left the core suite green. publish.test.ts
drives the CLI against stub nix/git/skopeo tools and asserts the contract:
stdout is exactly one digest reference, every declared descriptor has a blob
of that size, a stale layout is cleared before the digest assertion can read
it, an ambiguous registry probe never reaches `copy`, a registry resolving a
different manifest exits digestMismatch, and --authfile reaches every call
when the login pinned one.

Writing that suite caught a real defect in the first version of the
realization change: the store-path filter matched a literal `/nix/store/`
prefix, which no stub can produce, and would also have failed anywhere the
store lives elsewhere.

Refs RIG-3788
…he scan

Three defects that made the publish lane either dead or fail-open.

The job could not succeed at all: routing realization through the gate's
build task made moon a hard dependency, but the workflow provisioned only
bun, so the lane died at exit 6 before publishing anything. Both toolchains
now come from the gate-tools pin. A test cannot catch a missing workflow
dependency, so the reproduction was a CI-shaped PATH carrying bun, skopeo and
git but no moon.

The tag-absence classifier read any "not found" as a free tag, so
`creds was not found in the docker config file` meant publish — a local
credentials fault would have overwritten a published artifact. It now matches
only a registry's own absence answers; everything else aborts. The sibling
lane's looser glob is safe there because its fallback merely continues a
walk, where this one's would overwrite.

The annotation scan was inert against every key this lane emits. Its
`(^|_)…($|_)` boundary comes from environment-variable land, but OCI
annotation keys are dot-and-dash separated, so `org.compass.guest.password`
and `.registry-token` both scanned clean. Annotations are the only
credential-leak defense here, and its one passing test used `GITHUB_TOKEN`,
a shape the lane never produces.

Also, each smaller than the above but each a real fault:

- A missing or malformed agent-oci.lock threw instead of exiting badLayout.
- An asset path that stats but is not a regular file escaped as an uncaught
  EISDIR; stat and the hashing read now share one guard.
- An empty --repo passed the usage gate and failed after the whole multi-GiB
  realization.
- The caller-supplied --layout was deleted recursively with no check; it must
  now carry an oci-layout marker.
- Hashing read each asset whole into the heap, and the blob write read it
  again. The hash streams in chunks and the blob is copied by the filesystem.
  Verified the streamed digests equal coreutils sha256sum on all three.
- The default layout directory was untracked but not ignored, leaving a
  multi-GiB tree in an auto-snapshotting working copy.

Rejected one style finding: `triple` is not invented terminology, it is the
frozen design's own wording for the three assets.

Refs RIG-3788
@rigel-mintaka
rigel-mintaka force-pushed the compass-managed/rig-3788-guest-artifact-publish branch from d6ecc8b to b2c0666 Compare September 17, 2026 03:09
@rigel-mintaka
rigel-mintaka changed the base branch from compass-managed/rig-3787-derive-rootfs to main September 17, 2026 03:09
@trunk-io

trunk-io Bot commented Sep 17, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@rigel-mintaka
rigel-mintaka marked this pull request as ready for review September 17, 2026 03:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant