diff --git a/.github/workflows/framework-build-base.yml b/.github/workflows/framework-build-base.yml index 5049d030901..9427c1af294 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,82 @@ 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)." + # 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 + 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 (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." 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)" + 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" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Free runner disk if: steps.existing.outputs.skip != 'true' run: | @@ -257,6 +339,38 @@ 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)" + 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" + 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 +406,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 +416,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',