diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ccb2e5fc6..a1b796f1d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -16,8 +16,10 @@ on: # cancel in-progress builds: on a fast-merging release branch (v2 saw ~34 merges # in 2h), cancel-in-progress meant every build was killed by the next merge and # NONE ever completed, freezing -latest and blocking all deploys. With -# cancel-in-progress:false the builds queue and each runs to completion, so -# -latest always advances to the newest merged commit. +# cancel-in-progress:false the builds queue and each runs to completion. Each +# completed build publishes an immutable SHA tag; publish-image-tags.sh uses a +# per-tag workflow generation to advance moving tags without either starving +# during a merge burst or regressing when queued runs execute out of order. concurrency: group: docker-build-${{ github.ref_name }} cancel-in-progress: false @@ -143,6 +145,11 @@ jobs: build-args: | GIT_HASH=${{ github.sha }} GIT_BRANCH=${{ github.ref_name }} + # Monotonic moving-tag guard metadata (#4804). This config label is + # inherited by each platform manifest without changing its media type. + labels: | + io.kubestellar.hive.github-actions-run-number=${{ github.run_number }} + org.opencontainers.image.revision=${{ github.sha }} # STALE-BINARY FIX (unblocks the #3760 fix in the SHIPPED image). # `no-cache-filters: builder` alone rebuilt # a fresh /hive in the `builder` stage but let BuildKit serve the FINAL @@ -269,6 +276,9 @@ jobs: runs-on: ubuntu-latest needs: [gate, build] steps: + - name: Checkout tag publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -286,56 +296,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Verify build commit is still HEAD of branch - id: head-check - run: | - BRANCH="${{ github.ref_name }}" - CURRENT_HEAD=$(git ls-remote https://github.com/${{ github.repository }} "$BRANCH" | awk '{print $1}') - if [ "${{ github.sha }}" != "$CURRENT_HEAD" ]; then - echo "Build is stale (built ${{ github.sha }} but $BRANCH HEAD is $CURRENT_HEAD) — skipping tag" - echo "stale=true" >> "$GITHUB_OUTPUT" - else - echo "stale=false" >> "$GITHUB_OUTPUT" - fi - - - name: Extract git metadata - if: steps.head-check.outputs.stale != 'true' - id: meta - run: | - echo "git_short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - # Docker tags can't contain '/', but branch names can (feat/x). - # Use the github.ref_name expression (reliably populated) and - # sanitize '/' -> '-' so feat/x becomes tag feat-x-latest. - REF_NAME='${{ github.ref_name }}' - echo "branch_tag=${REF_NAME//\//-}" >> "$GITHUB_OUTPUT" - # RELEASE CHANNELS. stable/candidate/edge are moving pointers that must - # always resolve to the newest good build of the RELEASE branch (v4). - # They are emitted here, in the same imagetools create that publishes - # -latest, so a channel is a RETAG of the digest we just built — - # never a rebuild, and never a manual promotion step that can be - # forgotten. Empty on every other branch, so a feature branch can never - # move a channel out from under production. - if [ "$REF_NAME" = "v4" ]; then - echo "release_channels=stable candidate edge" >> "$GITHUB_OUTPUT" - else - echo "release_channels=" >> "$GITHUB_OUTPUT" - fi - - name: Create manifest list and push - if: steps.head-check.outputs.stale != 'true' - working-directory: /tmp/digests run: | - shopt -s nullglob - digests=(*) - if [ ${#digests[@]} -eq 0 ]; then - echo "No digests to merge (per-arch builds published none) — skipping." - exit 0 - fi - docker buildx imagetools create \ - -t ghcr.io/kubestellar/hive:${{ steps.meta.outputs.branch_tag }}-latest \ - $(for c in ${{ steps.meta.outputs.release_channels }}; do printf -- '-t ghcr.io/kubestellar/hive:%s ' "$c"; done) \ - -t ghcr.io/kubestellar/hive:${{ steps.meta.outputs.git_short }} \ - $(printf 'ghcr.io/kubestellar/hive@sha256:%s ' "${digests[@]}") + src/scripts/publish-image-tags.sh \ + ghcr.io/kubestellar/hive /tmp/digests \ + '${{ github.ref_name }}' '${{ github.sha }}' '${{ github.run_number }}' \ + v4 false build-contributor: needs: gate @@ -374,6 +340,9 @@ jobs: context: . file: src/Dockerfile.contributor platforms: ${{ matrix.platform }} + labels: | + io.kubestellar.hive.github-actions-run-number=${{ github.run_number }} + org.opencontainers.image.revision=${{ github.sha }} # #3760: no default provenance/SBOM attestations → plain image # manifests, not an OCI index. See the note on the main build above. provenance: false @@ -403,6 +372,9 @@ jobs: runs-on: ubuntu-latest needs: [gate, build-contributor] steps: + - name: Checkout tag publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -420,57 +392,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Verify build commit is still HEAD of branch - id: head-check - run: | - BRANCH="${{ github.ref_name }}" - CURRENT_HEAD=$(git ls-remote https://github.com/${{ github.repository }} "$BRANCH" | awk '{print $1}') - if [ "${{ github.sha }}" != "$CURRENT_HEAD" ]; then - echo "Build is stale (built ${{ github.sha }} but $BRANCH HEAD is $CURRENT_HEAD) — skipping tag" - echo "stale=true" >> "$GITHUB_OUTPUT" - else - echo "stale=false" >> "$GITHUB_OUTPUT" - fi - - - name: Extract git metadata - if: steps.head-check.outputs.stale != 'true' - id: meta - run: | - echo "git_short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - # Docker tags can't contain '/', but branch names can (feat/x). - # Use the github.ref_name expression (reliably populated) and - # sanitize '/' -> '-' so feat/x becomes tag feat-x-latest. - REF_NAME='${{ github.ref_name }}' - echo "branch_tag=${REF_NAME//\//-}" >> "$GITHUB_OUTPUT" - # RELEASE CHANNELS. stable/candidate/edge are moving pointers that must - # always resolve to the newest good build of the RELEASE branch (v4). - # They are emitted here, in the same imagetools create that publishes - # -latest, so a channel is a RETAG of the digest we just built — - # never a rebuild, and never a manual promotion step that can be - # forgotten. Empty on every other branch, so a feature branch can never - # move a channel out from under production. - if [ "$REF_NAME" = "v4" ]; then - echo "release_channels=stable candidate edge" >> "$GITHUB_OUTPUT" - else - echo "release_channels=" >> "$GITHUB_OUTPUT" - fi - - name: Create contributor manifest list and push - if: steps.head-check.outputs.stale != 'true' - working-directory: /tmp/contrib-digests run: | - shopt -s nullglob - digests=(*) - if [ ${#digests[@]} -eq 0 ]; then - echo "No digests to merge (per-arch builds published none) — skipping." - exit 0 - fi - docker buildx imagetools create \ - -t ghcr.io/kubestellar/hive-contributor:latest \ - -t ghcr.io/kubestellar/hive-contributor:${{ steps.meta.outputs.branch_tag }}-latest \ - $(for c in ${{ steps.meta.outputs.release_channels }}; do printf -- '-t ghcr.io/kubestellar/hive-contributor:%s ' "$c"; done) \ - -t ghcr.io/kubestellar/hive-contributor:${{ steps.meta.outputs.git_short }} \ - $(printf 'ghcr.io/kubestellar/hive-contributor@sha256:%s ' "${digests[@]}") + src/scripts/publish-image-tags.sh \ + ghcr.io/kubestellar/hive-contributor /tmp/contrib-digests \ + '${{ github.ref_name }}' '${{ github.sha }}' '${{ github.run_number }}' \ + v4 true build-hub: needs: gate @@ -512,6 +439,9 @@ jobs: build-args: | GIT_HASH=${{ github.sha }} GIT_BRANCH=${{ github.ref_name }} + labels: | + io.kubestellar.hive.github-actions-run-number=${{ github.run_number }} + org.opencontainers.image.revision=${{ github.sha }} # STALE-BINARY FIX (see the main build step). The hub image also does # `COPY --from=builder /hive` into a `runtime` final stage, and its GHA # cache scope (hub-) is likewise branch-persistent — so without @@ -547,6 +477,9 @@ jobs: runs-on: ubuntu-latest needs: [gate, build-hub] steps: + - name: Checkout tag publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -564,54 +497,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Verify build commit is still HEAD of branch - id: head-check - run: | - BRANCH="${{ github.ref_name }}" - CURRENT_HEAD=$(git ls-remote https://github.com/${{ github.repository }} "$BRANCH" | awk '{print $1}') - if [ "${{ github.sha }}" != "$CURRENT_HEAD" ]; then - echo "Build is stale (built ${{ github.sha }} but $BRANCH HEAD is $CURRENT_HEAD) — skipping tag" - echo "stale=true" >> "$GITHUB_OUTPUT" - else - echo "stale=false" >> "$GITHUB_OUTPUT" - fi - - - name: Extract git metadata - if: steps.head-check.outputs.stale != 'true' - id: meta - run: | - echo "git_short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - # Docker tags can't contain '/', but branch names can (feat/x). - # Use the github.ref_name expression (reliably populated) and - # sanitize '/' -> '-' so feat/x becomes tag feat-x-latest. - REF_NAME='${{ github.ref_name }}' - echo "branch_tag=${REF_NAME//\//-}" >> "$GITHUB_OUTPUT" - # RELEASE CHANNELS. stable/candidate/edge are moving pointers that must - # always resolve to the newest good build of the RELEASE branch (v4). - # They are emitted here, in the same imagetools create that publishes - # -latest, so a channel is a RETAG of the digest we just built — - # never a rebuild, and never a manual promotion step that can be - # forgotten. Empty on every other branch, so a feature branch can never - # move a channel out from under production. - if [ "$REF_NAME" = "v4" ]; then - echo "release_channels=stable candidate edge" >> "$GITHUB_OUTPUT" - else - echo "release_channels=" >> "$GITHUB_OUTPUT" - fi - - name: Create hub manifest list and push - if: steps.head-check.outputs.stale != 'true' - working-directory: /tmp/hub-digests run: | - shopt -s nullglob - digests=(*) - if [ ${#digests[@]} -eq 0 ]; then - echo "No digests to merge (per-arch builds published none) — skipping." - exit 0 - fi - docker buildx imagetools create \ - -t ghcr.io/kubestellar/hive-hub:latest \ - -t ghcr.io/kubestellar/hive-hub:${{ steps.meta.outputs.branch_tag }}-latest \ - $(for c in ${{ steps.meta.outputs.release_channels }}; do printf -- '-t ghcr.io/kubestellar/hive-hub:%s ' "$c"; done) \ - -t ghcr.io/kubestellar/hive-hub:${{ steps.meta.outputs.git_short }} \ - $(printf 'ghcr.io/kubestellar/hive-hub@sha256:%s ' "${digests[@]}") + src/scripts/publish-image-tags.sh \ + ghcr.io/kubestellar/hive-hub /tmp/hub-digests \ + '${{ github.ref_name }}' '${{ github.sha }}' '${{ github.run_number }}' \ + v4 true diff --git a/.github/workflows/prune-ghcr.yml b/.github/workflows/prune-ghcr.yml index e731c4cfd..2962a9ef8 100644 --- a/.github/workflows/prune-ghcr.yml +++ b/.github/workflows/prune-ghcr.yml @@ -116,3 +116,56 @@ jobs: done done [ "$DRY_RUN" = "true" ] && echo "(dry-run: nothing deleted above)" || true + + # The docker workflow publishes an immutable short-SHA tag for every + # successful long-lived-branch build so merge bursts cannot leave completed + # commits without an addressable image. Keep that history bounded: once all + # moving tags have advanced away from an old build, the remaining pure + # 7-hex-SHA version is only a rollback/debug affordance and is pruned after + # the retention window below. Versions that still carry any non-SHA tag + # (v4-latest, stable, candidate, edge, latest, or future channel names) are + # never deleted by this job. + prune-old-sha-tags: + runs-on: ubuntu-latest + steps: + - name: Delete old pure short-SHA image tags + env: + GH_TOKEN: ${{ secrets.GHCR_PRUNE_TOKEN }} + DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || inputs.dry_run }} + RETENTION_DAYS: "90" + run: | + set -euo pipefail + cutoff=$(date -u -d "$RETENTION_DAYS days ago" +%s) + for image in hive hive-hub hive-contributor; do + echo "=== image: $image (pure short-SHA tags older than ${RETENTION_DAYS}d) ===" + gh api --paginate \ + "/orgs/kubestellar/packages/container/$image/versions" \ + --jq '.[] | {id: .id, created_at: .created_at, tags: .metadata.container.tags}' \ + | jq -c '.' | while read -r row; do + id=$(echo "$row" | jq -r '.id') + created_at=$(echo "$row" | jq -r '.created_at') + mapfile -t tags < <(echo "$row" | jq -r '.tags[]?') + [ "${#tags[@]}" -gt 0 ] || continue + + all_short_sha=true + for t in "${tags[@]}"; do + if ! [[ $t =~ ^[0-9a-f]{7}$ ]]; then + all_short_sha=false + break + fi + done + [ "$all_short_sha" = true ] || continue + + created_epoch=$(date -u -d "$created_at" +%s) + [ "$created_epoch" -lt "$cutoff" ] || continue + + joined=$(printf '%s,' "${tags[@]}") + echo "old pure-SHA version $id created=$created_at tags=[${joined%,}]" + if [ "$DRY_RUN" != "true" ]; then + gh api -X DELETE \ + "/orgs/kubestellar/packages/container/$image/versions/$id" \ + && echo " deleted $id" || echo " FAILED $id" + fi + done + done + [ "$DRY_RUN" = "true" ] && echo "(dry-run: nothing deleted above)" || true diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml index 6b9e1809b..6f63df64c 100644 --- a/.github/workflows/v2-ci.yml +++ b/.github/workflows/v2-ci.yml @@ -13,7 +13,7 @@ on: # not run on the one change that can break it — which is how the quick start # shipped broken twice over. src/docs/** already triggers this workflow via # src/**, so this is the same rule applied to the docs outside src/. - paths: ['src/**', 'bin/**', 'README.md', 'docs/**', '.github/workflows/v2-ci.yml', '.github/workflows/changelog-reminder.yml'] + paths: ['src/**', 'bin/**', 'README.md', 'docs/**', '.github/workflows/v2-ci.yml', '.github/workflows/changelog-reminder.yml', '.github/workflows/docker.yml'] pull_request: branches: [v2, v4] # bin/** is in scope because the gh wrapper is a SECURITY boundary (it is the @@ -26,7 +26,7 @@ on: # not run on the one change that can break it — which is how the quick start # shipped broken twice over. src/docs/** already triggers this workflow via # src/**, so this is the same rule applied to the docs outside src/. - paths: ['src/**', 'bin/**', 'README.md', 'docs/**', '.github/workflows/v2-ci.yml', '.github/workflows/changelog-reminder.yml'] + paths: ['src/**', 'bin/**', 'README.md', 'docs/**', '.github/workflows/v2-ci.yml', '.github/workflows/changelog-reminder.yml', '.github/workflows/docker.yml'] permissions: contents: read @@ -100,6 +100,13 @@ jobs: - name: Supply-chain pin guard run: bash deploy/test_supply_chain_pins.sh + # #4804: HEAD-only tagging made every completed build in a merge burst + # exit green without publishing anything. Exercise the real publisher + # against a fake registry and pin both halves of the replacement policy: + # immutable SHA tags are unconditional, while moving tags are monotonic. + - name: Docker release-tag publication policy (#4804) + run: bash scripts/test-publish-image-tags.sh + # #4206: the standalone stack's image references live in exactly one # place, src/deploy/standalone-images.sh, so the Docker Compose assets # and the Podman assets that land later cannot drift onto different diff --git a/src/docs/release-channels.md b/src/docs/release-channels.md index 4b63418e7..27777c6d7 100644 --- a/src/docs/release-channels.md +++ b/src/docs/release-channels.md @@ -20,6 +20,10 @@ Channels are **retags, not rebuilds**. The `docker.yml` workflow adds `stable`, Only builds of branch `v4` publish channels — a feature-branch build can never move a production channel. +Publishing is monotonic by workflow run number. Every successful multi-arch build receives its immutable short-SHA tag even if a newer merge has already reached the branch. If that exact short-SHA tag already exists, a re-run leaves it untouched. Moving tags (`v4-latest` and the three channels) advance only when that build is newer than the generation currently published; an older workflow that runs out of queue order publishes only any missing immutable tag. This avoids both failure modes of a HEAD-only guard: a merge burst cannot starve all tags, and an old queued build cannot move a channel backwards. Registry inspection failures fail the publish job instead of producing a silent green skip. + +Short-SHA tags are retained as a bounded rollback/debug window, not forever. The scheduled GHCR pruning workflow deletes only old package versions whose complete tag set is one or more 7-hex short-SHA tags, after 90 days. Versions still carrying any moving tag (`v4-latest`, `latest`, `stable`, `candidate`, `edge`, or future channel names) are never deleted by that cleanup. + ## Switching a hive to a channel From the hub dashboard's **My Hives** list, click the blue version pill on a hive row. The menu lists branches first, then a **Channels** section with the three channels (most stable first). Only the hive's **owner** can switch. diff --git a/src/scripts/publish-image-tags.sh b/src/scripts/publish-image-tags.sh new file mode 100755 index 000000000..d03ea86ae --- /dev/null +++ b/src/scripts/publish-image-tags.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Publish one multi-arch image without starving moving tags during merge bursts. +# +# Every successful build receives its immutable short-SHA tag. Moving tags are +# guarded by the workflow run number stored in each platform image's config: +# an older queued run may fill a gap, but can never move a tag backwards over a +# newer run that finished first. +set -euo pipefail + +if [[ $# -ne 7 ]]; then + echo "usage: $0 IMAGE DIGEST_DIR BRANCH GIT_SHA RUN_NUMBER RELEASE_BRANCH INCLUDE_LATEST" >&2 + exit 2 +fi + +image=$1 +digest_dir=$2 +branch=$3 +git_sha=$4 +run_number=$5 +release_branch=$6 +include_latest=$7 +run_label=io.kubestellar.hive.github-actions-run-number + +if [[ ! $run_number =~ ^[0-9]+$ ]]; then + echo "invalid workflow run number: $run_number" >&2 + exit 2 +fi +if [[ $include_latest != true && $include_latest != false ]]; then + echo "INCLUDE_LATEST must be true or false, got: $include_latest" >&2 + exit 2 +fi + +shopt -s nullglob +digest_files=("$digest_dir"/*) +if (( ${#digest_files[@]} == 0 )); then + echo "No digests to merge (per-arch builds published none) — skipping." + exit 0 +fi + +branch_tag=${branch//\//-} +git_short=${git_sha:0:7} +moving_refs=("$image:${branch_tag}-latest") +if [[ $include_latest == true ]]; then + moving_refs+=("$image:latest") +fi +if [[ $branch == "$release_branch" ]]; then + moving_refs+=("$image:stable" "$image:candidate" "$image:edge") +fi + +inspect_status= +inspect_generation= +read_generation() { + local ref=$1 inspect value + if inspect=$(docker buildx imagetools inspect \ + --format '{{json (index .Image "linux/amd64")}}' "$ref" 2>&1); then + value=$(jq -r --arg label "$run_label" '.config.Labels[$label] // "0"' <<<"$inspect") + if [[ ! $value =~ ^[0-9]+$ ]]; then + echo "::error::tag $ref has invalid $run_label label: $value" >&2 + exit 1 + fi + inspect_status=found + inspect_generation=$value + elif grep -Eqi 'manifest unknown|manifest.*not found|not found.*manifest|no such manifest|(^|[[:space:]:])not found$' <<<"$inspect"; then + inspect_status=missing + inspect_generation=0 + else + echo "::error::could not inspect tag $ref; refusing a potentially regressive publish" >&2 + echo "$inspect" >&2 + exit 1 + fi +} + +# Read the amd64 config label from every moving tag this invocation would +# update. All platforms are built with the same run-number label. Evaluate +# each tag independently so a newer global :latest from another branch cannot +# prevent this branch's own -latest tag from advancing after out-of-order runs. +# A registry transport/auth failure is red, not a green skip. +tag_args=() +sha_ref="$image:$git_short" +read_generation "$sha_ref" +if [[ $inspect_status == missing ]]; then + tag_args+=(-t "$sha_ref") + echo "Publishing immutable build tag $sha_ref at run $run_number." +else + echo "Immutable build tag $sha_ref already exists (generation: $inspect_generation); leaving it unchanged." +fi +for ref in "${moving_refs[@]}"; do + read_generation "$ref" + value=$inspect_generation + if [[ $inspect_status == missing ]]; then + echo "Moving tag $ref does not exist yet; treating it as generation 0." + fi + if (( run_number > value )); then + tag_args+=(-t "$ref") + echo "Advancing $ref at run $run_number (published generation: $value)." + elif (( run_number == value )); then + echo "Moving tag $ref is already at run $run_number; leaving it unchanged." + else + echo "::warning::run $run_number is older than $ref generation $value; leaving that moving tag unchanged" + fi +done + +if (( ${#tag_args[@]} == 0 )); then + echo "No tags need publishing." + exit 0 +fi + +source_args=() +for file in "${digest_files[@]}"; do + source_args+=("$image@sha256:${file##*/}") +done + +docker buildx imagetools create "${tag_args[@]}" "${source_args[@]}" diff --git a/src/scripts/test-publish-image-tags.sh b/src/scripts/test-publish-image-tags.sh new file mode 100755 index 000000000..7361fc8d9 --- /dev/null +++ b/src/scripts/test-publish-image-tags.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +publisher="$script_dir/publish-image-tags.sh" +tmp_root=${TMPDIR:-"$script_dir/../.test-tmp"} +mkdir -p "$tmp_root" +tmp=$(mktemp -d "$tmp_root/publish-tags.XXXXXX") +trap 'rm -rf "$tmp"; rmdir "$tmp_root" 2>/dev/null || true' EXIT +mkdir -p "$tmp/bin" "$tmp/digests" +touch "$tmp/digests/aaaaaaaa" "$tmp/digests/bbbbbbbb" + +cat >"$tmp/bin/docker" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +if [[ $1 == buildx && $2 == imagetools && $3 == inspect ]]; then + ref=${@: -1} + case "${MOCK_INSPECT_MODE:-legacy}" in + missing) echo "ERROR: $ref: not found" >&2; exit 1 ;; + failure) echo 'dial tcp: registry unavailable' >&2; exit 1 ;; + legacy) echo '{"config":{"Labels":{}}}' ;; + sha-missing-moving-newer) + if [[ $ref == *':abcdef1' ]]; then echo 'manifest unknown' >&2; exit 1; fi + printf '{"config":{"Labels":{"io.kubestellar.hive.github-actions-run-number":"101"}}}\n' + ;; + mixed) + if [[ $ref == *':latest' ]]; then value=101; else value=99; fi + printf '{"config":{"Labels":{"io.kubestellar.hive.github-actions-run-number":"%s"}}}\n' "$value" + ;; + *) printf '{"config":{"Labels":{"io.kubestellar.hive.github-actions-run-number":"%s"}}}\n' "$MOCK_INSPECT_MODE" ;; + esac + exit 0 +fi +printf '%q ' "$@" >"$MOCK_CAPTURE" +printf '\n' >>"$MOCK_CAPTURE" +MOCK +chmod +x "$tmp/bin/docker" + +run_case() { + local mode=$1 run=$2 capture=$3 + PATH="$tmp/bin:$PATH" MOCK_INSPECT_MODE=$mode MOCK_CAPTURE="$capture" \ + "$publisher" ghcr.io/kubestellar/hive "$tmp/digests" v4 abcdef123456 "$run" v4 false +} + +run_custom_case() { + local mode=$1 run=$2 capture=$3 branch=$4 include_latest=$5 + PATH="$tmp/bin:$PATH" MOCK_INSPECT_MODE=$mode MOCK_CAPTURE="$capture" \ + "$publisher" ghcr.io/kubestellar/hive-hub "$tmp/digests" "$branch" abcdef123456 "$run" v4 "$include_latest" +} + +capture="$tmp/create-new" +run_case missing 100 "$capture" +grep -q 'hive:abcdef1' "$capture" +grep -q 'hive:v4-latest' "$capture" +grep -q 'hive:stable' "$capture" + +capture="$tmp/create-forward" +run_case 99 100 "$capture" +grep -q 'hive:v4-latest' "$capture" + +capture="$tmp/create-stale" +run_case sha-missing-moving-newer 100 "$capture" +grep -q 'hive:abcdef1' "$capture" +if grep -q 'hive:v4-latest\|hive:stable\|hive:candidate\|hive:edge' "$capture"; then + echo "stale run moved a mutable tag" >&2 + exit 1 +fi + +capture="$tmp/create-idempotent" +run_case 100 100 "$capture" +if [ -e "$capture" ]; then + echo "rerun of an already-published workflow generation published tags again" >&2 + exit 1 +fi + +capture="$tmp/create-legacy" +run_case legacy 100 "$capture" +grep -q 'hive:v4-latest' "$capture" + +if run_case failure 100 "$tmp/create-failure"; then + echo "registry inspection failure did not fail closed" >&2 + exit 1 +fi + +capture="$tmp/create-feature" +run_custom_case missing 100 "$capture" feat/demo true +grep -q 'hive-hub:feat-demo-latest' "$capture" +grep -q 'hive-hub:latest' "$capture" +if grep -q 'hive-hub:stable\|hive-hub:candidate\|hive-hub:edge' "$capture"; then + echo "non-release branch moved a release channel" >&2 + exit 1 +fi + +# Moving tags are independent: a newer global :latest published from another +# branch must not block this branch's own -latest tag. +capture="$tmp/create-partial" +run_custom_case mixed 100 "$capture" feat/demo true +grep -q 'hive-hub:feat-demo-latest' "$capture" +if grep -q 'hive-hub:latest' "$capture"; then + echo "older run regressed the independently newer global latest tag" >&2 + exit 1 +fi + +# Pin all three workflow integrations. The publisher test alone would still +# pass if a later workflow edit bypassed it or forgot to stamp build metadata. +workflow="$script_dir/../../.github/workflows/docker.yml" +[[ $(grep -c 'io.kubestellar.hive.github-actions-run-number=' "$workflow") -eq 3 ]] +[[ $(grep -c 'src/scripts/publish-image-tags.sh' "$workflow") -eq 3 ]] +if grep -q 'head-check\|Verify build commit is still HEAD' "$workflow"; then + echo "HEAD-only publication guard was reintroduced" >&2 + exit 1 +fi + +echo "publish-image-tags tests: PASS"