Skip to content

feat(ci): publish amd64 and arm64 images - #996

Merged
mforce merged 8 commits into
mainfrom
ci/multi-arch-images
Sep 29, 2026
Merged

mforce merged 8 commits into
mainfrom
ci/multi-arch-images

Conversation

@mforce

@mforce mforce commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Why

Published images only contain amd64. Build and test both amd64 and arm64 on native runners, then publish one index that Docker can resolve on either host.

Scope

  • Run image as a native two-architecture matrix, with both legs pinned to Ubuntu 26.04. Pin publish to Ubuntu 26.04 as well. Trivy and the boot smoke test gate each image.
  • Carry each image and its local Id in a separate artifact. publish checks both before pushing the platform manifests.
  • Capture each pushed manifest digest, keep a digest-keyed tag for retention, and assemble the index from immutable digest references. Compute its digest locally from buildx's dry-run JSON and verify the published index and its exact children by digest.
  • Keep release promotion by digest and document how deployers verify the index attestation.

Tradeoffs

The digest-keyed child tags remain in GHCR so repair builds cannot orphan an older release's platform manifests. Plain per-commit architecture tags are convenience names. Publication waits for both matrix legs; a failed leg blocks the release until a successful CI dispatch or fix commit. The NuGet lock-drift guard runs once because its verdict is architecture-independent.

Blast Radius

CI gains an arm64 runner and a second image artifact. Releases still promote the CI digest without rebuilding. The amd64 cache key remains byte-identical to E2E's; the arm64 prefix cannot match amd64's broad restore key. The image and publisher runners are pinned so an ubuntu-latest alias move cannot change a build or digest-publication host without a workflow edit.

Verification

The pinned Ubuntu 26.04 CI image legs passed Trivy and the readiness smoke test on both architectures. The measured Build runtime image step was amd64 57 s and arm64 28 s on same-commit, fully cached reruns. One further same-commit rerun measured 35 s and 34 s, respectively; the 57 s amd64 sample did not repeat. Earlier Ubuntu 24.04 measurements were amd64 63 s cold / 33 s warm and arm64 66 s cold / 32 s warm; no 26.04 cold build was measured. The decision record links the run attempts.

On a Docker 29.8.1 client and daemon with the containerd store, a push to local registry:2 produced the exact tag: digest: sha256:… size: … line extracted by the workflow parser. The Docker 29.4.2 source uses this format in both image-store paths. Local RepoDigests still named the source index rather than the pushed platform manifest, so image inspection is unsafe for this handoff.

The local registry's exact-child assertion rejected a substituted amd64 manifest and an added Windows child; the former Linux-filtered assertion accepted that extra child. The dry-run JSON's locally computed digest matched the pushed index, and the immutable digest-built index stayed valid after the plain amd64 tag moved. Both imagetools create modes preserved an existing index digest; the decision record links the buildx implementation. An index-only GHCR attestation verified by index digest and failed by platform child digest. Bash syntax and the tracked-file image-pin guard passed. actionlint passed with only its stale unknown-label diagnostics for the two GA Ubuntu 26.04 labels ignored.

Closes #995

@mforce

mforce commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

Review record — local Codex agent (codex/gpt-6-astra), 2 rounds

CodeRabbit was not triggered. Review was run locally over the branch diff and the
full surrounding workflows, and is recorded here because it leaves no other trace
on the PR.

Round 1 — against a3345f0: 1 must-fix, 3 should-fix, 7 items sound

All four were accepted and fixed.

  1. Must-fix — the index was assembled from mutable tags, so its children were
    not provably the scanned bytes.
    imagetools create resolved
    :sha-<commit>-<arch> by tag, and the recorded index digest was then read back
    from :sha-<commit> — two substitution windows. Anything holding
    packages: write could repoint either between steps, and CI would attest,
    record and promote the substituted index. The media-type and architecture
    assertions could not catch it: a substituted amd64 manifest is still an amd64
    manifest, and the reviewer showed the predicate also returned true for an
    index carrying an extra Windows child, because it pre-filtered to
    os == "linux".

    This is the pattern release-please.yml already refuses one stage later, for
    the reason stated in its own comment. The pre-Publish multi-arch images so Cluckwork runs on arm64 (Raspberry Pi and ARM VPS) #995 code had no exposure,
    because it read RepoDigests off the same local image with no tag round-trip
    feeding a later step.

  2. Should-fix — per-commit child tags get moved by a repair dispatch. After a
    release promoted index I1, rebuilding the same commit moved both
    :sha-<commit>-<arch> tags to the new manifests, leaving I1's children
    untagged and collectable by untagged-manifest cleanup. The retention argument
    was right; the tag key was not.

  3. Should-fix — the arm64 cache prefix contained amd64's. restore-keys are
    prefix matches, so image-layers-arm64-<hash>-<sha> was matched by amd64's
    bare image-layers- fallback and by the identical fallback in
    e2e-smoke.yml. Lost cache reuse, silently.

  4. Should-fix — the release availability cost was undocumented. needs: image
    couples publication to both legs on its own, even without fail-fast.

Fixes, at 4e32097

  • Child digests now come from docker push's own stdout, not a registry
    read-back. The index digest is computed locally as the SHA-256 of
    imagetools create --dry-run output, and every verification runs against
    <image>@<that digest>. No mutable tag is read between establishing the digest
    and attesting it.
  • The jq assertion no longer pre-filters to linux, and pins both children's exact
    digests, so an extra or substituted child breaks it. Demonstrated red
    against a substituted amd64 manifest and against an added Windows child in a
    local registry:2; the old predicate accepted the latter.
  • Durable child tags carry the full manifest digest, so a repair dispatch cannot
    move them.
  • arm64 cache prefix is now arm64-image-layers-, disjoint from amd64's. The
    amd64 key stays byte-identical to e2e-smoke.yml, which needs no change.
  • The decision record records the fail-fast / needs distinction and cites
    buildx create.go rather than local hashes nobody can reproduce.

Round 2 — against 4e32097: clean

No must-fix, no should-fix. Established by source inspection and executed
counterexamples, not by argument:

  • Hosted runners ship buildx 0.37.1, which serializes the index without a
    trailing newline and pushes the original bytes, so the local hash is correct.
  • The push-output parser fails closed on a missing digest, a wrong tag, and two
    matching lines including identical duplicates.
  • The exact-child assertion rejects substituted digests, a duplicate third child,
    an annotations-only descriptor, and missing or null OS values.
  • --prefer-index=false carbon-copies in the single-manifest case the durable
    tags depend on.
  • No mutable-tag readback remains anywhere between push and subject-digest.

platform.variant and top-level subject / referrers can pass the predicate,
which is not a full schema validator. That is not a bypass: the locally computed
digest binds the whole index, and external referrers do not alter those bytes.

Residual risk, stated plainly

The publish path has never executed in CI, because it only runs on merges to
main; PR runs skip it. Index assembly, the locally computed digest and the new
assertions have been exercised only in a local registry:2. A failure there
would be fail-closed — a blocked publish, not wrong bytes — but it would block a
release until fixed. Actual GHCR publication, promotion, and a pull of a released
arm64 tag remain untested until merge.

The review loop was stopped deliberately at a clean round, not left unfinished.

mforce added a commit that referenced this pull request Sep 29, 2026
Review round 1 on #997 (Codex): the comment had the removal condition
backwards. #996 landing is when a workflow starts USING the
ubuntu-26.04-arm label, not when the entry becomes safe to drop. Keep
both entries; the only correct trigger to remove the file is a released
actionlint that knows the labels natively (rhysd/actionlint#682).

Also corrects an overstated PR-body/comment claim (Node/.NET/Playwright
are the only per-job-installed tools, Docker the only runner-provided
one) and softens the optical-size.spec.ts flake framing per the same
review round; those are PR body/comment edits, no code change.
@mforce
mforce force-pushed the ci/multi-arch-images branch from f4a030e to bacc473 Compare September 29, 2026 19:09
@mforce
mforce merged commit ce4a4ab into main Sep 29, 2026
16 checks passed
@mforce
mforce deleted the ci/multi-arch-images branch September 29, 2026 19:24
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.

Publish multi-arch images so Cluckwork runs on arm64 (Raspberry Pi and ARM VPS)

1 participant