From 92879770536bf6742196ab811e0c69fb3c9e206d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 03:25:01 +0000 Subject: [PATCH 1/4] ci(framework-build-base): tag every published digest immutably, refuse a silent re-point Every digest the publish job pushes, and the digest already published under a requested sha, now carries its own tag SHA-DIGEST12 that is never deleted or re-pointed, so a GHCR cleanup of untagged versions or a rebuild of the same pin never takes a pinned digest away. A rebuild over a published tag is refused unless replace_published is also true. Re-anchors the dispatch-gates self-test row for the moved no-check-families declaration (line 32 to 44). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSrcejjpWHd8D1fpmZ --- .github/workflows/framework-build-base.yml | 112 +++++++++++++++++++-- scripts/pm/dispatch-gates.self-test.mjs | 2 +- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/.github/workflows/framework-build-base.yml b/.github/workflows/framework-build-base.yml index 5049d030901..f76ba291782 100644 --- a/.github/workflows/framework-build-base.yml +++ b/.github/workflows/framework-build-base.yml @@ -17,8 +17,20 @@ name: Framework Build Base Image # with the PR's Dockerfile and pushes nothing; its job has no packages grant. # # A tag is written once. A request for a commit whose tag already exists reports -# the existing digest and builds nothing (dispatch with `rebuild: true` to -# replace it), so a downstream can ask on every pin move at no cost. +# the existing digest and builds nothing, so a downstream can ask on every pin +# move at no cost. Replacing a published tag takes a dispatch with BOTH +# `rebuild: true` and `replace_published: true`: `rebuild: true` alone over a +# published tag is refused, naming the digest it would move. A +# repository_dispatch carries neither input, so it never replaces anything. +# +# Every digest this workflow publishes also carries a tag of its own, +# `-`, and that tag is never deleted or +# re-pointed. A downstream pins this image by digest (cloud's committed +# .objectstack-base-image), and a registry serves a digest only while some tag +# holds it: a cleanup of untagged versions, or a replace that moves the `` +# tag, would otherwise take the pinned digest away. A request whose `` tag +# already exists writes its missing digest tag too, so a digest published +# before this rule becomes durable the next time it is requested. # # The image definition always comes from THIS run's checkout of main; only the # build context is the requested commit. So a commit that predates this file can @@ -29,7 +41,7 @@ name: Framework Build Base Image # settings (Danger Zone, Change visibility; it cannot be made private again). # Until then the anonymous-pull step below is red and names that fix. -# dispatch-gates: no-check-families -- the steps are an image build, a registry read and an in-image check script (docker/framework-build-base/check-image.sh, which needs the built image), not named local checks +# dispatch-gates: no-check-families -- the steps are an image build and push, registry reads and digest-tag writes, and an in-image check script (docker/framework-build-base/check-image.sh, which needs the built image), not named local checks on: workflow_dispatch: @@ -39,7 +51,12 @@ on: required: true type: string rebuild: - description: 'Rebuild and re-push even when the tag already exists (the digest changes)' + description: 'Rebuild and re-push even when the tag already exists (the digest changes); over a published tag it also needs replace_published' + required: false + type: boolean + default: false + replace_published: + description: 'With rebuild, move a published tag to the new digest (the old digest stays served under its own sha-digest tag; downstream pins must then be rewritten)' required: false type: boolean default: false @@ -190,17 +207,60 @@ jobs: env: REF: ${{ env.IMAGE }}:${{ steps.sha.outputs.sha }} REBUILD: ${{ inputs.rebuild && 'true' || 'false' }} + REPLACE: ${{ inputs.replace_published && 'true' || 'false' }} run: | set -euo pipefail DIGEST="$(docker buildx imagetools inspect "$REF" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest // empty' || true)" - if [ -n "$DIGEST" ] && [ "$REBUILD" != true ]; then - echo "$REF already exists at $DIGEST; nothing to build (dispatch with rebuild: true to replace it)." + # A published digest is handed on whatever happens next: the digest-tag + # step below keeps it served under a tag of its own. + if [ -n "$DIGEST" ]; then echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"; fi + if [ -z "$DIGEST" ]; then + echo "skip=false" >> "$GITHUB_OUTPUT" + elif [ "$REBUILD" != true ]; then + echo "$REF already exists at $DIGEST; nothing to build." echo "skip=true" >> "$GITHUB_OUTPUT" - echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + elif [ "$REPLACE" != true ]; then + echo "::error::$REF is published at $DIGEST, and rebuild: true would move it to a new digest. To replace it on purpose, dispatch again with rebuild: true and replace_published: true; then rewrite every downstream pin (cloud: scripts/framework-base-image.sh write, then commit .objectstack-base-image)." + exit 1 else + echo "$REF is published at $DIGEST; replace_published: true, so it is rebuilt and the tag moves. The old digest stays served under its digest tag." echo "skip=false" >> "$GITHUB_OUTPUT" fi + # Every published digest keeps a tag of its own, `-<12 hex>`, that is + # never deleted or re-pointed (see the header). The two steps below run the + # same script: this one for the digest already published under `` + # (the backfill, before a replace can move the `` tag), the next one + # for the digest this run pushed. + - name: Keep the published digest served under its own tag + if: steps.existing.outputs.digest != '' + id: kept + env: + SHA: ${{ steps.sha.outputs.sha }} + DIGEST: ${{ steps.existing.outputs.digest }} + run: | + set -euo pipefail + if ! printf '%s' "$DIGEST" | grep -Eq '^sha256:[0-9a-f]{64}$'; then + echo "::error::refusing to tag '$DIGEST': a digest is sha256: followed by 64 lowercase hex" + exit 1 + fi + TAG="$IMAGE:$SHA-${DIGEST:7:12}" + held() { docker buildx imagetools inspect "$TAG" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest // empty' || true; } + HELD="$(held)" + if [ -z "$HELD" ]; then + # --prefer-index=false copies the manifest as it is. The default + # wraps a lone image manifest in a new index, whose digest differs, + # so the tag would not hold DIGEST at all. + docker buildx imagetools create --prefer-index=false --tag "$TAG" "$IMAGE@$DIGEST" + HELD="$(held)" + fi + if [ "$HELD" != "$DIGEST" ]; then + echo "::error::$TAG holds ${HELD:-nothing}, not $DIGEST. A digest tag is never re-pointed, so nothing was changed; read the tag's history in the package's settings before touching it." + exit 1 + fi + echo "$TAG holds $DIGEST" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Free runner disk if: steps.existing.outputs.skip != 'true' run: | @@ -257,6 +317,35 @@ jobs: START: ${{ steps.clock.outputs.start }} run: echo "seconds=$(( $(date +%s) - START ))" >> "$GITHUB_OUTPUT" + - name: Tag the pushed digest under its own name + if: steps.existing.outputs.skip != 'true' + id: digest_tag + env: + SHA: ${{ steps.sha.outputs.sha }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + if ! printf '%s' "$DIGEST" | grep -Eq '^sha256:[0-9a-f]{64}$'; then + echo "::error::refusing to tag '$DIGEST': a digest is sha256: followed by 64 lowercase hex" + exit 1 + fi + TAG="$IMAGE:$SHA-${DIGEST:7:12}" + held() { docker buildx imagetools inspect "$TAG" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest // empty' || true; } + HELD="$(held)" + if [ -z "$HELD" ]; then + # --prefer-index=false copies the manifest as it is. The default + # wraps a lone image manifest in a new index, whose digest differs, + # so the tag would not hold DIGEST at all. + docker buildx imagetools create --prefer-index=false --tag "$TAG" "$IMAGE@$DIGEST" + HELD="$(held)" + fi + if [ "$HELD" != "$DIGEST" ]; then + echo "::error::$TAG holds ${HELD:-nothing}, not $DIGEST. A digest tag is never re-pointed, so nothing was changed; read the tag's history in the package's settings before touching it." + exit 1 + fi + echo "$TAG holds $DIGEST" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # The image is for anyone to pull, so it is read back the way a stranger # would: logged out. A fresh build is pulled whole and looked inside; an # existing tag only has its manifest read, anonymously. @@ -292,6 +381,9 @@ jobs: SKIPPED: ${{ steps.existing.outputs.skip }} WALL: ${{ steps.wall.outputs.seconds }} PULL: ${{ steps.pull.outcome }} + BUILT: ${{ steps.build.outputs.digest }} + PUSHED_TAG: ${{ steps.digest_tag.outputs.tag }} + KEPT_TAG: ${{ steps.kept.outputs.tag }} run: | { echo "### $IMAGE" @@ -299,6 +391,12 @@ jobs: echo "- framework commit: \`$SHA\`" echo "- tag: \`$IMAGE:$SHA\`" echo "- digest: \`${DIGEST:-none}\`" + if [ -n "$BUILT" ]; then + echo "- digest tag (never deleted or re-pointed): \`${PUSHED_TAG:-not written}\`" + if [ -n "$KEPT_TAG" ]; then echo "- the replaced digest stays served under: \`$KEPT_TAG\`"; fi + else + echo "- digest tag (never deleted or re-pointed): \`${KEPT_TAG:-not written}\`" + fi if [ "$SKIPPED" = true ]; then echo "- build: skipped, the tag already existed" else diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 3a1384c9440..27212393f75 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -8067,7 +8067,7 @@ export function selfTest({ tier = 'full' } = {}) { // is paths-filtered (its own file and docker/framework-build-base/) and builds // an image, then checks it with a script that needs the built image, so it // discovers no `check:` family. - '.github/workflows/framework-build-base.yml:32 no-check-families', + '.github/workflows/framework-build-base.yml:44 no-check-families', '.github/workflows/merged-branch-reaper.yml:212 no-check-families', '.github/workflows/os-create-smoke.yml:48 no-check-families', '.github/workflows/scaffold-e2e.yml:23 no-check-families', From 23ac9413be184b19c15821ab3e025af1d88ce65b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 03:26:15 +0000 Subject: [PATCH 2/4] ci(framework-build-base): name a failed digest-tag read-back apart from a held tag A write whose read-back differs now says the tag was written and the digest is not yet held, instead of claiming nothing changed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSrcejjpWHd8D1fpmZ --- .github/workflows/framework-build-base.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/framework-build-base.yml b/.github/workflows/framework-build-base.yml index f76ba291782..c7ba24ab109 100644 --- a/.github/workflows/framework-build-base.yml +++ b/.github/workflows/framework-build-base.yml @@ -253,9 +253,12 @@ jobs: # so the tag would not hold DIGEST at all. docker buildx imagetools create --prefer-index=false --tag "$TAG" "$IMAGE@$DIGEST" HELD="$(held)" - fi - if [ "$HELD" != "$DIGEST" ]; then - echo "::error::$TAG holds ${HELD:-nothing}, not $DIGEST. A digest tag is never re-pointed, so nothing was changed; read the tag's history in the package's settings before touching it." + if [ "$HELD" != "$DIGEST" ]; then + echo "::error::wrote $TAG, but it reads back as ${HELD:-nothing}, not $DIGEST, so $DIGEST is not yet held by a tag of its own." + exit 1 + fi + elif [ "$HELD" != "$DIGEST" ]; then + echo "::error::$TAG already holds $HELD, not $DIGEST. A digest tag is never re-pointed, so nothing was changed; read the tag's history in the package's settings before touching it." exit 1 fi echo "$TAG holds $DIGEST" @@ -338,9 +341,12 @@ jobs: # so the tag would not hold DIGEST at all. docker buildx imagetools create --prefer-index=false --tag "$TAG" "$IMAGE@$DIGEST" HELD="$(held)" - fi - if [ "$HELD" != "$DIGEST" ]; then - echo "::error::$TAG holds ${HELD:-nothing}, not $DIGEST. A digest tag is never re-pointed, so nothing was changed; read the tag's history in the package's settings before touching it." + if [ "$HELD" != "$DIGEST" ]; then + echo "::error::wrote $TAG, but it reads back as ${HELD:-nothing}, not $DIGEST, so $DIGEST is not yet held by a tag of its own." + exit 1 + fi + elif [ "$HELD" != "$DIGEST" ]; then + echo "::error::$TAG already holds $HELD, not $DIGEST. A digest tag is never re-pointed, so nothing was changed; read the tag's history in the package's settings before touching it." exit 1 fi echo "$TAG holds $DIGEST" From dd539bd54e21c4622cf7ed872aed7feefa86b9f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 03:36:07 +0000 Subject: [PATCH 3/4] ci(framework-build-base): name cloud's pin-rewrite step without a scripts/ path The replace refusal spelled the cloud repository's script as a scripts/ path, which check-self-test-wired reads as an invocation of a file in this tree. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSrcejjpWHd8D1fpmZ --- .github/workflows/framework-build-base.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/framework-build-base.yml b/.github/workflows/framework-build-base.yml index c7ba24ab109..1661c46c241 100644 --- a/.github/workflows/framework-build-base.yml +++ b/.github/workflows/framework-build-base.yml @@ -220,7 +220,7 @@ jobs: echo "$REF already exists at $DIGEST; nothing to build." echo "skip=true" >> "$GITHUB_OUTPUT" elif [ "$REPLACE" != true ]; then - echo "::error::$REF is published at $DIGEST, and rebuild: true would move it to a new digest. To replace it on purpose, dispatch again with rebuild: true and replace_published: true; then rewrite every downstream pin (cloud: scripts/framework-base-image.sh write, then commit .objectstack-base-image)." + echo "::error::$REF is published at $DIGEST, and rebuild: true would move it to a new digest. To replace it on purpose, dispatch again with rebuild: true and replace_published: true; then rewrite every downstream pin (in the cloud repository: run framework-base-image.sh write from its scripts directory, then commit .objectstack-base-image)." exit 1 else echo "$REF is published at $DIGEST; replace_published: true, so it is rebuilt and the tag moves. The old digest stays served under its digest tag." From 9907636b5a86c1d744db6e08bd9aea556ee3c084 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 04:17:38 +0000 Subject: [PATCH 4/4] ci(framework-build-base): read only the registry's not-found as unpublished A failed tag read (registry, token or network) used to read as unpublished, so the job rebuilt and moved the sha tag past the replace guard and before the backfill. Now only buildx's not-found answer means unpublished; any other failure stops the job naming the ref and the error. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSrcejjpWHd8D1fpmZ --- .github/workflows/framework-build-base.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/framework-build-base.yml b/.github/workflows/framework-build-base.yml index 1661c46c241..9427c1af294 100644 --- a/.github/workflows/framework-build-base.yml +++ b/.github/workflows/framework-build-base.yml @@ -210,7 +210,26 @@ jobs: REPLACE: ${{ inputs.replace_published && 'true' || 'false' }} run: | set -euo pipefail - DIGEST="$(docker buildx imagetools inspect "$REF" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest // empty' || true)" + # Only the registry's own "no such tag" answer means unpublished. Any + # other failed read (a registry, token or network error) stops the job: + # read as unpublished it would rebuild and move the tag past both + # guards below. buildx prints that answer as `ERROR: REF: not found`: + # containerd's resolver (core/remotes/docker/resolver.go) wraps a 404 + # for the tag as "REF: " plus errdefs.ErrNotFound, whose text is + # "not found", and buildx's main.go prefixes "ERROR: ". + ERR="$(mktemp)" + if READ="$(docker buildx imagetools inspect "$REF" --format '{{json .Manifest}}' 2>"$ERR")"; then + DIGEST="$(printf '%s' "$READ" | jq -r '.digest // empty')" + if [ -z "$DIGEST" ]; then + echo "::error::$REF was read, but the answer names no digest: $READ" + exit 1 + fi + elif grep -Fq "$REF: not found" "$ERR"; then + DIGEST="" + else + echo "::error::cannot tell whether $REF is published, so nothing is built or moved. The registry read failed with: $(tr '\n' ' ' < "$ERR")" + exit 1 + fi # A published digest is handed on whatever happens next: the digest-tag # step below keeps it served under a tag of its own. if [ -n "$DIGEST" ]; then echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"; fi