diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4c6fdee5..f928bd2eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,9 +16,10 @@ # - release-pr — runs release-please on every main push, authenticated by a # scoped GitHub App token (NOT GITHUB_TOKEN) so the standing # Release PR receives CI and the merge gate is real (§A5). -# - publish-image — the RELOCATED per-push duty: publishes :git- + -# :latest via agent-image/publish.sh, self-gated by an in-job -# changed-path check over the image closure set. +# - publish-image-amd64 / publish-image-arm64 — parallel native builds with +# the shared image-closure gate and immutable per-arch tags. +# - publish-image-manifest — serializes the digest-pinned index and verifies +# the immutable and moving consumer-facing tags. # # The release-assets + release-image jobs (gated on # needs.release-pr.outputs.releases_created) are T3 (RIG-2915) and are NOT here. @@ -30,8 +31,8 @@ name: release on: # NO paths filter: release-pr MUST see every main commit (a paths filter would - # drop commits from the changelog and stall the Release PR). publish-image - # self-gates in-job instead (see its first step). + # drop commits from the changelog and stall the Release PR). The image build + # jobs self-gate in-job instead (see their first step). push: branches: [main] workflow_dispatch: @@ -42,9 +43,8 @@ permissions: {} # The image closure path set, defined ONCE. A change to any of these globs can # change the published image artifact, so it must trigger a republish. This is -# the SAME set publish-agent-image.yml:49-64 names — with that file's self-ref -# retargeted to release.yml — consumed by publish-image's in-job gate now and by -# T3's release-image resolver cross-check later, so it lives in one place. +# consumed by the two publish-image architecture gates and by T3's release-image +# resolver cross-check later, so it lives in one place. env: IMAGE_CLOSURE_PATHS: | agent-image/** @@ -54,16 +54,12 @@ env: .github/workflows/release.yml tools/toolchain/versions/bun.nix # The runner image's own closure path set, SEPARATE from IMAGE_CLOSURE_PATHS - # (that one is the agent image's, and publish-image plus T3's resolver - # cross-check consume it — widening it would republish the agent image on a - # runner-only change). Derived from runner-image/moon.yml's build.inputs and - # the flake attributes tools/runner-image/build.ts realises: the image's - # source tree, the TypeScript build/publish lane, the Go runner binary and its - # internal packages, the guest asset closure, the two flake files the nix - # realise reads, version.txt (stamped into the runner binary, so a release - # push publishes an image that reports its own version), the VMM env, and - # this workflow. Every glob is verified to - # match a real path — one that never matches would silently narrow the gate. + # (that one is the two publish-image architecture gates plus T3's resolver + # cross-check — widening it would republish the agent image on runner-only changes). + # Derived from runner-image/moon.yml's build.inputs and the flake attributes + # tools/runner-image/build.ts realises: source tree, build tools, Go packages, + # assets, version.txt, the VMM env, and this workflow. Every glob is verified + # to match a real path — one that never matches would silently narrow the gate. RUNNER_IMAGE_CLOSURE_PATHS: | runner-image/** tools/runner-image/** @@ -144,42 +140,26 @@ jobs: # branch it releases from is stated rather than inferred. target-branch: main - publish-image: - name: publish-image + publish-image-amd64: + name: publish-image-amd64 runs-on: ubuntu-latest # Least privilege: read the tree, write the GHCR package, nothing else. permissions: contents: read packages: write - # Publishes SERIALIZE — an in-flight :latest move must never be - # half-superseded by a newer run. `cancel-in-progress: false` is the OPPOSITE - # of ci.yml's cancelling group. `queue: max` (the bare literal token — there - # is no numeric form; up to 100 pending) rather than the default single - # pending slot: a per-push burst must be able to queue up to 100 deep WITHOUT - # a later entrant evicting a pending run, because T3's non-superseding - # :vX.Y.Z release mint shares this exact group and a bare - # `cancel-in-progress: false` alone would let a per-push entrant drop it - # (§A4 no-drop invariant). - concurrency: - group: publish-agent-image - cancel-in-progress: false - queue: max - # workflow_dispatch runs on any branch; guard so a dispatch from a feature - # branch can never mint a `:git-` for unmerged code nor move `:latest` - # off main. Main pushes satisfy this trivially. + # workflow_dispatch accepts any ref; keep both per-arch writes main-only. if: github.ref == 'refs/heads/main' # The image closure is the heavy nix build that sizes this timeout, the same # cost that motivates ci.yml's 90m. timeout-minutes: 90 - # `run: ./publish.sh` resolves relative to this default, and publish.sh's - # `devenv container build` resolves this image's devenv.yaml/devenv.lock - # only from agent-image/. The other steps do NOT depend on this cwd — the - # two bootstraps override to the repo root for their repo-root-relative nix - # paths, and login/verify invoke skopeo by name off PATH (bootstrapped - # below as an absolute store path). + # The publish script runs from agent-image/ so devenv resolves this image's + # lockfile. Bootstrap steps override to the repo root; login and publish use + # the patched skopeo on PATH. defaults: run: working-directory: agent-image + outputs: + should_publish: ${{ steps.gate.outputs.should_publish }} steps: # Default depth — the publish script needs only HEAD (git rev-parse HEAD # for the :git- tag). @@ -357,80 +337,136 @@ jobs: login ghcr.io -u "$ACTOR" --password-stdin \ --authfile "$REGISTRY_AUTH_FILE" <<< "$GITHUB_TOKEN" - - name: Build and publish the two-tag set - if: steps.gate.outputs.should_publish == 'true' - # No args = the default two-tag set (:git- then :latest). The script - # honors $REGISTRY_AUTH_FILE, builds the spec once, and enforces - # :git- immutability, exiting non-zero on violation. - run: ./publish.sh - - - name: Verify the published tags resolve from GHCR + - name: Build and publish the per-arch tag if: steps.gate.outputs.should_publish == 'true' - # Proves the artifact is resolvable from GHCR (not merely that copy - # exited 0), that the platform contract holds, and that the two-copy pair - # landed coherently. Every skopeo call pins --authfile. - run: | - set -euo pipefail - sha12="$(git rev-parse --short=12 HEAD)" - ref="docker://ghcr.io/rigelbuild/compass-agent" - inspect_json="$RUNNER_TEMP/git-inspect.json" - - # Resolvable from GHCR at the immutable tag. skopeo is the root dev - # shell's patched skopeo on PATH (bootstrapped above), understanding - # the `nix:` transport. - skopeo inspect --authfile "$REGISTRY_AUTH_FILE" "$ref:git-$sha12" > "$inspect_json" - - # Cheapest platform-contract-regression tripwire. - arch="$(jq -r .Architecture "$inspect_json")" - os="$(jq -r .Os "$inspect_json")" - if [ "$arch" != "amd64" ] || [ "$os" != "linux" ]; then - echo "platform contract violated: got $os/$arch, want linux/amd64" >&2 - exit 1 - fi + # The script publishes only the immutable per-arch tag and keeps its + # single-image digest guard unchanged. + run: ./publish.sh --arch-suffix amd64 - # :latest and :git- must share a config digest — proves the - # two-copy pair landed coherently. - git_digest="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:git-$sha12" | jq -r .config.digest)" - latest_digest="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:latest" | jq -r .config.digest)" - if [ "$git_digest" != "$latest_digest" ]; then - echo "tag pair incoherent: :git-$sha12=$git_digest != :latest=$latest_digest" >&2 - exit 1 - fi - echo "verified: $ref:git-$sha12 resolves, linux/amd64, coherent with :latest" - - agent-image-arm64-spike: - name: agent-image-arm64-spike - # GitHub-hosted arm64 runner (GA for public repos); the build is native, so - # nix2container emits an aarch64-linux spec with no cross machinery. + publish-image-arm64: + name: publish-image-arm64 runs-on: ubuntu-24.04-arm - # Dispatch-only spike: it proves the arm64 build and reports values later - # tasks consume. NOT ref-guarded like publish-image — the point is to run it - # from a feature branch. Inert on push because a push cannot set this event. - if: github.event_name == 'workflow_dispatch' - # Reads the tree only. It writes NO tags, so no packages: write. + # Least privilege: read the tree, write the GHCR package, nothing else. permissions: contents: read - # Same ceiling the image closure sizes elsewhere in this file. + packages: write + # workflow_dispatch accepts any ref; keep both per-arch writes main-only. + if: github.ref == 'refs/heads/main' + # The image closure is the heavy nix build that sizes this timeout, the same + # cost that motivates ci.yml's 90m. timeout-minutes: 90 + # The publish script runs from agent-image/ so devenv resolves the image's + # lockfile. Bootstrap and GHCR steps use repo-root overrides or PATH. defaults: run: working-directory: agent-image + outputs: + should_publish: ${{ steps.gate.outputs.should_publish }} steps: + # Default depth — the publish script needs only HEAD (git rev-parse HEAD + # for the :git- tag). - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Decide whether this push touches the image closure + id: gate + # The workflow trigger carries NO `paths:` filter (release-pr needs every + # commit), so this job decides for itself whether the push touched the + # closure set. This is a push-event before/after tree diff, NOT ci.yml's + # moon-affected query — a distinct technique. Run at the repo root; the + # closure globs are repo-root-relative. + working-directory: . + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + # workflow_dispatch has no before/after range to diff, and §A4's + # remediation (dispatch the per-push publish, then re-run the release) + # depends on a dispatch ALWAYS publishing. Force-publish. + 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 + + # First push to the branch has an all-zero before-sha with no diff + # base. Publish rather than risk silently dropping a closure change + # (the no-drop invariant errs toward publishing). + 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 + + # A two-dot tree diff needs only the two endpoint commits; checkout has + # HEAD at depth 1, so fetch the before endpoint the same way. + git fetch --no-tags --depth=1 origin "$BEFORE_SHA" >/dev/null 2>&1 || true + + # If the before-sha is unreachable (a force-push overwrote the prior + # tip, a GC'd object, or a fetch transient) the diff base is missing. + # Publish rather than fail the job — the no-drop invariant errs toward + # publishing, matching the workflow_dispatch and first-push fallbacks. + 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 + *'/**') + # Directory glob: match any changed file under the prefix. + prefix="${pattern%'/**'}/" + while IFS= read -r f; do + [ -n "$f" ] || continue + case "$f" in + "$prefix"*) should_publish=true ;; + esac + done <<< "$changed" + ;; + *) + # Exact file path. + while IFS= read -r f; do + [ "$f" = "$pattern" ] && should_publish=true + done <<< "$changed" + ;; + esac + [ "$should_publish" = true ] && break + done <<< "$IMAGE_CLOSURE_PATHS" + + echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT" + echo "changed-path gate over the image closure set: should_publish=$should_publish" + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 + if: steps.gate.outputs.should_publish == 'true' with: - # Caches declared inline, never via accept-flake-config: that would - # trust the nixConfig of any evaluated flake and let a branch inject - # its own substituter + key. Naming them here keeps that trust reviewed. + # nix-command + flakes for the RigelBuild forks' flakes. The two caches + # are declared HERE, not delegated via `accept-flake-config` — that + # setting makes nix trust the `nixConfig` of ANY flake it evaluates + # (the RigelBuild/devenv flake carries such a block), so a PR could add its + # own substituter AND trusted key and have CI run attacker-signed + # binaries. Naming the caches in this reviewed file keeps that trust + # reviewed. 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 toolchain on PATH - # bun resolves the devenv-CLI that reads the fork rev from devenv.lock. - # `jq -r` renders a missing `.store` as the literal `null`, so guard it. + if: steps.gate.outputs.should_publish == 'true' + # bun comes from the nix-pinned toolchain, never setup-bun. Resolve it + # from gate-tools.nix's langs so it is byte-identical to the dev shell's + # bun — the same derivation, not merely the same version. Only bun is + # bootstrapped here: publish needs the devenv-CLI resolver, not the + # whole language set. `langs.bun` must carry a real store path, and + # `jq -r` renders a missing `.store` as the literal string `null` — a + # value, not an absence — so the guard tests for `null` explicitly + # rather than emptiness alone, the same shape the digest check in + # publish.sh uses. working-directory: . run: | set -euo pipefail @@ -443,11 +479,25 @@ jobs: echo "$store/bin" >>"$GITHUB_PATH" - name: Put the fork's patched skopeo on PATH - # The fork's skopeo understands the `nix:` transport stock skopeo lacks. - # `--print-out-paths` prints every output; take the one carrying bin/skopeo. + if: steps.gate.outputs.should_publish == 'true' + # The publish lane invokes a plain `skopeo` (the RigelBuild/nix2container + # fork's patched build, understanding the `nix:` transport). Resolve it + # from the shared pinned helper, tools/toolchain/skopeo-nix2container-env.nix + # — which builds the exact derivation the root dev shell installs, from + # the nix2container + nixpkgs revisions ../devenv.lock pins (one source of + # truth for both revs, no raw nix2container flake ref) — and prepend its + # bin/ to PATH, so the login / publish / verify steps below invoke skopeo + # by name. This is the same out-of-band `nix build` pattern ci.yml uses + # for chromium-e2e-env.nix, and it avoids entering the root dev shell + # (whose enterShell banner would pollute a captured store path). skopeo is + # deliberately NOT in agent-image/devenv.nix: a package there would bake + # its ~168 MB closure into every published image via the container + # entrypoint's sourced shell env. working-directory: . 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 @@ -462,50 +512,238 @@ jobs: fi echo "$skopeo_bin" >> "$GITHUB_PATH" - - name: Build the arm64 image spec and record wall-clock - # Mirrors publish.sh's spec build (resolve the devenv fork rev from - # devenv.lock, then `container build agent`); pushes nothing. + - name: Pin the registry auth file + if: steps.gate.outputs.should_publish == 'true' + # LOAD-BEARING. `skopeo login` and the publish script's `skopeo copy` + # run as SEPARATE `nix run` processes and must resolve the SAME creds + # file. The default location ($XDG_RUNTIME_DIR/containers/auth.json) is + # environment-dependent on GitHub-hosted runners — a mismatch greens the + # login step and then 401s the copy. Export an explicit path both honor. + 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 — behavior-identical here (GitHub usernames carry no shell + # metacharacters), but keeps context values off the run: command line. + ACTOR: ${{ github.actor }} + # The root compass dev shell's patched skopeo (bootstrapped onto PATH + # above) understands the `nix:` transport the publish uses. It is a plain + # command here — one skopeo backs the whole lane, resolved from the + # lockfile-pinned nix2container input, and no raw nix2container flake ref + # lives in this workflow. + run: | + skopeo \ + login ghcr.io -u "$ACTOR" --password-stdin \ + --authfile "$REGISTRY_AUTH_FILE" <<< "$GITHUB_TOKEN" + + - name: Build and publish the per-arch tag + if: steps.gate.outputs.should_publish == 'true' + # The script publishes only the immutable per-arch tag and keeps its + # single-image digest guard unchanged. + run: ./publish.sh --arch-suffix arm64 + + publish-image-manifest: + name: publish-image-manifest + runs-on: ubuntu-latest + needs: [publish-image-amd64, publish-image-arm64] + if: >- + github.ref == 'refs/heads/main' && + needs.publish-image-amd64.outputs.should_publish == 'true' && + needs.publish-image-arm64.outputs.should_publish == 'true' + permissions: + contents: read + packages: write + # The only writer of :git- and :latest, so it alone serializes, with + # release-image. `queue: max` keeps up to 100 pending runs so a per-push + # burst never evicts a pending release mint (§A4 no-drop invariant). + concurrency: + group: publish-agent-image + cancel-in-progress: false + queue: max + steps: + # Full history: the :latest guard walks main's first-parent commits newer + # than this run's sha. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Pin the registry auth file + run: echo "REGISTRY_AUTH_FILE=$RUNNER_TEMP/ghcr-auth.json" >> "$GITHUB_ENV" + + - name: Log in to GHCR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} + run: | + skopeo login ghcr.io -u "$ACTOR" --password-stdin \ + --authfile "$REGISTRY_AUTH_FILE" <<< "$GITHUB_TOKEN" + + - name: Compose, guard, push, and verify the image index + env: + GH_SHA: ${{ github.sha }} run: | set -euo pipefail - started=$(date +%s) - src="$(bun ../tools/toolchain/devenv-cli/index.ts --lock devenv.lock --mode flakeref)" - build_out="$(nix run "$src" -- container build agent)" - spec="$(printf '%s\n' "$build_out" | tail -n 1)" - if [[ "$spec" != /nix/store/* ]]; then - echo "::error::container build produced no spec store path: $spec" >&2 + ref="docker://ghcr.io/rigelbuild/compass-agent" + # Truncate exactly as publish.sh does; --short=12 can return more. + sha12="${GH_SHA:0:12}" + local_list="localhost/compass-agent:${sha12}" + amd64_tag="$ref:git-${sha12}-amd64" + arm64_tag="$ref:git-${sha12}-arm64" + + amd64_digest="$(skopeo inspect --authfile "$REGISTRY_AUTH_FILE" \ + --format '{{.Digest}}' "$amd64_tag")" + arm64_digest="$(skopeo inspect --authfile "$REGISTRY_AUTH_FILE" \ + --format '{{.Digest}}' "$arm64_tag")" + for digest in "$amd64_digest" "$arm64_digest"; do + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::invalid per-arch manifest digest: $digest" >&2 + exit 1 + fi + done + + podman manifest create "$local_list" + # The list digest depends on add order; C.2 needs a stable digest to skip. + podman manifest add --authfile "$REGISTRY_AUTH_FILE" "$local_list" "$ref@$amd64_digest" + podman manifest add --authfile "$REGISTRY_AUTH_FILE" "$local_list" "$ref@$arm64_digest" + podman manifest inspect "$local_list" > "$RUNNER_TEMP/local-index.json" + local_count="$(jq -er '.manifests | length' "$RUNNER_TEMP/local-index.json")" + if [ "$local_count" -ne 2 ]; then + echo "::error::composed index has $local_count members, expected exactly 2" >&2 + exit 1 + fi + local_platforms="$(jq -er '[.manifests[].platform | "\(.os)/\(.architecture)"] | sort | join(",")' \ + "$RUNNER_TEMP/local-index.json")" + if [ "$local_platforms" != "linux/amd64,linux/arm64" ]; then + echo "::error::composed platform set is '$local_platforms', expected linux/amd64,linux/arm64" >&2 + exit 1 + fi + podman manifest push --format oci \ + --digestfile "$RUNNER_TEMP/local-index.digest" \ + "$local_list" "oci:$RUNNER_TEMP/local-index:index" + local_digest="$(cat "$RUNNER_TEMP/local-index.digest")" + if [[ ! "$local_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::podman returned an invalid local index digest: $local_digest" >&2 exit 1 fi - echo "SPEC=$spec" >> "$GITHUB_ENV" - echo "BUILD_SECONDS=$(( $(date +%s) - started ))" >> "$GITHUB_ENV" - - name: Assert arm64, report the native addon, and summarize - run: | - set -euo pipefail - arch="$(skopeo inspect "nix:$SPEC" | jq -r .Architecture)" - if [ "$arch" != "arm64" ]; then - echo "::error::built spec architecture is $arch, want arm64" >&2 + # Guard the immutable pin: skip on an equal list digest, fail on a + # different one, push only on a definitive manifest-unknown. + remote_err="$RUNNER_TEMP/git-index-inspect.err" + if remote_raw="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" \ + "$ref:git-$sha12" 2>"$remote_err")"; then + inspect_status=0 + else + inspect_status=$? + fi + cat "$remote_err" >&2 + if [ "$inspect_status" -eq 0 ]; then + remote_digest="$(printf '%s' "$remote_raw" | sha256sum | cut -d' ' -f1)" + remote_digest="sha256:$remote_digest" + if [ "$remote_digest" = "$local_digest" ]; then + echo "immutable index already matches: $local_digest" + else + echo "::error::immutable :git-$sha12 index differs: remote=$remote_digest local=$local_digest" >&2 + exit 1 + fi + elif grep -qiE '(^|[^[:alnum:]_])manifest unknown([^[:alnum:]_]|$)' "$remote_err"; then + podman manifest push --format oci --authfile "$REGISTRY_AUTH_FILE" \ + "$local_list" "$ref:git-$sha12" + else + echo "::error::ambiguous inspect failure probing :git-$sha12; refusing to push" >&2 exit 1 fi - # A later task needs the REAL @oh-my-pi native-addon filenames; - # discover them from the built closure rather than assuming x64's - # two-variant (modern/baseline) scheme, which has no arm64 analogue. - mapfile -t node_files < <(nix-store --query --requisites "$SPEC" \ - | xargs -r -I{} find {} -name 'pi_natives*.node' -printf '%f\n' | sort -u) - if [ "${#node_files[@]}" -eq 0 ]; then - echo "::error::no @oh-my-pi pi_natives*.node addon found in the built bundle" >&2 + # Runs reach this job in arch-build finish order, not commit order. If a + # newer main commit already has its :git index, that run owns :latest, + # so this older run only checks it. A sha no longer on main never moves it. + if ! git merge-base --is-ancestor "$GH_SHA" origin/main; then + echo "::error::$GH_SHA is not on origin/main (rewritten?); refusing to move :latest" >&2 exit 1 fi + newer_index="" + newer_commits="$(git rev-list --first-parent "$GH_SHA..origin/main")" + while IFS= read -r commit; do + [ -n "$commit" ] || continue + newer_sha12="${commit:0:12}" + if probe="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" \ + "$ref:git-$newer_sha12" 2>&1 >/dev/null)"; then + newer_index="$newer_sha12" + break + fi + if ! grep -qiE '(^|[^[:alnum:]_])manifest unknown([^[:alnum:]_]|$)' <<< "$probe"; then + echo "::error::ambiguous inspect failure probing newer :git-$newer_sha12; refusing to move :latest: $probe" >&2 + exit 1 + fi + done <<< "$newer_commits" - { - echo "### compass-agent arm64 build spike" - echo "- architecture: \`$arch\`" - echo "- build wall-clock: ${BUILD_SECONDS}s (ceiling 90m)" - echo "- native addon files:" - for f in "${node_files[@]}"; do echo " - \`$f\`"; done - } >> "$GITHUB_STEP_SUMMARY" + if [ -z "$newer_index" ]; then + podman manifest push --format oci --authfile "$REGISTRY_AUTH_FILE" \ + "$local_list" "$ref:latest" + else + echo "::warning::newer :git-$newer_index is published; leaving :latest to it" + fi + git_raw="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:git-$sha12")" + git_digest="$(printf '%s' "$git_raw" | sha256sum | cut -d' ' -f1)" + git_digest="sha256:$git_digest" + if [ "$git_digest" != "$local_digest" ]; then + echo "::error::pushed :git-$sha12 index changed: local=$local_digest remote=$git_digest" >&2 + exit 1 + fi - printf 'native addon files found:\n%s\n' "${node_files[*]}" + printf '%s' "$git_raw" > "$RUNNER_TEMP/published-index.json" + media_type="$(jq -er '.mediaType' "$RUNNER_TEMP/published-index.json")" + case "$media_type" in + application/vnd.oci.image.index.v1+json|application/vnd.docker.distribution.manifest.list.v2+json) ;; + *) + echo "::error::unexpected index mediaType: $media_type" >&2 + exit 1 + ;; + esac + platforms="$(jq -er '[.manifests[].platform | "\(.os)/\(.architecture)"] | sort | join(",")' \ + "$RUNNER_TEMP/published-index.json")" + if [ "$platforms" != "linux/amd64,linux/arm64" ]; then + echo "::error::platform set is '$platforms', expected linux/amd64,linux/arm64" >&2 + exit 1 + fi + member_digests="$(jq -er '[.manifests[].digest] | sort | join(",")' \ + "$RUNNER_TEMP/published-index.json")" + expected_digests="$(printf '%s\n%s\n' "$amd64_digest" "$arm64_digest" | sort | paste -sd, -)" + if [ "$member_digests" != "$expected_digests" ]; then + echo "::error::index member digest set differs: got=$member_digests expected=$expected_digests" >&2 + exit 1 + fi + + while IFS=$'\t' read -r member_digest member_os member_arch; do + config="$(skopeo inspect --config --authfile "$REGISTRY_AUTH_FILE" \ + "$ref@$member_digest")" + config_os="$(printf '%s' "$config" | jq -er '.os')" + config_arch="$(printf '%s' "$config" | jq -er '.architecture')" + if [ "$config_os" != "$member_os" ] || [ "$config_arch" != "$member_arch" ]; then + echo "::error::member $member_digest config is $config_os/$config_arch, declared $member_os/$member_arch" >&2 + exit 1 + fi + done < <(jq -r '.manifests[] | [.digest, .platform.os, .platform.architecture] | @tsv' \ + "$RUNNER_TEMP/published-index.json") + + # :latest must name the newest published index: ours, or the newer one + # found above. A newer run that failed before :latest reds this run. + expected_tag="git-$sha12" + expected_digest="$git_digest" + if [ -n "$newer_index" ]; then + expected_tag="git-$newer_index" + expected_digest="sha256:$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" \ + "$ref:$expected_tag" | sha256sum | cut -d' ' -f1)" + fi + latest_raw="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:latest")" + latest_digest="sha256:$(printf '%s' "$latest_raw" | sha256sum | cut -d' ' -f1)" + if [ "$latest_digest" != "$expected_digest" ]; then + echo "::error::latest index differs from newest immutable tag: :$expected_tag=$expected_digest :latest=$latest_digest" >&2 + exit 1 + fi + echo "verified OCI index $git_digest: linux/amd64,linux/arm64; :latest matches :$expected_tag" release-assets-macos: name: release-assets-macos @@ -636,8 +874,8 @@ jobs: # the generator below. Both jobs share the same `if:` (main ref + a real # release cut), so this edge never skips release-assets on a release run. It # DOES serialize release-assets behind release-image, which itself queues in - # the publish-agent-image concurrency group behind an in-flight publish-image - # — accepted added latency for a single authoritative digest. + # the publish-agent-image concurrency group behind an in-flight + # publish-image-manifest — accepted added latency for a single authoritative digest. needs: [release-pr, release-assets-macos, release-image] if: github.ref == 'refs/heads/main' && needs.release-pr.outputs.releases_created == 'true' # Least privilege: write the Release, nothing else. NOT packages:write — the @@ -646,7 +884,7 @@ jobs: permissions: contents: write # The nix toolchain resolve is the cost that sizes this timeout — the same - # ceiling ci.yml and publish-image use. + # ceiling ci.yml and both agent-image arch builds use. timeout-minutes: 90 steps: # Check out the release sha — the Release PR's merge commit release-please @@ -692,7 +930,7 @@ jobs: done - name: Put the fork's patched skopeo on PATH - # The release-notes generator queries GHCR for the image config digest + # The release-notes generator queries GHCR for the image index digest # with a plain `skopeo` (the RigelBuild/nix2container fork's patched # build). The langs bootstrap above carries only go/bun/node/moon, so # skopeo must be provisioned here or the digest query — a core T2 @@ -897,7 +1135,7 @@ jobs: # Digest-re-tag the already-published per-push `:git-` image to the # semver `:vX.Y.Z`. NEVER a second build (the release sha's image content is # byte-identical to the last closure-affecting sha's — §A4), NEVER touches - # `:latest` (owned exclusively by publish-image). Runs ONLY when the merged + # `:latest` (owned exclusively by publish-image-manifest). Runs ONLY when the merged # Release PR actually cut a release. needs: release-pr # Least privilege: read the tree, write the GHCR package, nothing else @@ -905,33 +1143,23 @@ jobs: permissions: contents: read packages: write - # The SAME group as publish-image: a non-superseding `:vX.Y.Z` release mint + # The SAME group as publish-image-manifest: a non-superseding `:vX.Y.Z` release mint # must serialize behind an in-flight `:latest` move WITHOUT being cancelled # by a later per-push entrant claiming the single default pending slot. # `queue: max` (the bare literal token — no numeric form; up to 100 pending) # rather than the default single pending slot preserves the §A4 no-drop # invariant; bare `cancel-in-progress: false` alone would drop it. - # Within a single release run this also queues behind that run's own - # publish-image job (same group) — intended serialization latency, not a - # hang; neither job `needs` the other. + # Within a single release run this also queues behind its own + # publish-image-manifest job (same group), with no needs edge. concurrency: group: publish-agent-image cancel-in-progress: false queue: max # workflow_dispatch runs on any branch; guard so a dispatch from a feature # branch can never mint a `:vX.Y.Z` for unmerged code. Main pushes satisfy - # this trivially (§A5(2), mirrors publish-image). + # this trivially (§A5(2), mirrors publish-image-manifest). if: github.ref == 'refs/heads/main' && needs.release-pr.outputs.releases_created == 'true' timeout-minutes: 90 - # Publish the resolved ancestor identity for release-assets to consume. The - # agent-image lane is paths-filtered (publish-image runs only when the image - # closure changes), so a release commit that touches nothing in that closure - # has NO `:git-` image — the release sha's tag was never built. - # This resolver already walks first-parent ancestors and proves the correct - # source digest (§A4 byte-identity). Rather than have release-assets re-probe - # `:git-` — a tag that does not exist, which hard-fails the notes - # generator — export the digest it already verified here as the single source - # of truth. release-assets reads `image_digest` and skips its own probe. outputs: image_digest: ${{ steps.retag.outputs.image_digest }} resolved_sha12: ${{ steps.retag.outputs.resolved_sha12 }} @@ -1072,25 +1300,23 @@ jobs: done < <(git rev-list --first-parent "$release_sha") if [ -z "$resolved_sha12" ]; then - echo "::error::no ancestor image :git- resolved on GHCR within $max_walk first-parent ancestors of $release_sha. Remediation: workflow_dispatch the release workflow (publish-image) on the release sha to publish the per-push image, then re-run this release. NEVER rebuild here." >&2 + echo "::error::no ancestor image :git- resolved on GHCR within $max_walk first-parent ancestors of $release_sha. Remediation: workflow_dispatch the release workflow on the release sha to publish its image, then re-run this release. NEVER rebuild here." >&2 exit 1 fi - # Registry-side manifest write: copy the resolved source tag's - # digest-resolved manifest to :vX.Y.Z. No nix build at all. - skopeo copy --authfile "$REGISTRY_AUTH_FILE" \ + # Registry-side write: copy the resolved index and every member to + # :vX.Y.Z byte-for-byte, so the release tag names the same list digest. + skopeo copy --multi-arch all --preserve-digests --authfile "$REGISTRY_AUTH_FILE" \ "$ref:git-$resolved_sha12" "$ref:$tag" - # Verify :vX.Y.Z resolves AND shares the source tag's config digest — - # the same coherence shape as publish-image's two-copy check - # (release.yml:311-318). Hard-fail on mismatch. - src_digest="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:git-$resolved_sha12" | jq -r .config.digest)" - tag_digest="$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:$tag" | jq -r .config.digest)" + # Verify :vX.Y.Z is the same index: equal list digests over the raw bytes. + src_digest="sha256:$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:git-$resolved_sha12" | sha256sum | cut -d' ' -f1)" + tag_digest="sha256:$(skopeo inspect --raw --authfile "$REGISTRY_AUTH_FILE" "$ref:$tag" | sha256sum | cut -d' ' -f1)" if [ "$src_digest" != "$tag_digest" ]; then echo "re-tag incoherent: :git-$resolved_sha12=$src_digest != :$tag=$tag_digest" >&2 exit 1 fi - echo "verified: $ref:$tag re-tagged from :git-$resolved_sha12, config digests coherent" + echo "verified: $ref:$tag re-tagged from :git-$resolved_sha12, list digests coherent" # Publish the resolved identity for release-assets (see the job-level # outputs: block). Reuse the digest the coherence check above already @@ -1125,12 +1351,8 @@ jobs: - name: Decide whether this push touches the runner-image closure id: gate - # Same push-event before/after tree diff publish-image uses (release.yml - # gate above), over RUNNER_IMAGE_CLOSURE_PATHS instead of the agent set. - # The trigger carries no `paths:` filter, so this job decides for itself. - # Every fallback ERRS TOWARD PUBLISHING (the no-drop invariant): a - # dispatch, a first push with no diff base, or an unreachable before-sha - # all force-publish rather than risk silently dropping a closure change. + # This gate uses the runner closure set because the trigger has no paths filter. + working-directory: . env: EVENT_NAME: ${{ github.event_name }} BEFORE_SHA: ${{ github.event.before }} diff --git a/agent-image/publish.sh b/agent-image/publish.sh index 44f44e2e6..2c9f1c2ac 100755 --- a/agent-image/publish.sh +++ b/agent-image/publish.sh @@ -70,22 +70,23 @@ elif [[ "${1:-}" == -* ]]; then exit 1 fi -# Default tag set: immutable pin FIRST, then the moving tag, so :git- -# exists before :latest moves onto it. A GA add is one more positional arg -# (./publish.sh git- v latest). +# The multi-arch index (and :latest) is composed in CI from the per-arch tags, +# so there is no bare default tag set; explicit tags serve manual retags. if [[ -n "$ARCH_SUFFIX" ]]; then # Reject --arch-suffix plus positional tags: a caller passing both has a bug. if [[ $# -gt 0 ]]; then err "--arch-suffix cannot be combined with explicit positional tags" exit 1 fi - SHA="$(git rev-parse --short=12 HEAD)" + # Truncate, not --short=12: a prefix collision would lengthen the tag and the + # CI index job, which reads exactly 12 chars, would miss it. + SHA="$(git rev-parse HEAD | cut -c1-12)" TAGS=("git-${SHA}-${ARCH_SUFFIX}") elif [[ $# -gt 0 ]]; then TAGS=("$@") else - SHA="$(git rev-parse --short=12 HEAD)" - TAGS=("git-${SHA}" "latest") + err "pass --arch-suffix or explicit tags" + exit 1 fi # Build the image spec exactly as dogfood:agent-image does — the fork rev is diff --git a/tools/release-notes/index.test.ts b/tools/release-notes/index.test.ts index 89542a0ed..69bca034d 100644 --- a/tools/release-notes/index.test.ts +++ b/tools/release-notes/index.test.ts @@ -10,6 +10,7 @@ // import.meta.main-guarded, so importing index.ts never runs it. No network. import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { type AssembleInput, assemble, @@ -163,7 +164,13 @@ describe("parseArgs — the edge's argv contract", () => { }); describe("classifyImageResult — the skopeo-result contract (crux of the skopeo fix)", () => { - const digestJson = JSON.stringify({ config: { digest: "sha256:beef" } }); + const manifestJson = JSON.stringify({ config: { digest: "sha256:beef" } }); + const indexJson = JSON.stringify({ + mediaType: "application/vnd.oci.image.index.v1+json", + manifests: [{ digest: "sha256:aaaa" }, { digest: "sha256:bbbb" }], + }); + const digestOf = (raw: string) => + `sha256:${createHash("sha256").update(raw).digest("hex")}`; test("exit 127 THROWS — a missing skopeo can never masquerade as an absent image", () => { expect(() => @@ -185,13 +192,18 @@ describe("classifyImageResult — the skopeo-result contract (crux of the skopeo ).toBeNull(); }); - test("exit 0 with a config digest yields the @digest ref and the digest", () => { + test("an image manifest yields the digest of its raw bytes, not its config digest", () => { + const digest = digestOf(manifestJson); expect( - classifyImageResult({ exitCode: 0, stdout: digestJson, stderr: "" }), - ).toEqual({ - ref: "ghcr.io/rigelbuild/compass-agent@sha256:beef", - digest: "sha256:beef", - }); + classifyImageResult({ exitCode: 0, stdout: manifestJson, stderr: "" }), + ).toEqual({ ref: `ghcr.io/rigelbuild/compass-agent@${digest}`, digest }); + }); + + test("a multi-arch index yields the list digest of its raw bytes", () => { + const digest = digestOf(indexJson); + expect( + classifyImageResult({ exitCode: 0, stdout: indexJson, stderr: "" }), + ).toEqual({ ref: `ghcr.io/rigelbuild/compass-agent@${digest}`, digest }); }); test("exit 0 with unparseable output degrades to null", () => { @@ -200,7 +212,7 @@ describe("classifyImageResult — the skopeo-result contract (crux of the skopeo ).toBeNull(); }); - test("exit 0 with no config.digest degrades to null", () => { + test("exit 0 with neither manifests nor config degrades to null", () => { expect( classifyImageResult({ exitCode: 0, stdout: "{}", stderr: "" }), ).toBeNull(); diff --git a/tools/release-notes/index.ts b/tools/release-notes/index.ts index a51ed90c5..56151557f 100644 --- a/tools/release-notes/index.ts +++ b/tools/release-notes/index.ts @@ -15,6 +15,7 @@ // the body + manifest files. Guarding behind `import.meta.main` lets the test // import the pure core without firing the edge. +import { createHash } from "node:crypto"; import { $ } from "bun"; // ── Pure-core types ──────────────────────────────────────────────────────── @@ -33,7 +34,7 @@ export type NixOutput = { export type ImageIdentity = { /** the pullable ref by digest, e.g. "ghcr.io/rigelbuild/compass-agent@sha256:…" */ ref: string; - /** the config digest, e.g. "sha256:…" */ + /** the manifest (or index) digest, e.g. "sha256:…" */ digest: string; }; @@ -219,8 +220,8 @@ export function parseArgs(argv: string[]): Args { * - any other non-zero => null: a 404 for an unpublished tag or a transient * transport error DEGRADES (the image lane is paths-filtered independently, * and a re-run converges the pointer once the image publishes). - * - exit 0 but unparseable output or no `.config.digest` => null. - * - exit 0 with a digest => the pullable @digest ref + the digest. + * - exit 0 but unparseable output, or neither an index nor an image manifest => null. + * - exit 0 with a manifest => the pullable @digest ref + the sha256 of its raw bytes. */ export function classifyImageResult(result: { exitCode: number; @@ -235,18 +236,16 @@ export function classifyImageResult(result: { if (result.exitCode !== 0) { return null; } - let digest: string; + let raw: { manifests?: unknown; config?: unknown }; try { - const raw = JSON.parse(result.stdout) as { - config?: { digest?: string }; - }; - digest = raw.config?.digest ?? ""; + raw = JSON.parse(result.stdout); } catch { return null; } - if (digest === "") { + if (raw?.manifests === undefined && raw?.config === undefined) { return null; } + const digest = `sha256:${createHash("sha256").update(result.stdout).digest("hex")}`; return { ref: `${IMAGE_REPO}@${digest}`, digest }; } @@ -257,7 +256,7 @@ export function classifyImageResult(result: { * no `:git-` image — probing the release sha asks for a tag that * was never published. The release-image job walks first-parent ancestors, * resolves the correct ancestor `:git-` digest, and re-tags it to - * `:vX.Y.Z`; passing that already-verified config digest here is the single + * `:vX.Y.Z`; passing that already-verified list digest here is the single * source of truth and avoids a re-probe race. Pure + exported so it is * unit-tested. Returns null for an empty/whitespace-only digest (no flag given). */ @@ -289,9 +288,9 @@ export function requireImageAtRelease( } /** - * Query GHCR for the image config digest at :git-, exactly as - * release.yml's publish-image verify does (`skopeo inspect --raw … | jq -r - * .config.digest`). Returns null when the tag is not published — the image lane + * Query GHCR for the image manifest digest at :git-: the sha256 of the + * raw bytes, as release.yml's index verify computes it. Returns null when the + * tag is not published — the image lane * is paths-filtered independently, so a go-only push has no image for its sha. */ async function gatherImage(sha: string): Promise {