Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ env:
flake.lock
tools/toolchain/microvm-vmm-env.nix
.github/workflows/release.yml
# The guest artifact's own closure set, SEPARATE from the two above: this
# publishes the three guest assets, so an agent- or runner-only change must
# not republish it. Derived from guest-image/moon.yml's build.inputs plus the
# publish lane itself; the agent pin travels inside guest-image/.
GUEST_IMAGE_CLOSURE_PATHS: |
guest-image/**
tools/guest-image/**
go/go.mod
go/go.sum
go/cmd/compass-guestd/**
go/internal/**
.github/workflows/release.yml

jobs:
release-pr:
Expand Down Expand Up @@ -1278,3 +1290,175 @@ jobs:
--repo ghcr.io/rigelbuild/compass-runner --sha "$sha12")"
echo "published $ref"
echo "runner image: \`$ref\`" >> "$GITHUB_STEP_SUMMARY"

publish-guest-image:
name: publish-guest-image
runs-on: ubuntu-latest
# Least privilege: read the tree, write the GHCR package, nothing else.
permissions:
contents: read
packages: write
# Its own group: a different package from the agent and runner images, so
# serializing against those would only add latency. Within this package,
# publishes must serialize — two runs pushing one tag race over which bytes
# it names.
concurrency:
group: publish-guest-image
cancel-in-progress: false
queue: max
# A dispatch from a feature branch must never publish assets for unmerged
# code. Main pushes satisfy this trivially.
if: github.ref == 'refs/heads/main'
# The rootfs is a ~2 GiB erofs built from source; that nix build sizes this.
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Decide whether this push touches the guest-image closure
id: gate
# The sibling publish jobs' push-event tree diff, over
# GUEST_IMAGE_CLOSURE_PATHS. Every fallback errs toward publishing, so
# a missing diff base never silently drops a closure change.
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail

if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "workflow_dispatch: force-publish (no push range to diff)"
exit 0
fi

if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "no diff base (first push to branch): force-publish"
exit 0
fi

git fetch --no-tags --depth=1 origin "$BEFORE_SHA" >/dev/null 2>&1 || true

if ! changed="$(git diff --name-only "$BEFORE_SHA" "$HEAD_SHA" 2>/dev/null)"; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "before-sha unreachable: force-publish (no-drop errs toward publishing)"
exit 0
fi

should_publish=false
while IFS= read -r pattern; do
[ -n "$pattern" ] || continue
case "$pattern" in
*'/**')
prefix="${pattern%'/**'}/"
while IFS= read -r f; do
[ -n "$f" ] || continue
case "$f" in
"$prefix"*) should_publish=true ;;
esac
done <<< "$changed"
;;
*)
while IFS= read -r f; do
[ "$f" = "$pattern" ] && should_publish=true
done <<< "$changed"
;;
esac
[ "$should_publish" = true ] && break
done <<< "$GUEST_IMAGE_CLOSURE_PATHS"

echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT"
echo "changed-path gate over the guest-image closure set: should_publish=$should_publish"

- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31
if: steps.gate.outputs.should_publish == 'true'
with:
# Declared here rather than via `accept-flake-config`, which would
# make nix trust the nixConfig of any flake it evaluates.
extra_nix_config: |
experimental-features = nix-command flakes
extra-substituters = https://devenv.cachix.org https://cachix.cachix.org
extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM=

- name: Put the pinned bun and moon on PATH
if: steps.gate.outputs.should_publish == 'true'
# Both come from the gate-tools pin, so the lane runs byte-identical
# binaries here and on a dev box. moon is required, not incidental: the
# lane realises its assets through the gate's own build task.
run: |
set -euo pipefail
for lang in bun moon; do
# `jq -r` renders a missing `.store` as the string `null`, a value
# rather than an absence, so test for it explicitly.
store=$(nix eval --json -f tools/toolchain/gate-tools.nix "langs.$lang" | jq -r '.store')
if [ -z "$store" ] || [ "$store" = null ]; then
echo "::error::gate-tools.nix langs.$lang produced no store path" >&2
exit 1
fi
nix build --no-link "$store"
echo "$store/bin" >>"$GITHUB_PATH"
done

- name: Put the fork's patched skopeo on PATH
if: steps.gate.outputs.should_publish == 'true'
# The publish lane probes and copies with a plain `skopeo` (the
# RigelBuild/nix2container fork's patched build). Resolve it from the
# shared pinned helper so this privileged job cannot run a mutable
# upstream build, the same pattern the sibling publish jobs use.
run: |
set -euo pipefail
# `--print-out-paths` prints every output (skopeo ships a `-man` output
# too); take the one carrying bin/skopeo, not a fixed line.
skopeo_bin=""
for store in $(nix build --no-link --print-out-paths \
-f tools/toolchain/skopeo-nix2container-env.nix skopeo); do
if [ -x "$store/bin/skopeo" ]; then
skopeo_bin="$store/bin"
break
fi
done
if [ -z "$skopeo_bin" ]; then
echo "::error::skopeo-nix2container-env.nix produced no output carrying bin/skopeo" >&2
exit 1
fi
echo "$skopeo_bin" >> "$GITHUB_PATH"

- name: Pin the registry auth file
if: steps.gate.outputs.should_publish == 'true'
# LOAD-BEARING. The login and the lane's own skopeo calls are SEPARATE
# processes and must resolve the SAME creds file; the default location
# is environment-dependent on hosted runners, and a mismatch greens the
# login then 401s the push.
run: echo "REGISTRY_AUTH_FILE=$RUNNER_TEMP/ghcr-auth.json" >> "$GITHUB_ENV"

- name: Log in to GHCR
if: steps.gate.outputs.should_publish == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pass the actor through env rather than interpolating ${{ }} into the
# shell, keeping context values off the run: command line.
ACTOR: ${{ github.actor }}
# The token is passed via env and `--password-stdin` only — never on a
# command line or in a log.
run: |
skopeo \
login ghcr.io -u "$ACTOR" --password-stdin \
--authfile "$REGISTRY_AUTH_FILE" <<< "$GITHUB_TOKEN"

- name: Publish the guest artifact by digest
if: steps.gate.outputs.should_publish == 'true'
# The lane realises the three assets, assembles the layout, scans its
# annotations before any push, and asserts the registry resolved the
# manifest it built. The deployable `repo@sha256:…` lands in the summary:
# GHCR has no server-side tag immutability, so nothing resolves by tag.
run: |
set -euo pipefail
# `--short=12` returns the shortest UNIQUE length >= 12, so a collision
# would yield 13+ chars and fail the lane's strict 12-hex check. A
# deterministic truncation keeps the contract exact.
sha12="$(git rev-parse HEAD | cut -c1-12)"
ref="$(bun tools/guest-image/publish.ts \
--repo ghcr.io/rigelbuild/compass-guest-image --sha "$sha12")"
echo "published $ref"
echo "guest image: \`$ref\`" >> "$GITHUB_STEP_SUMMARY"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,7 @@ result-*
# entrypoint symlink) and the OCI layout, realised by tools/runner-image/build.ts.
/runner-image/store/
/runner-image/out/

# guest-image publish output: the OCI layout a local dry run writes, multi-GiB
# because it holds a copy of the rootfs blob.
/guest-image/oci-layout/
26 changes: 17 additions & 9 deletions tools/guest-image/moon.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json
#
# guest-image: the agent-image pin the microVM guest rootfs derives from.
# guest-image: the agent-image pin the microVM guest rootfs derives from, and
# the lane that publishes the guest triple as an OCI artifact.
# TypeScript rather than a shell script (the no-bash-gate CI task).
#
# The pin CLI itself talks to a registry, so it is not a gate task. The
# typecheck/test tasks here ARE ordinary bun gates: they cover the pure core —
# the provenance rejections and the layer-descriptor extraction whose drift
# would silently make the rootfs unreproducible or stack a different
# filesystem.
# Both CLIs talk to a registry, so neither is a gate task. The typecheck/test
# tasks here ARE ordinary bun gates: they cover the pure cores — the provenance
# rejections, the layer-descriptor extraction whose drift would silently make
# the rootfs unreproducible, and the artifact mapping a registry would reject.
#
# A bun/TypeScript CLI, hoisted root-workspace member (`bun` tag): install is
# inherited via .moon/tasks/tag-bun.yml and lint/format are whole-repo root
Expand All @@ -22,9 +22,10 @@ tasks:
deps: ['install']
inputs: ['*.ts', 'tsconfig.json', '/tsconfig.base.json', 'package.json', '/bun.lock']
test:
# The pure core (pin-core.ts): every provenance rejection (foreign repo,
# moving tag, malformed digest), the manifest-shape refusals, and the
# layer order that determines the stacked filesystem.
# The pure cores: every provenance rejection (foreign repo, moving tag,
# malformed digest), the manifest-shape refusals, the layer order that
# determines the stacked filesystem, and the artifact descriptors plus
# fail-closed tag disposition the publish lane maps.
inputs: ['*.ts', 'tsconfig.json', '/tsconfig.base.json', 'package.json', '/bun.lock']
# The CI aggregate. Without it ci-matrix contributes NO target for this
# project (it emits `<id>:ci` only for a project defining a `ci` task), so
Expand All @@ -34,3 +35,10 @@ tasks:
deps: ['typecheck', 'test']
options:
cache: false
# Publishing needs network and GHCR credentials, so it runs in the release
# workflow, never the gate — the gate's cost stays the guest-image build.
publish:
command: 'bun publish.ts'
options:
runInCI: false
cache: false
Loading
Loading