From 6c1feb4734c21d6c88901e2698ae7a67a7b1e32b Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:32:31 -0600 Subject: [PATCH 01/11] Native releases: plain X.Y.Z tags from 3.0, and a GitHub Release per release Operator direction: name native releases plainly ("3.0.2", not "native-3.0.2"), and have the release workflow create the GitHub Release. - release.yml: the push trigger matches only 0.x-2.x tags, and a first step refuses any 3.x+ or native- tag, pushed or dispatched. Only its chart job depends on that job. - native-release.yml, the dispatch verifier, identity, publisher and rolling-tag scripts accept plain X.Y.Z(-rc.N) from major 3. native- stays valid for the tags published before plain ones. The plan job passes the tag prefix (native- or none) to the tag-source verifier. - Rolling tags: native finals are native-X.Y.Z plus plain X.Y.Z from major 3. PHP finals, which can hold latest back, are plain X.Y.Z below 3. - A Release job runs after publish and aliases, and is the only job with contents: write. It creates the release for the tag (title = version, digest and run in the notes), a candidate as a prerelease and a final as Latest, and leaves an existing release alone on rerun. - check-native-release-tags.sh covers: - release.yml patterns don't match 3.x; - plain 3.x accepted and plain 2.x refused by publisher, identity and verifier; - plain-tag rolling cases; - the refusal step; - the single contents: write; - the exact job conditions including release. Existing native-3.0.0 and native-3.0.1 tags and their backfilled Releases are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-release.yml | 60 +++++++++++++++++--- .github/workflows/release.yml | 14 ++++- scripts/ci/check-native-release-tags.sh | 30 ++++++++-- scripts/ci/native-release-identity.sh | 8 ++- scripts/ci/native-release-publish.sh | 9 ++- scripts/ci/native-release-rolling-tags.sh | 17 ++++-- scripts/ci/verify-native-release-dispatch.sh | 5 +- 7 files changed, 118 insertions(+), 25 deletions(-) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index f190778ee..5b1adf68b 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -1,9 +1,12 @@ name: Native release candidate -# Publishes the native (Rust) server image for an existing immutable native -# Git tag, native- (Server #325). A native- tag never matches the PHP -# Release workflow's tag filter, so it starts no PHP release; the image tag is -# the version without the prefix. Each architecture builds on its own runner and pushes by +# Publishes the native (Rust) server image for an existing immutable Git tag: +# a plain X.Y.Z (or X.Y.Z-rc.N) from major 3, or a native- tag from +# before plain tags (Server #325). PHP Server releases are 2.x and earlier, and +# the PHP Release workflow refuses any other tag, so a native tag never starts +# a PHP release; the image tag is the version without any native- prefix. +# After both registries publish and a final release's aliases move, the +# Release job creates the GitHub Release (a candidate as a prerelease). Each architecture builds on its own runner and pushes by # digest only; that exact digest is pulled back and smoke-tested there, so a # failed smoke leaves no tag. The publish job then refuses tags that already # exist, gives both smoked digests @@ -41,7 +44,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Existing immutable native Git tag, native- (for example native-2.12.0-rc.1); the image tag drops native-' + description: 'Existing immutable native Git tag: X.Y.Z or X.Y.Z-rc.N from major 3 (for example 3.0.2), or a historical native-; the image tag drops native-' required: true release_commit: description: 'Full source commit of that tag' @@ -68,6 +71,7 @@ jobs: outputs: dockerhub: ${{ steps.plan.outputs.dockerhub }} version: ${{ steps.plan.outputs.version }} + tag_prefix: ${{ steps.plan.outputs.tag_prefix }} steps: - name: Check out trusted release tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -90,10 +94,14 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail - [[ "$RELEASE_TAG" =~ ^native-[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]] \ - || { echo 'tag must be native-X.Y.Z or native-X.Y.Z-rc.N' >&2; exit 1; } + # Plain X.Y.Z from major 3, above every PHP release; native- names + # the releases tagged before plain tags. + [[ "$RELEASE_TAG" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]] \ + || { echo 'tag must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)' >&2; exit 1; } echo dockerhub=true >> "$GITHUB_OUTPUT" echo "version=${RELEASE_TAG#native-}" >> "$GITHUB_OUTPUT" + if [[ "$RELEASE_TAG" == native-* ]]; then prefix=native-; else prefix=; fi + echo "tag_prefix=$prefix" >> "$GITHUB_OUTPUT" build-amd64: name: Build and smoke linux/amd64 @@ -126,7 +134,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} - RELEASE_TAG_PREFIX: native- + RELEASE_TAG_PREFIX: ${{ needs.plan.outputs.tag_prefix }} RELEASE_COMMIT: ${{ inputs.release_commit }} run: | set -euo pipefail @@ -219,7 +227,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} - RELEASE_TAG_PREFIX: native- + RELEASE_TAG_PREFIX: ${{ needs.plan.outputs.tag_prefix }} RELEASE_COMMIT: ${{ inputs.release_commit }} run: | set -euo pipefail @@ -390,3 +398,37 @@ jobs: INDEX_DIGEST: ${{ needs.publish.outputs.digest }} RELEASE_IMAGE_TAG_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git run: bash scripts/ci/native-release-rolling-tags.sh + + release: + name: Create the GitHub Release + # Only after both registries publish and a final release's aliases move: + # the release page then never runs ahead of the images. + needs: [plan, publish, aliases] + if: github.ref == format('refs/tags/{0}', inputs.tag) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Create or confirm the release for this tag + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_COMMIT: ${{ inputs.release_commit }} + VERSION: ${{ needs.plan.outputs.version }} + INDEX_DIGEST: ${{ needs.publish.outputs.digest }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists; left unchanged." + exit 0 + fi + notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `durableworkflow/server` and `ghcr.io/durable-workflow/server` |\n| Release run | %s |\n' \ + "$VERSION" "$RELEASE_TAG" "$RELEASE_COMMIT" "$INDEX_DIGEST" "$RUN_URL") + if [[ "$VERSION" == *-rc.* ]]; then + gh release create "$RELEASE_TAG" --verify-tag --title "$VERSION" --notes "$notes" --prerelease --latest=false + else + gh release create "$RELEASE_TAG" --verify-tag --title "$VERSION" --notes "$notes" --latest + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2b8356dd..b7b94e30d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,11 @@ name: Release run-name: Release ${{ inputs.tag || github.ref_name }} at ${{ inputs.release_commit || github.sha }} on: + # PHP Server releases are 2.x and earlier; plain tags from 3.0 are native + # releases (native-release.yml). push: tags: - - '[0-9]+.[0-9]+.[0-9]+*' + - '[0-2].[0-9]+.[0-9]+*' workflow_dispatch: inputs: tag: @@ -56,6 +58,16 @@ jobs: exact_publish_outcome: ${{ steps.exact.outputs.exact_publish_outcome }} published_database_tls_outcome: ${{ steps.published_database_tls.outcome }} steps: + - name: Refuse a native release tag + env: + REQUESTED_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + run: | + set -euo pipefail + # Pushed or dispatched, a PHP release is 2.x or earlier. A 3.x (or + # later) or native- tag belongs to native-release.yml. + [[ "$REQUESTED_TAG" =~ ^[0-2]\.[0-9]+\.[0-9]+ ]] \ + || { printf 'tag %s is not a PHP Server release (2.x or earlier)\n' "$REQUESTED_TAG" >&2; exit 1; } + - name: Check out trusted release tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 00f47ed2c..60c83e5c7 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -28,7 +28,7 @@ matches() { [[ $tag =~ ^${regex}$ ]] } for pattern in "${patterns[@]}"; do - for tag in native-2.12.0-rc.1 native-2.11.4 native-3.0.0; do + for tag in native-2.12.0-rc.1 native-2.11.4 native-3.0.0 3.0.2 3.1.0-rc.1 10.0.0; do if matches "$pattern" "$tag"; then fail "release.yml tag pattern $pattern matches $tag."; fi done done @@ -56,7 +56,10 @@ expect "may only be empty or native-" RELEASE_TAG=x-2.12.0 RELEASE_TAG_PREFIX=x- publish=scripts/ci/native-release-publish.sh images=(GHCR_IMAGE=ghcr.io/example/native DOCKERHUB_IMAGE=example/native) -expect "must be native-" RELEASE_TAG=2.12.0-rc.1 "${images[@]}" PUBLISH_DOCKERHUB=true bash "$publish" +expect "must be a native release version" RELEASE_TAG=2.12.0-rc.1 "${images[@]}" PUBLISH_DOCKERHUB=true bash "$publish" +expect "must be a native release version" RELEASE_TAG=2.11.9 "${images[@]}" PUBLISH_DOCKERHUB=true bash "$publish" +# A plain tag from major 3 passes the tag check and stops at the digests. +expect "exact smoked digest" RELEASE_TAG=3.0.2 "${images[@]}" PUBLISH_DOCKERHUB=true bash "$publish" # No digests stops the publisher right after the tag and registries are accepted. expect "exact smoked digest" RELEASE_TAG=native-2.12.0-rc.1 "${images[@]}" PUBLISH_DOCKERHUB=true bash "$publish" expect "exact smoked digest" RELEASE_TAG=native-2.12.0-rc.1 "${images[@]}" PUBLISH_DOCKERHUB=false bash "$publish" @@ -96,6 +99,10 @@ rolling() { # known tags, release tag [, alias state [, exact digest]] -> moved } want() { test "$2" = "$3" || fail "$1: expected rolling tags '$3', got '$2'."; } want "first final" "$(rolling $'2.11.8\nnative-3.0.0-rc.2' native-3.0.0)" "3.0 3 latest" +# Plain tags from major 3 are native; plain tags below 3 stay PHP releases. +want "first plain final" "$(rolling $'2.11.8\nnative-3.0.0\nnative-3.0.1' 3.0.2)" "3.0 3 latest" +want "plain behind a plain" "$(rolling $'2.11.8\nnative-3.0.1\n3.0.3' 3.0.2)" "" +want "plain candidate" "$(rolling $'2.11.8\nnative-3.0.1' 3.1.0-rc.1)" "" want "candidate" "$(rolling $'2.11.8' native-3.0.1-rc.1)" "" want "backport below a newer minor" "$(rolling $'native-3.0.0\nnative-3.1.0' native-3.0.1)" "3.0" want "older patch" "$(rolling $'native-3.0.0\nnative-3.0.2' native-3.0.1)" "" @@ -123,10 +130,13 @@ expect "INDEX_DIGEST must be the verified index digest" RELEASE_TAG=native-3.0.0 # only when its server reports exactly that version. identity=scripts/ci/native-release-identity.sh test "$(bash "$identity" expect native-3.0.0)" = 3.0.0 || fail "native-3.0.0 must admit 3.0.0." +test "$(bash "$identity" expect 3.0.2)" = 3.0.2 || fail "3.0.2 must admit 3.0.2." +test "$(bash "$identity" expect 3.1.0-rc.1)" = 3.1.0-rc.1 || fail "3.1.0-rc.1 must admit 3.1.0-rc.1." +if bash "$identity" expect 2.11.9 >/dev/null 2>&1; then fail "a plain 2.x tag is a PHP release, not native."; fi test "$(bash "$identity" expect native-3.0.0-rc.3)" = 3.0.0-rc.3 || fail "native-3.0.0-rc.3 must admit 3.0.0-rc.3." test -z "$(bash "$identity" expect '')" || fail "an unlabelled image is a development image." test -z "$(bash "$identity" expect '')" || fail "an unlabelled image is a development image." -for label in 3.0.0 native-3.0 native-3.0.0-beta native-3.0.0x 'native-3.0.0 '; do +for label in 2.11.9 3.0 3.0.0-beta native-3.0 native-3.0.0-beta native-3.0.0x 'native-3.0.0 '; do if bash "$identity" expect "$label" >/dev/null 2>&1; then fail "label '$label' must be refused."; fi done bash "$identity" check native-3.0.0 3.0.0 || fail "a matching release version must pass." @@ -223,10 +233,13 @@ ok_dispatch=(RELEASE_TAG=native-3.0.2 RELEASE_COMMIT=$commit DISPATCH_SHA=$commi expect "dispatch from refs/tags/native-3.0.2, not refs/heads/main" "${ok_dispatch[@]}" DISPATCH_REF=refs/heads/main bash "$dispatch" expect "dispatch from refs/tags/native-3.0.2, not refs/tags/native-3.0.1" "${ok_dispatch[@]}" DISPATCH_REF=refs/tags/native-3.0.1 bash "$dispatch" expect "is not RELEASE_COMMIT" RELEASE_TAG=native-3.0.2 RELEASE_COMMIT=$commit DISPATCH_SHA=$other DISPATCH_REF=refs/tags/native-3.0.2 GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh" bash "$dispatch" -expect "must be native-X.Y.Z" "${ok_dispatch[@]}" RELEASE_TAG=3.0.2 DISPATCH_REF=refs/tags/3.0.2 bash "$dispatch" +expect "must be X.Y.Z or X.Y.Z-rc.N from major 3" "${ok_dispatch[@]}" RELEASE_TAG=2.0.2 DISPATCH_REF=refs/tags/2.0.2 bash "$dispatch" for status in behind diverged; do expect "is not on main (compare status: $status)" "${ok_dispatch[@]}" DISPATCH_REF=refs/tags/native-3.0.2 COMPARE_STATUS=$status bash "$dispatch" done +plain_dispatch=(RELEASE_TAG=3.0.2 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit DISPATCH_REF=refs/tags/3.0.2 GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh") +env -i PATH="$PATH" "${plain_dispatch[@]}" bash "$dispatch" >/dev/null || fail "the dispatch verifier must accept a plain 3.x tag." +expect "must be X.Y.Z or X.Y.Z-rc.N from major 3" RELEASE_TAG=2.11.9 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit DISPATCH_REF=refs/tags/2.11.9 GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh" bash "$dispatch" for status in ahead identical; do env -i PATH="$PATH" "${ok_dispatch[@]}" DISPATCH_REF=refs/tags/native-3.0.2 COMPARE_STATUS=$status bash "$dispatch" >/dev/null \ || fail "the dispatch verifier must accept a release commit main contains ($status)." @@ -246,7 +259,8 @@ expected_conditions="plan=none build-amd64=$tag_gate build-arm64=$tag_gate publish=$tag_gate -aliases=$tag_gate && needs.plan.outputs.dockerhub == 'true'" +aliases=$tag_gate && needs.plan.outputs.dockerhub == 'true' +release=$tag_gate" test "$job_conditions" = "$expected_conditions" \ || fail "native-release.yml job conditions changed; expected: $expected_conditions @@ -254,4 +268,10 @@ got: $job_conditions" grep -q 'run: scripts/ci/verify-native-release-dispatch.sh' .github/workflows/native-release.yml \ || fail "the plan job must run the dispatch verifier." +# The PHP Release workflow refuses a native tag, pushed or dispatched, and only +# the native Release job may write releases. +grep -q 'name: Refuse a native release tag' .github/workflows/release.yml \ + || fail "release.yml must refuse a 3.x or native- tag." +test "$(grep -c ' contents: write' .github/workflows/native-release.yml)" = 1 \ + || fail "only the native Release job may hold contents: write." printf 'Native release tags: %d release.yml pattern(s) checked; verifier, publisher and rolling tags behave as intended.\n' "${#patterns[@]}" diff --git a/scripts/ci/native-release-identity.sh b/scripts/ci/native-release-identity.sh index a39a6d6ad..92aae6a0e 100755 --- a/scripts/ci/native-release-identity.sh +++ b/scripts/ci/native-release-identity.sh @@ -16,8 +16,12 @@ expected() { if [[ -z "$label" || "$label" == "" ]]; then return 0 fi - [[ "$label" =~ ^native-([0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?)$ ]] \ - || fail "release tag label '$label' is not native- or native--rc.." + # A plain tag is native only from major 3, above every PHP release; the + # native- prefix stays valid for the tags published before plain ones. + [[ "$label" == native-* || "$label" =~ ^([3-9]|[1-9][0-9]+)\. ]] \ + || fail "release tag label '$label' is not X.Y.Z (major 3 or later), X.Y.Z-rc.N, or a native- tag." + [[ "${label#native-}" =~ ^([0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?)$ ]] \ + || fail "release tag label '$label' is not X.Y.Z (major 3 or later), X.Y.Z-rc.N, or a native- tag." printf '%s\n' "${BASH_REMATCH[1]}" } diff --git a/scripts/ci/native-release-publish.sh b/scripts/ci/native-release-publish.sh index db4ebe2e0..c885000e0 100755 --- a/scripts/ci/native-release-publish.sh +++ b/scripts/ci/native-release-publish.sh @@ -11,8 +11,13 @@ # exact digest passed its smoke test. Writes digest= to $GITHUB_OUTPUT. set -euo pipefail fail() { printf 'Native release publication failed: %s\n' "$1" >&2; exit 1; } -[[ "${RELEASE_TAG:-}" =~ ^native-([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?)$ ]] \ - || fail "RELEASE_TAG must be native-." +# A plain tag is native only from major 3, above every PHP release; native- +# names the tags published before plain ones. +release_label=${RELEASE_TAG:-} +[[ "$release_label" == native-* || "$release_label" =~ ^([3-9]|[1-9][0-9]+)\. ]] \ + || fail "RELEASE_TAG must be a native release version: X.Y.Z from major 3, or native-." +[[ "${release_label#native-}" =~ ^([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?)$ ]] \ + || fail "RELEASE_TAG must be a native release version: X.Y.Z from major 3, or native-." image_tag=${BASH_REMATCH[1]} : "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" # Only a release candidate may skip Docker Hub. diff --git a/scripts/ci/native-release-rolling-tags.sh b/scripts/ci/native-release-rolling-tags.sh index 83bd084ca..f4840d77e 100755 --- a/scripts/ci/native-release-rolling-tags.sh +++ b/scripts/ci/native-release-rolling-tags.sh @@ -26,8 +26,13 @@ set -euo pipefail fail() { printf 'Native rolling tags failed: %s\n' "$1" >&2; exit 1; } docker_bin=${DOCKER:-docker} -[[ "${RELEASE_TAG:-}" =~ ^native-([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?)$ ]] \ - || fail "RELEASE_TAG must be native-." +# A plain tag is native only from major 3, above every PHP release; native- +# names the tags published before plain ones. +release_label=${RELEASE_TAG:-} +[[ "$release_label" == native-* || "$release_label" =~ ^([3-9]|[1-9][0-9]+)\. ]] \ + || fail "RELEASE_TAG must be a native release version: X.Y.Z from major 3, or native-." +[[ "${release_label#native-}" =~ ^([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?)$ ]] \ + || fail "RELEASE_TAG must be a native release version: X.Y.Z from major 3, or native-." version=${BASH_REMATCH[1]} if [[ -n "${BASH_REMATCH[2]}" ]]; then printf 'Native %s is not a final release; no rolling tag moves.\n' "$version" @@ -56,8 +61,12 @@ else fail "set RELEASE_IMAGE_KNOWN_TAGS or RELEASE_IMAGE_TAG_SOURCE_URL." fi -natives=$( { sed -n 's/^native-\([0-9]*\.[0-9]*\.[0-9]*\)$/\1/p' <<<"$known"; printf '%s\n' "$version"; } | sort -u -V) -php_latest=$(grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"$known" | sort -u -V | tail -n 1 || true) +# Native finals: native-X.Y.Z tags and plain X.Y.Z from major 3. PHP finals: +# plain X.Y.Z below major 3. +natives=$( { sed -n 's/^native-\([0-9]*\.[0-9]*\.[0-9]*\)$/\1/p' <<<"$known" + grep -E '^([3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+$' <<<"$known" || true + printf '%s\n' "$version"; } | sort -u -V) +php_latest=$(grep -E '^[0-2]\.[0-9]+\.[0-9]+$' <<<"$known" | sort -u -V | tail -n 1 || true) major=${version%%.*} minor=${version#*.}; minor="$major.${minor%%.*}" newer() { [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]]; } diff --git a/scripts/ci/verify-native-release-dispatch.sh b/scripts/ci/verify-native-release-dispatch.sh index c4c051ef9..3b4bf089a 100755 --- a/scripts/ci/verify-native-release-dispatch.sh +++ b/scripts/ci/verify-native-release-dispatch.sh @@ -15,8 +15,9 @@ dispatch_ref="${DISPATCH_REF:-}" dispatch_sha="${DISPATCH_SHA:-}" repository="${GITHUB_REPOSITORY:-}" gh_cli="${GH_CLI:-gh}" -[[ "$release_tag" =~ ^native-[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]] \ - || fail "RELEASE_TAG must be native-X.Y.Z or native-X.Y.Z-rc.N." +# Plain X.Y.Z(-rc.N) from major 3, or a native- tag published before plain ones. +[[ "$release_tag" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]] \ + || fail "RELEASE_TAG must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)." [[ "$release_commit" =~ ^[0-9a-f]{40}$ ]] \ || fail "RELEASE_COMMIT must be a full lowercase Git commit SHA." [ "$dispatch_ref" = "refs/tags/$release_tag" ] \ From 68da7a4ff4190bad84b334820b17fb9b66bec7a2 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:38:30 -0600 Subject: [PATCH 02/11] GitHub Latest follows the latest alias, so a backport never takes it The release job marked every final Latest. A 3.0.2 backport after 3.1.0 correctly left the registry latest alias at 3.1.0 but would have moved GitHub Latest back to 3.0.2. The aliases job now outputs the aliases it moved, and the release step (scripts/ci/native-release-github-release.sh) marks a final Latest only when latest is among them. Candidates stay prereleases, never Latest; an existing release is left unchanged. check-native-release-tags.sh chains the rolling-tag decision into the release step: forward 3.1.0 is Latest; a 3.0.2 backport behind 3.1.0, with current or stale Git tags, is not. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-release.yml | 27 ++++++++-------- scripts/ci/check-native-release-tags.sh | 35 +++++++++++++++++++++ scripts/ci/native-release-github-release.sh | 35 +++++++++++++++++++++ 3 files changed, 83 insertions(+), 14 deletions(-) create mode 100755 scripts/ci/native-release-github-release.sh diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 5b1adf68b..11b603218 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -370,6 +370,8 @@ jobs: permissions: contents: read packages: write + outputs: + rolling_tags: ${{ steps.rolling.outputs.rolling_tags }} steps: - name: Check out trusted release tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -393,6 +395,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Reconcile the rolling tags with the attested index + id: rolling env: RELEASE_TAG: ${{ inputs.tag }} INDEX_DIGEST: ${{ needs.publish.outputs.digest }} @@ -402,7 +405,8 @@ jobs: release: name: Create the GitHub Release # Only after both registries publish and a final release's aliases move: - # the release page then never runs ahead of the images. + # the release page then never runs ahead of the images, and GitHub Latest + # follows the latest alias, so a backport never takes it. needs: [plan, publish, aliases] if: github.ref == format('refs/tags/{0}', inputs.tag) runs-on: ubuntu-latest @@ -410,6 +414,12 @@ jobs: permissions: contents: write steps: + - name: Check out trusted release tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Create or confirm the release for this tag env: GH_TOKEN: ${{ github.token }} @@ -418,17 +428,6 @@ jobs: RELEASE_COMMIT: ${{ inputs.release_commit }} VERSION: ${{ needs.plan.outputs.version }} INDEX_DIGEST: ${{ needs.publish.outputs.digest }} + ROLLING_TAGS: ${{ needs.aliases.outputs.rolling_tags }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - set -euo pipefail - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - echo "Release $RELEASE_TAG already exists; left unchanged." - exit 0 - fi - notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `durableworkflow/server` and `ghcr.io/durable-workflow/server` |\n| Release run | %s |\n' \ - "$VERSION" "$RELEASE_TAG" "$RELEASE_COMMIT" "$INDEX_DIGEST" "$RUN_URL") - if [[ "$VERSION" == *-rc.* ]]; then - gh release create "$RELEASE_TAG" --verify-tag --title "$VERSION" --notes "$notes" --prerelease --latest=false - else - gh release create "$RELEASE_TAG" --verify-tag --title "$VERSION" --notes "$notes" --latest - fi + run: bash scripts/ci/native-release-github-release.sh diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 60c83e5c7..8df00dceb 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -125,6 +125,41 @@ grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written after want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "FAILED" grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written though an alias could not be read." expect "INDEX_DIGEST must be the verified index digest" RELEASE_TAG=native-3.0.0 "${images[@]}" RELEASE_IMAGE_KNOWN_TAGS=x bash scripts/ci/native-release-rolling-tags.sh +# GitHub Releases follow the latest alias: the release job marks a final +# Latest only when the rolling-tag job moved latest to it. Each case feeds the +# rolling-tag decision for the same state into the release step. +cat > "$scratch/gh" <<'FAKE' +#!/usr/bin/env bash +if [[ "$1 $2" == "release view" ]]; then [[ -n "${EXISTING:-}" ]]; exit; fi +printf '%s\n' "$*" >> "$GH_LOG" +FAKE +chmod +x "$scratch/gh" +gh_release() { # release tag, rolling tags [, existing] -> the Latest choice + : > "$scratch/gh_calls" + env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" RELEASE_TAG="$1" VERSION="${1#native-}" \ + RELEASE_COMMIT=$(printf '%040d' 0) INDEX_DIGEST="$index" RUN_URL=https://example.invalid/run \ + ROLLING_TAGS="$2" EXISTING="${3:-}" bash scripts/ci/native-release-github-release.sh >/dev/null 2>&1 || { echo FAILED; return; } + grep -q -- '^release create ' "$scratch/gh_calls" || { echo none; return; } + grep -q -- '--verify-tag --title ' "$scratch/gh_calls" || { echo FAILED; return; } + tr '\n' ' ' < "$scratch/gh_calls" | grep -oE -- '--prerelease|--latest(=false)?( |$)' | tr -d ' ' | paste -sd' ' - +} +want_release() { test "$2" = "$3" || fail "$1: expected GitHub Release '$3', got '$2'."; } +forward=$(rolling $'2.11.8\nnative-3.0.1\n3.0.2' 3.1.0 $'ghcr.io/example/native:latest 3.0.2\nexample/native:latest 3.0.2') +want "forward 3.1.0" "$forward" "3.1 3 latest" +want_release "forward 3.1.0" "$(gh_release 3.1.0 "$forward")" "--latest" +backport_state=$'ghcr.io/example/native:3 3.1.0\nexample/native:3 3.1.0\nghcr.io/example/native:latest 3.1.0\nexample/native:latest 3.1.0' +backport=$(rolling $'2.11.8\nnative-3.0.1\n3.1.0' 3.0.2 "$backport_state") +want "3.0.2 backport behind 3.1.0" "$backport" "3.0" +want_release "3.0.2 backport behind 3.1.0" "$(gh_release 3.0.2 "$backport")" "--latest=false" +# The older release finishing last, after reading stale Git tags, still +# leaves Latest to the newer one. +stale=$(rolling $'2.11.8\nnative-3.0.1' 3.0.2 "$backport_state") +want "3.0.2 with stale tags behind 3.1.0" "$stale" "3.0" +want_release "3.0.2 with stale tags behind 3.1.0" "$(gh_release 3.0.2 "$stale")" "--latest=false" +want_release "backport that moves nothing" "$(gh_release 3.0.2 "")" "--latest=false" +want_release "candidate" "$(gh_release 3.1.0-rc.1 "")" "--prerelease --latest=false" +want_release "native- final that owns latest" "$(gh_release native-3.0.2 "3.0 3 latest")" "--latest" +want_release "rerun of an existing release" "$(gh_release 3.1.0 "3.1 3 latest" yes)" "none" # Release identity: only a strict native- label admits a release image, and # only when its server reports exactly that version. diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh new file mode 100755 index 000000000..7b1b47088 --- /dev/null +++ b/scripts/ci/native-release-github-release.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Create the GitHub Release for a published native release tag (Server #325). +# +# GitHub Latest follows the registry: a final release is marked Latest only +# when the rolling-tag job moved (or confirmed) the latest alias to it, so a +# backport behind a newer release never takes Latest from it. A release +# candidate is a prerelease and never Latest. An existing release is left +# unchanged, so a rerun cannot move Latest either. +# +# Env: RELEASE_TAG, RELEASE_COMMIT, VERSION, INDEX_DIGEST, RUN_URL, and +# ROLLING_TAGS (the aliases the rolling-tag job moved, space separated). GH +# overrides the gh command. +set -euo pipefail +fail() { printf 'GitHub Release failed: %s\n' "$1" >&2; exit 1; } +gh_bin=${GH:-gh} +: "${RELEASE_TAG:?}" "${RELEASE_COMMIT:?}" "${VERSION:?}" "${INDEX_DIGEST:?}" "${RUN_URL:?}" +[[ "${RELEASE_TAG#native-}" == "$VERSION" ]] || fail "RELEASE_TAG $RELEASE_TAG does not name VERSION $VERSION." + +if "$gh_bin" release view "$RELEASE_TAG" >/dev/null 2>&1; then + printf 'Release %s already exists; left unchanged.\n' "$RELEASE_TAG" + exit 0 +fi + +notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `durableworkflow/server` and `ghcr.io/durable-workflow/server` |\n| Release run | %s |\n' \ + "$VERSION" "$RELEASE_TAG" "$RELEASE_COMMIT" "$INDEX_DIGEST" "$RUN_URL") +args=(release create "$RELEASE_TAG" --verify-tag --title "$VERSION" --notes "$notes") +if [[ "$VERSION" == *-* ]]; then + args+=(--prerelease --latest=false) +elif [[ " ${ROLLING_TAGS:-} " == *" latest "* ]]; then + args+=(--latest) +else + printf 'Release %s does not own the latest alias; GitHub Latest stays where it is.\n' "$RELEASE_TAG" + args+=(--latest=false) +fi +"$gh_bin" "${args[@]}" From 79dd3ee41e027a5a2b76860e13f4e694a70ee5f8 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:44:04 -0600 Subject: [PATCH 03/11] Decide GitHub Latest from the live alias under the rolling-alias lock The release job read the aliases job's output after that job had released the cross-run lock. If 3.0.2 moved latest, then 3.1.0 moved it on before 3.0.2's release step ran, 3.0.2 would still be marked Latest and GitHub would fall behind the registry. The release job now holds durable-workflow-server-rolling-aliases and reads the latest alias in both registries right before creating the release. A final is Latest only when both name its attested index. An unreadable alias stops the step before any write. It adds packages: read for GHCR. check-native-release-tags.sh covers forward 3.1.0 (Latest), a 3.0.2 backport behind 3.1.0, 3.0.2 released after 3.1.0 took latest, one registry behind, a missing or unreadable alias, a candidate and a rerun. It also checks that exactly aliases and release hold the lock. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-release.yml | 28 +++++--- scripts/ci/check-native-release-tags.sh | 73 ++++++++++++++------- scripts/ci/native-release-github-release.sh | 51 ++++++++++---- 3 files changed, 107 insertions(+), 45 deletions(-) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 11b603218..484876b03 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -370,8 +370,6 @@ jobs: permissions: contents: read packages: write - outputs: - rolling_tags: ${{ steps.rolling.outputs.rolling_tags }} steps: - name: Check out trusted release tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -395,7 +393,6 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Reconcile the rolling tags with the attested index - id: rolling env: RELEASE_TAG: ${{ inputs.tag }} INDEX_DIGEST: ${{ needs.publish.outputs.digest }} @@ -404,15 +401,21 @@ jobs: release: name: Create the GitHub Release - # Only after both registries publish and a final release's aliases move: - # the release page then never runs ahead of the images, and GitHub Latest - # follows the latest alias, so a backport never takes it. + # Only after both registries publish and a final release's aliases move, + # so the release page never runs ahead of the images. It holds the + # rolling-alias lock and marks a final Latest only when the live latest + # alias names its index, so GitHub Latest never moves behind the + # registry, whichever order concurrent releases finish in. needs: [plan, publish, aliases] if: github.ref == format('refs/tags/{0}', inputs.tag) runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 + concurrency: + group: durable-workflow-server-rolling-aliases + cancel-in-progress: false permissions: contents: write + packages: read steps: - name: Check out trusted release tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -420,6 +423,16 @@ jobs: ref: ${{ github.sha }} persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Create or confirm the release for this tag env: GH_TOKEN: ${{ github.token }} @@ -428,6 +441,5 @@ jobs: RELEASE_COMMIT: ${{ inputs.release_commit }} VERSION: ${{ needs.plan.outputs.version }} INDEX_DIGEST: ${{ needs.publish.outputs.digest }} - ROLLING_TAGS: ${{ needs.aliases.outputs.rolling_tags }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: bash scripts/ci/native-release-github-release.sh diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 8df00dceb..3988aaa04 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -125,41 +125,58 @@ grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written after want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "FAILED" grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written though an alias could not be read." expect "INDEX_DIGEST must be the verified index digest" RELEASE_TAG=native-3.0.0 "${images[@]}" RELEASE_IMAGE_KNOWN_TAGS=x bash scripts/ci/native-release-rolling-tags.sh -# GitHub Releases follow the latest alias: the release job marks a final -# Latest only when the rolling-tag job moved latest to it. Each case feeds the -# rolling-tag decision for the same state into the release step. +# GitHub Releases follow the latest alias: the release step, holding the +# rolling-alias lock, marks a final Latest only when the live latest alias +# names its index in both registries. LATEST_STATE lists "image digest" for +# latest aliases that exist; "unreadable" fails the lookup. cat > "$scratch/gh" <<'FAKE' #!/usr/bin/env bash if [[ "$1 $2" == "release view" ]]; then [[ -n "${EXISTING:-}" ]]; exit; fi printf '%s\n' "$*" >> "$GH_LOG" FAKE -chmod +x "$scratch/gh" -gh_release() { # release tag, rolling tags [, existing] -> the Latest choice +cat > "$scratch/latest-docker" <<'FAKE' +#!/usr/bin/env bash +ref=${!#} +digest=$(awk -v r="${ref%:latest}" '$1 == r { print $2 }' <<<"${LATEST_STATE:-}") +[[ -z "$digest" ]] && { echo "ERROR: $ref: not found" >&2; exit 1; } +[[ "$digest" == unreadable ]] && { echo "ERROR: unauthorized" >&2; exit 1; } +printf '%s\n' "$digest" +FAKE +chmod +x "$scratch/gh" "$scratch/latest-docker" +newer_index=sha256:$(printf '%064d' 0 | tr 0 b) +gh_release() { # release tag, latest state [, existing] -> the Latest choice : > "$scratch/gh_calls" - env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" RELEASE_TAG="$1" VERSION="${1#native-}" \ - RELEASE_COMMIT=$(printf '%040d' 0) INDEX_DIGEST="$index" RUN_URL=https://example.invalid/run \ - ROLLING_TAGS="$2" EXISTING="${3:-}" bash scripts/ci/native-release-github-release.sh >/dev/null 2>&1 || { echo FAILED; return; } + env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" DOCKER="$scratch/latest-docker" "${images[@]}" \ + RELEASE_TAG="$1" VERSION="${1#native-}" RELEASE_COMMIT=$(printf '%040d' 0) INDEX_DIGEST="$index" \ + RUN_URL=https://example.invalid/run LATEST_STATE="$2" EXISTING="${3:-}" \ + bash scripts/ci/native-release-github-release.sh >/dev/null 2>&1 || { echo FAILED; return; } grep -q -- '^release create ' "$scratch/gh_calls" || { echo none; return; } grep -q -- '--verify-tag --title ' "$scratch/gh_calls" || { echo FAILED; return; } tr '\n' ' ' < "$scratch/gh_calls" | grep -oE -- '--prerelease|--latest(=false)?( |$)' | tr -d ' ' | paste -sd' ' - } want_release() { test "$2" = "$3" || fail "$1: expected GitHub Release '$3', got '$2'."; } +ours=$'ghcr.io/example/native '"$index"$'\nexample/native '"$index" +theirs=$'ghcr.io/example/native '"$newer_index"$'\nexample/native '"$newer_index" +# Forward: 3.1.0 moved latest, so it is Latest. forward=$(rolling $'2.11.8\nnative-3.0.1\n3.0.2' 3.1.0 $'ghcr.io/example/native:latest 3.0.2\nexample/native:latest 3.0.2') want "forward 3.1.0" "$forward" "3.1 3 latest" -want_release "forward 3.1.0" "$(gh_release 3.1.0 "$forward")" "--latest" -backport_state=$'ghcr.io/example/native:3 3.1.0\nexample/native:3 3.1.0\nghcr.io/example/native:latest 3.1.0\nexample/native:latest 3.1.0' -backport=$(rolling $'2.11.8\nnative-3.0.1\n3.1.0' 3.0.2 "$backport_state") +want_release "forward 3.1.0" "$(gh_release 3.1.0 "$ours")" "--latest" +# Backport: 3.0.2 behind 3.1.0 leaves latest at 3.1.0, and GitHub Latest too. +backport=$(rolling $'2.11.8\nnative-3.0.1\n3.1.0' 3.0.2 $'ghcr.io/example/native:3 3.1.0\nexample/native:3 3.1.0\nghcr.io/example/native:latest 3.1.0\nexample/native:latest 3.1.0') want "3.0.2 backport behind 3.1.0" "$backport" "3.0" -want_release "3.0.2 backport behind 3.1.0" "$(gh_release 3.0.2 "$backport")" "--latest=false" -# The older release finishing last, after reading stale Git tags, still -# leaves Latest to the newer one. -stale=$(rolling $'2.11.8\nnative-3.0.1' 3.0.2 "$backport_state") -want "3.0.2 with stale tags behind 3.1.0" "$stale" "3.0" -want_release "3.0.2 with stale tags behind 3.1.0" "$(gh_release 3.0.2 "$stale")" "--latest=false" -want_release "backport that moves nothing" "$(gh_release 3.0.2 "")" "--latest=false" -want_release "candidate" "$(gh_release 3.1.0-rc.1 "")" "--prerelease --latest=false" -want_release "native- final that owns latest" "$(gh_release native-3.0.2 "3.0 3 latest")" "--latest" -want_release "rerun of an existing release" "$(gh_release 3.1.0 "3.1 3 latest" yes)" "none" +want_release "3.0.2 backport behind 3.1.0" "$(gh_release 3.0.2 "$theirs")" "--latest=false" +# Interleaved: 3.0.2 moved latest, then 3.1.0 moved it on before 3.0.2's +# release step ran. The live alias names 3.1.0, so 3.0.2 is not Latest. +interleaved=$(rolling $'2.11.8\nnative-3.0.1' 3.0.2) +want "3.0.2 before 3.1.0" "$interleaved" "3.0 3 latest" +want_release "3.0.2 released after 3.1.0 took latest" "$(gh_release 3.0.2 "$theirs")" "--latest=false" +# Both registries must name the index; one behind is not enough. +want_release "only GHCR names the index" "$(gh_release 3.0.2 $'ghcr.io/example/native '"$index"$'\nexample/native '"$newer_index")" "--latest=false" +want_release "no latest alias yet" "$(gh_release 3.0.2 "")" "--latest=false" +want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "FAILED" +want_release "candidate" "$(gh_release 3.1.0-rc.1 "$ours")" "--prerelease --latest=false" +want_release "native- final that owns latest" "$(gh_release native-3.0.2 "$ours")" "--latest" +want_release "rerun of an existing release" "$(gh_release 3.1.0 "$ours" yes)" "none" # Release identity: only a strict native- label admits a release image, and # only when its server reports exactly that version. @@ -249,10 +266,16 @@ test ! -s "$scratch/calls" || fail "the promoter wrote without a tag inventory." rm -f "$scratch/git" "$scratch/git-count" # Both workflows hold the one rolling-alias lock, and the PHP promoter takes # the resolver's inventory rather than reading the tags again. -for workflow in .github/workflows/release.yml .github/workflows/native-release.yml; do - test "$(grep -c '^ group: durable-workflow-server-rolling-aliases$' "$workflow")" = 1 \ - || fail "$workflow must hold the durable-workflow-server-rolling-aliases lock in one job." -done +# The native release step holds it too, so GitHub Latest is decided with no +# alias moving underneath it. +test "$(grep -c '^ group: durable-workflow-server-rolling-aliases$' .github/workflows/release.yml)" = 1 \ + || fail "release.yml must hold the durable-workflow-server-rolling-aliases lock in one job." +locked_jobs=$(awk ' + /^ [a-z0-9-]+:$/ { job = substr($1, 1, length($1) - 1) } + /^ group: durable-workflow-server-rolling-aliases$/ { print job } +' .github/workflows/native-release.yml | paste -sd' ' -) +test "$locked_jobs" = "aliases release" \ + || fail "native-release.yml must hold the rolling-alias lock in aliases and release, not '$locked_jobs'." grep -q 'RELEASE_IMAGE_KNOWN_TAGS: ${{ steps.rolling.outputs.release_inventory }}' .github/workflows/release.yml \ || fail "the PHP promoter must use the resolver's inventory." awk '/- name: Promote rolling image aliases/,/run: scripts\/ci\/promote-release-image-rolling-tags.sh/' .github/workflows/release.yml \ diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index 7b1b47088..f13389360 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -2,18 +2,24 @@ # Create the GitHub Release for a published native release tag (Server #325). # # GitHub Latest follows the registry: a final release is marked Latest only -# when the rolling-tag job moved (or confirmed) the latest alias to it, so a -# backport behind a newer release never takes Latest from it. A release -# candidate is a prerelease and never Latest. An existing release is left -# unchanged, so a rerun cannot move Latest either. +# when the latest alias names its attested index in both registries, read +# live right before the release is created. The caller holds the rolling +# alias lock across this step, so no alias moves between the read and the +# write; a backport behind a newer release, or a release whose alias a newer +# one has since taken, is created without Latest. An alias that cannot be +# read stops the step before any write. A release candidate is a prerelease +# and never Latest. An existing release is left unchanged, so a rerun cannot +# move Latest either. # -# Env: RELEASE_TAG, RELEASE_COMMIT, VERSION, INDEX_DIGEST, RUN_URL, and -# ROLLING_TAGS (the aliases the rolling-tag job moved, space separated). GH -# overrides the gh command. +# Env: RELEASE_TAG, RELEASE_COMMIT, VERSION, INDEX_DIGEST, RUN_URL, +# GHCR_IMAGE, DOCKERHUB_IMAGE. GH and DOCKER override the gh and docker +# commands. set -euo pipefail fail() { printf 'GitHub Release failed: %s\n' "$1" >&2; exit 1; } gh_bin=${GH:-gh} -: "${RELEASE_TAG:?}" "${RELEASE_COMMIT:?}" "${VERSION:?}" "${INDEX_DIGEST:?}" "${RUN_URL:?}" +docker_bin=${DOCKER:-docker} +: "${RELEASE_TAG:?}" "${RELEASE_COMMIT:?}" "${VERSION:?}" "${RUN_URL:?}" "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" +[[ "${INDEX_DIGEST:-}" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "INDEX_DIGEST must be the verified index digest." [[ "${RELEASE_TAG#native-}" == "$VERSION" ]] || fail "RELEASE_TAG $RELEASE_TAG does not name VERSION $VERSION." if "$gh_bin" release view "$RELEASE_TAG" >/dev/null 2>&1; then @@ -21,15 +27,36 @@ if "$gh_bin" release view "$RELEASE_TAG" >/dev/null 2>&1; then exit 0 fi -notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `durableworkflow/server` and `ghcr.io/durable-workflow/server` |\n| Release run | %s |\n' \ - "$VERSION" "$RELEASE_TAG" "$RELEASE_COMMIT" "$INDEX_DIGEST" "$RUN_URL") +# The digest the latest alias names now; empty when it does not exist. +latest_digest() { + local output + if ! output=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1:latest" 2>&1); then + grep -qiE 'not found|manifest unknown|name unknown' <<<"$output" && return 0 + fail "cannot read the current $1:latest: $(head -c 300 <<<"$output")" + fi + printf '%s\n' "$output" +} + +latest=false +if [[ "$VERSION" != *-* ]]; then + latest=true + for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do + current=$(latest_digest "$image") + if [[ "$current" != "$INDEX_DIGEST" ]]; then + printf '%s:latest names %s, not this release; GitHub Latest stays where it is.\n' "$image" "${current:-nothing}" + latest=false + fi + done +fi + +notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `%s` and `%s` |\n| Release run | %s |\n' \ + "$VERSION" "$RELEASE_TAG" "$RELEASE_COMMIT" "$INDEX_DIGEST" "$DOCKERHUB_IMAGE" "$GHCR_IMAGE" "$RUN_URL") args=(release create "$RELEASE_TAG" --verify-tag --title "$VERSION" --notes "$notes") if [[ "$VERSION" == *-* ]]; then args+=(--prerelease --latest=false) -elif [[ " ${ROLLING_TAGS:-} " == *" latest "* ]]; then +elif [[ "$latest" == true ]]; then args+=(--latest) else - printf 'Release %s does not own the latest alias; GitHub Latest stays where it is.\n' "$RELEASE_TAG" args+=(--latest=false) fi "$gh_bin" "${args[@]}" From 2e3d0ec18edf1104867b59f15a6a7f58f6ca7552 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:47:47 -0600 Subject: [PATCH 04/11] Run the GitHub Release job in the dockerhub-native Environment The contents: write job now runs behind the same tag-restricted Environment as every other publishing job, so the Environment guards its write token directly instead of relying on the jobs it depends on. Its scopes are unchanged. check-native-release-tags.sh fails if any job with a write scope runs outside dockerhub-native, and checks the release job by name. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-release.yml | 3 +++ scripts/ci/check-native-release-tags.sh | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 484876b03..dc8af3566 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -410,6 +410,9 @@ jobs: if: github.ref == format('refs/tags/{0}', inputs.tag) runs-on: ubuntu-latest timeout-minutes: 10 + # The tag-restricted Environment guards the write token, as for every + # publishing job. + environment: dockerhub-native concurrency: group: durable-workflow-server-rolling-aliases cancel-in-progress: false diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 3988aaa04..7f62e9108 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -276,6 +276,20 @@ locked_jobs=$(awk ' ' .github/workflows/native-release.yml | paste -sd' ' -) test "$locked_jobs" = "aliases release" \ || fail "native-release.yml must hold the rolling-alias lock in aliases and release, not '$locked_jobs'." +# Every job that can publish runs in the tag-restricted Environment: each +# job with a write scope, the GitHub Release included. +unprotected=$(awk ' + function flush() { if (job != "" && writes && env != "dockerhub-native") print job } + /^jobs:/ { jobs = 1; next } + jobs && /^ [a-z0-9-]+:$/ { flush(); job = substr($1, 1, length($1) - 1); writes = 0; env = ""; next } + jobs && /^ environment: / { env = $2 } + jobs && /^ [a-z-]+: write$/ { writes = 1 } + END { flush() } +' .github/workflows/native-release.yml | paste -sd' ' -) +test -z "$unprotected" || fail "native-release.yml jobs with a write scope outside dockerhub-native: $unprotected." +awk '/^ [a-z0-9-]+:$/ { job = $1 } job == "release:" { print }' .github/workflows/native-release.yml \ + | grep -q '^ environment: dockerhub-native$' \ + || fail "the GitHub Release job must run in dockerhub-native." grep -q 'RELEASE_IMAGE_KNOWN_TAGS: ${{ steps.rolling.outputs.release_inventory }}' .github/workflows/release.yml \ || fail "the PHP promoter must use the resolver's inventory." awk '/- name: Promote rolling image aliases/,/run: scripts\/ci\/promote-release-image-rolling-tags.sh/' .github/workflows/release.yml \ From ce0ec125fd1fb81db35c879299d1e4967412bcbb Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:57:29 -0600 Subject: [PATCH 05/11] Use if-blocks for the release tag refusals The Docker workflow-isolation classifier treats a `|| { ...; }` group as an unclassified command in a write-scoped publish job. Public Boundary failed on release.yml's new refusal step. Both tag refusals are now explicit `if ! [[ ... ]]; then ...; exit 1; fi` blocks, with the same messages and exit status. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-release.yml | 6 ++++-- .github/workflows/release.yml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index dc8af3566..5468b2f4b 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -96,8 +96,10 @@ jobs: set -euo pipefail # Plain X.Y.Z from major 3, above every PHP release; native- names # the releases tagged before plain tags. - [[ "$RELEASE_TAG" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]] \ - || { echo 'tag must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)' >&2; exit 1; } + if ! [[ "$RELEASE_TAG" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo 'tag must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)' >&2 + exit 1 + fi echo dockerhub=true >> "$GITHUB_OUTPUT" echo "version=${RELEASE_TAG#native-}" >> "$GITHUB_OUTPUT" if [[ "$RELEASE_TAG" == native-* ]]; then prefix=native-; else prefix=; fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7b94e30d..7a5aff6aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,8 +65,10 @@ jobs: set -euo pipefail # Pushed or dispatched, a PHP release is 2.x or earlier. A 3.x (or # later) or native- tag belongs to native-release.yml. - [[ "$REQUESTED_TAG" =~ ^[0-2]\.[0-9]+\.[0-9]+ ]] \ - || { printf 'tag %s is not a PHP Server release (2.x or earlier)\n' "$REQUESTED_TAG" >&2; exit 1; } + if ! [[ "$REQUESTED_TAG" =~ ^[0-2]\.[0-9]+\.[0-9]+ ]]; then + printf 'tag %s is not a PHP Server release (2.x or earlier)\n' "$REQUESTED_TAG" >&2 + exit 1 + fi - name: Check out trusted release tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 From ff306b126f196b2cdb9dc39140678dbcc7fa9e92 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:26:10 -0600 Subject: [PATCH 06/11] Create the GitHub Release from protected main, not the tag-ref run The central policy admits no contents: write job in the tag-ref dispatch, and Security and General declined to widen it. The release job leaves native-release.yml, which now holds no contents: write. native-github-release.yml is dispatched from main with the tag and the id of the native-release.yml run that published it. Its single job: - runs only from refs/heads/main; - runs in the main-only github-release Environment; - holds the rolling-alias lock; - is the only contents: write. native-release-github-release.sh verifies before writing: - the tag's commit (through an annotated tag) is on main; - the run is native-release.yml, workflow_dispatch, completed success, from that tag at that commit; - both registries' exact version tags name one index; - that index's provenance verifies with signer native-release.yml, source ref refs/tags/ and source digest , and names an attempt of exactly that run. Then Latest follows the live alias in both registries; candidates are prereleases; an existing Release is left alone. check-native-release-tags.sh covers: - forward, backport and interleaved Latest, one registry behind, no alias, candidate, native- final, annotated tag and rerun; - fail-closed cases: a PHP tag, no run id, commit off main, a non-commit tag, a failed run or one of another workflow, ref or commit, mismatched registries, unverifiable provenance, provenance from another run or a run-id prefix, an unreadable alias; - workflow structure: one main-only, dispatch-only job in github-release, and no contents: write in native-release.yml. Negative controls: a looser run-id match and a missing main gate both fail. The central qualification policy and Docker isolation checks pass. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-github-release.yml | 74 ++++++++++++ .github/workflows/native-release.yml | 53 +-------- scripts/ci/check-native-release-tags.sh | 124 +++++++++++++------- scripts/ci/native-release-github-release.sh | 100 ++++++++++++---- 4 files changed, 233 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/native-github-release.yml diff --git a/.github/workflows/native-github-release.yml b/.github/workflows/native-github-release.yml new file mode 100644 index 000000000..92fc5bcd8 --- /dev/null +++ b/.github/workflows/native-github-release.yml @@ -0,0 +1,74 @@ +name: Native GitHub Release + +# Creates the GitHub Release for a native release tag after its +# native-release.yml run has published the image (Server #325). That run is +# dispatched from the tag's own ref and holds no contents: write; this one +# runs only from protected main, in the main-only github-release +# Environment, and verifies what the run published before it writes: +# the tag's commit is on main, the run is native-release.yml dispatched from +# that tag at that commit and succeeded, both registries' exact version tags +# name one index, and that index's build provenance was signed by +# native-release.yml from the tag's ref at its commit in that run. +# +# The Release is titled with the plain version. A candidate is a prerelease; +# a final release is Latest only when the live latest alias names its index +# in both registries. The job holds the rolling-alias lock, so no alias +# moves between that read and the write. An existing Release is left as it +# is. +on: + workflow_dispatch: + inputs: + tag: + description: 'Published native release tag: X.Y.Z or X.Y.Z-rc.N from major 3, or a historical native-' + required: true + type: string + release_run_id: + description: 'Id of the successful native-release.yml run that published it' + required: true + type: string + +permissions: + contents: read + +env: + DOCKERHUB_IMAGE: durableworkflow/server + GHCR_IMAGE: ghcr.io/durable-workflow/server + +jobs: + release: + name: Create the GitHub Release + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: github-release + concurrency: + group: durable-workflow-server-rolling-aliases + cancel-in-progress: false + permissions: + contents: write + packages: read + actions: read + attestations: read + steps: + - name: Check out trusted release tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify the published release and create its GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + run: bash scripts/ci/native-release-github-release.sh diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 5468b2f4b..be1010e28 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -5,8 +5,9 @@ name: Native release candidate # before plain tags (Server #325). PHP Server releases are 2.x and earlier, and # the PHP Release workflow refuses any other tag, so a native tag never starts # a PHP release; the image tag is the version without any native- prefix. -# After both registries publish and a final release's aliases move, the -# Release job creates the GitHub Release (a candidate as a prerelease). Each architecture builds on its own runner and pushes by +# Its GitHub Release is created afterwards from protected main by +# native-github-release.yml, which verifies what this run published; this +# workflow holds no contents: write. Each architecture builds on its own runner and pushes by # digest only; that exact digest is pulled back and smoke-tested there, so a # failed smoke leaves no tag. The publish job then refuses tags that already # exist, gives both smoked digests @@ -400,51 +401,3 @@ jobs: INDEX_DIGEST: ${{ needs.publish.outputs.digest }} RELEASE_IMAGE_TAG_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git run: bash scripts/ci/native-release-rolling-tags.sh - - release: - name: Create the GitHub Release - # Only after both registries publish and a final release's aliases move, - # so the release page never runs ahead of the images. It holds the - # rolling-alias lock and marks a final Latest only when the live latest - # alias names its index, so GitHub Latest never moves behind the - # registry, whichever order concurrent releases finish in. - needs: [plan, publish, aliases] - if: github.ref == format('refs/tags/{0}', inputs.tag) - runs-on: ubuntu-latest - timeout-minutes: 10 - # The tag-restricted Environment guards the write token, as for every - # publishing job. - environment: dockerhub-native - concurrency: - group: durable-workflow-server-rolling-aliases - cancel-in-progress: false - permissions: - contents: write - packages: read - steps: - - name: Check out trusted release tooling - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - - - name: Log in to GHCR - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create or confirm the release for this tag - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ inputs.tag }} - RELEASE_COMMIT: ${{ inputs.release_commit }} - VERSION: ${{ needs.plan.outputs.version }} - INDEX_DIGEST: ${{ needs.publish.outputs.digest }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: bash scripts/ci/native-release-github-release.sh diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 7f62e9108..c0e9a4330 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -125,30 +125,52 @@ grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written after want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "FAILED" grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written though an alias could not be read." expect "INDEX_DIGEST must be the verified index digest" RELEASE_TAG=native-3.0.0 "${images[@]}" RELEASE_IMAGE_KNOWN_TAGS=x bash scripts/ci/native-release-rolling-tags.sh -# GitHub Releases follow the latest alias: the release step, holding the -# rolling-alias lock, marks a final Latest only when the live latest alias -# names its index in both registries. LATEST_STATE lists "image digest" for -# latest aliases that exist; "unreadable" fails the lookup. +# The GitHub Release, from protected main (native-github-release.yml): it +# verifies the tag, the run, both exact tags and the provenance, then marks +# a final Latest only when the live latest alias names its index in both +# registries. Fakes answer each read from the variables below. +commit=0123456789abcdef0123456789abcdef01234567 cat > "$scratch/gh" <<'FAKE' #!/usr/bin/env bash -if [[ "$1 $2" == "release view" ]]; then [[ -n "${EXISTING:-}" ]]; exit; fi -printf '%s\n' "$*" >> "$GH_LOG" +case "$1 $2" in + "release view") [[ -n "${EXISTING:-}" ]]; exit ;; + "release create") printf '%s\n' "$*" >> "$GH_LOG"; exit 0 ;; + "attestation verify") + printf '%s\n' "$*" > "$GH_ATTEST_LOG" + [[ "${ATTEST:-ok}" == ok ]] || exit 1 + printf '%s\n' "${ATTEST_URI:-https://github.com/example/server/actions/runs/${RUN_ID}/attempts/1}"; exit 0 ;; +esac +path=$2 +case "$path" in + repos/example/server/git/ref/tags/*) printf '%s\n' "${TAG_OBJECT:-commit $COMMIT}" ;; + repos/example/server/git/tags/*) printf 'commit %s\n' "$COMMIT" ;; + repos/example/server/compare/*) printf '%s\n' "${COMPARE:-ahead}" ;; + repos/example/server/actions/runs/*) printf '%s\n' "${RUN_LINE:-.github/workflows/native-release.yml workflow_dispatch completed success $RELEASE_TAG $COMMIT}" ;; + *) exit 1 ;; +esac FAKE -cat > "$scratch/latest-docker" <<'FAKE' +cat > "$scratch/release-docker" <<'FAKE' #!/usr/bin/env bash ref=${!#} -digest=$(awk -v r="${ref%:latest}" '$1 == r { print $2 }' <<<"${LATEST_STATE:-}") -[[ -z "$digest" ]] && { echo "ERROR: $ref: not found" >&2; exit 1; } -[[ "$digest" == unreadable ]] && { echo "ERROR: unauthorized" >&2; exit 1; } -printf '%s\n' "$digest" +if [[ "$ref" == *:latest ]]; then + digest=$(awk -v r="${ref%:latest}" '$1 == r { print $2 }' <<<"${LATEST_STATE:-}") + [[ -z "$digest" ]] && { echo "ERROR: $ref: not found" >&2; exit 1; } + [[ "$digest" == unreadable ]] && { echo "ERROR: unauthorized" >&2; exit 1; } + printf '%s\n' "$digest" +elif [[ "$ref" == example/native:* ]]; then + printf '%s\n' "${HUB_EXACT:-$INDEX}" +else + printf '%s\n' "${GHCR_EXACT:-$INDEX}" +fi FAKE -chmod +x "$scratch/gh" "$scratch/latest-docker" +chmod +x "$scratch/gh" "$scratch/release-docker" newer_index=sha256:$(printf '%064d' 0 | tr 0 b) -gh_release() { # release tag, latest state [, existing] -> the Latest choice - : > "$scratch/gh_calls" - env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" DOCKER="$scratch/latest-docker" "${images[@]}" \ - RELEASE_TAG="$1" VERSION="${1#native-}" RELEASE_COMMIT=$(printf '%040d' 0) INDEX_DIGEST="$index" \ - RUN_URL=https://example.invalid/run LATEST_STATE="$2" EXISTING="${3:-}" \ +gh_release() { # release tag, latest state [, VAR=value ...] -> the Latest choice + local tag=$1 state=$2; shift 2 + : > "$scratch/gh_calls"; : > "$scratch/gh_attest" + env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" GH_ATTEST_LOG="$scratch/gh_attest" \ + DOCKER="$scratch/release-docker" "${images[@]}" GITHUB_REPOSITORY=example/server \ + RELEASE_TAG="$tag" RELEASE_RUN_ID=41 RUN_ID=41 COMMIT="$commit" INDEX="$index" LATEST_STATE="$state" "$@" \ bash scripts/ci/native-release-github-release.sh >/dev/null 2>&1 || { echo FAILED; return; } grep -q -- '^release create ' "$scratch/gh_calls" || { echo none; return; } grep -q -- '--verify-tag --title ' "$scratch/gh_calls" || { echo FAILED; return; } @@ -157,26 +179,38 @@ gh_release() { # release tag, latest state [, existing] -> the Latest choice want_release() { test "$2" = "$3" || fail "$1: expected GitHub Release '$3', got '$2'."; } ours=$'ghcr.io/example/native '"$index"$'\nexample/native '"$index" theirs=$'ghcr.io/example/native '"$newer_index"$'\nexample/native '"$newer_index" -# Forward: 3.1.0 moved latest, so it is Latest. +# Latest follows the live alias. forward=$(rolling $'2.11.8\nnative-3.0.1\n3.0.2' 3.1.0 $'ghcr.io/example/native:latest 3.0.2\nexample/native:latest 3.0.2') want "forward 3.1.0" "$forward" "3.1 3 latest" want_release "forward 3.1.0" "$(gh_release 3.1.0 "$ours")" "--latest" -# Backport: 3.0.2 behind 3.1.0 leaves latest at 3.1.0, and GitHub Latest too. +grep -q -- "--source-ref refs/tags/3.1.0 --source-digest $commit" "$scratch/gh_attest" \ + || fail "the provenance must be checked against the tag ref and its commit." +grep -q -- "--signer-workflow github.com/example/server/.github/workflows/native-release.yml" "$scratch/gh_attest" \ + || fail "the provenance must name native-release.yml as its signer." backport=$(rolling $'2.11.8\nnative-3.0.1\n3.1.0' 3.0.2 $'ghcr.io/example/native:3 3.1.0\nexample/native:3 3.1.0\nghcr.io/example/native:latest 3.1.0\nexample/native:latest 3.1.0') want "3.0.2 backport behind 3.1.0" "$backport" "3.0" want_release "3.0.2 backport behind 3.1.0" "$(gh_release 3.0.2 "$theirs")" "--latest=false" -# Interleaved: 3.0.2 moved latest, then 3.1.0 moved it on before 3.0.2's -# release step ran. The live alias names 3.1.0, so 3.0.2 is not Latest. -interleaved=$(rolling $'2.11.8\nnative-3.0.1' 3.0.2) -want "3.0.2 before 3.1.0" "$interleaved" "3.0 3 latest" want_release "3.0.2 released after 3.1.0 took latest" "$(gh_release 3.0.2 "$theirs")" "--latest=false" -# Both registries must name the index; one behind is not enough. want_release "only GHCR names the index" "$(gh_release 3.0.2 $'ghcr.io/example/native '"$index"$'\nexample/native '"$newer_index")" "--latest=false" want_release "no latest alias yet" "$(gh_release 3.0.2 "")" "--latest=false" -want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "FAILED" want_release "candidate" "$(gh_release 3.1.0-rc.1 "$ours")" "--prerelease --latest=false" want_release "native- final that owns latest" "$(gh_release native-3.0.2 "$ours")" "--latest" -want_release "rerun of an existing release" "$(gh_release 3.1.0 "$ours" yes)" "none" +want_release "annotated tag" "$(gh_release 3.0.2 "$ours" TAG_OBJECT="tag 89abcdef0123456789abcdef0123456789abcdef")" "--latest" +want_release "rerun of an existing release" "$(gh_release 3.1.0 "$ours" EXISTING=yes)" "none" +# Every binding fails closed, before any write. +want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "FAILED" +want_release "a PHP tag" "$(gh_release 2.11.9 "$ours")" "FAILED" +want_release "no run id" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ID=)" "FAILED" +want_release "tag commit not on main" "$(gh_release 3.0.2 "$ours" COMPARE=diverged)" "FAILED" +want_release "tag names a tree" "$(gh_release 3.0.2 "$ours" TAG_OBJECT="tree $commit")" "FAILED" +want_release "run failed" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed failure 3.0.2 $commit")" "FAILED" +want_release "run of another workflow" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/release.yml workflow_dispatch completed success 3.0.2 $commit")" "FAILED" +want_release "run from another ref" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success main $commit")" "FAILED" +want_release "run at another commit" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success 3.0.2 fedcba9876543210fedcba9876543210fedcba98")" "FAILED" +want_release "registries name different indexes" "$(gh_release 3.0.2 "$ours" HUB_EXACT="$newer_index")" "FAILED" +want_release "provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST=fail)" "FAILED" +want_release "provenance from another run" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/42/attempts/1)" "FAILED" +want_release "provenance from a run id prefix" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/411/attempts/1)" "FAILED" # Release identity: only a strict native- label admits a release image, and # only when its server reports exactly that version. @@ -266,18 +300,14 @@ test ! -s "$scratch/calls" || fail "the promoter wrote without a tag inventory." rm -f "$scratch/git" "$scratch/git-count" # Both workflows hold the one rolling-alias lock, and the PHP promoter takes # the resolver's inventory rather than reading the tags again. -# The native release step holds it too, so GitHub Latest is decided with no -# alias moving underneath it. -test "$(grep -c '^ group: durable-workflow-server-rolling-aliases$' .github/workflows/release.yml)" = 1 \ - || fail "release.yml must hold the durable-workflow-server-rolling-aliases lock in one job." -locked_jobs=$(awk ' - /^ [a-z0-9-]+:$/ { job = substr($1, 1, length($1) - 1) } - /^ group: durable-workflow-server-rolling-aliases$/ { print job } -' .github/workflows/native-release.yml | paste -sd' ' -) -test "$locked_jobs" = "aliases release" \ - || fail "native-release.yml must hold the rolling-alias lock in aliases and release, not '$locked_jobs'." -# Every job that can publish runs in the tag-restricted Environment: each -# job with a write scope, the GitHub Release included. +# The GitHub Release job holds it too, so Latest is decided with no alias +# moving underneath it. +for workflow in .github/workflows/release.yml .github/workflows/native-release.yml .github/workflows/native-github-release.yml; do + test "$(grep -c '^ group: durable-workflow-server-rolling-aliases$' "$workflow")" = 1 \ + || fail "$workflow must hold the durable-workflow-server-rolling-aliases lock in one job." +done +# Every native-release.yml job that can publish runs in the tag-restricted +# Environment, and none may write releases. unprotected=$(awk ' function flush() { if (job != "" && writes && env != "dockerhub-native") print job } /^jobs:/ { jobs = 1; next } @@ -287,9 +317,16 @@ unprotected=$(awk ' END { flush() } ' .github/workflows/native-release.yml | paste -sd' ' -) test -z "$unprotected" || fail "native-release.yml jobs with a write scope outside dockerhub-native: $unprotected." -awk '/^ [a-z0-9-]+:$/ { job = $1 } job == "release:" { print }' .github/workflows/native-release.yml \ - | grep -q '^ environment: dockerhub-native$' \ - || fail "the GitHub Release job must run in dockerhub-native." +# The GitHub Release workflow: one job, from protected main only, in its own +# main-only Environment, the only contents: write in either workflow. +github_release=.github/workflows/native-github-release.yml +test "$(grep -c '^ [a-z0-9-]*:$' <(sed -n '/^jobs:/,$p' "$github_release"))" = 1 || fail "$github_release must have one job." +grep -q "^ if: github.ref == 'refs/heads/main'$" "$github_release" || fail "$github_release must run only from main." +grep -q '^ environment: github-release$' "$github_release" || fail "$github_release must run in the github-release Environment." +grep -q '^ workflow_dispatch:$' "$github_release" && test "$(sed -n '/^on:/,/^permissions:/p' "$github_release" | grep -c '^ [a-z_]*:$')" = 1 \ + || fail "$github_release must be triggered only by workflow_dispatch." +test "$(grep -c ' contents: write' "$github_release")" = 1 || fail "$github_release must hold contents: write once." +test "$(grep -c '^ *contents: write$' .github/workflows/native-release.yml)" = 0 || fail "native-release.yml must not hold contents: write." grep -q 'RELEASE_IMAGE_KNOWN_TAGS: ${{ steps.rolling.outputs.release_inventory }}' .github/workflows/release.yml \ || fail "the PHP promoter must use the resolver's inventory." awk '/- name: Promote rolling image aliases/,/run: scripts\/ci\/promote-release-image-rolling-tags.sh/' .github/workflows/release.yml \ @@ -331,8 +368,7 @@ expected_conditions="plan=none build-amd64=$tag_gate build-arm64=$tag_gate publish=$tag_gate -aliases=$tag_gate && needs.plan.outputs.dockerhub == 'true' -release=$tag_gate" +aliases=$tag_gate && needs.plan.outputs.dockerhub == 'true'" test "$job_conditions" = "$expected_conditions" \ || fail "native-release.yml job conditions changed; expected: $expected_conditions @@ -344,6 +380,4 @@ grep -q 'run: scripts/ci/verify-native-release-dispatch.sh' .github/workflows/na # the native Release job may write releases. grep -q 'name: Refuse a native release tag' .github/workflows/release.yml \ || fail "release.yml must refuse a 3.x or native- tag." -test "$(grep -c ' contents: write' .github/workflows/native-release.yml)" = 1 \ - || fail "only the native Release job may hold contents: write." printf 'Native release tags: %d release.yml pattern(s) checked; verifier, publisher and rolling tags behave as intended.\n' "${#patterns[@]}" diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index f13389360..66c363caf 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -1,32 +1,86 @@ #!/usr/bin/env bash -# Create the GitHub Release for a published native release tag (Server #325). +# Create the GitHub Release for a published native release (Server #325), +# from protected main after the tag's release run has finished. # +# The release run itself holds no contents: write; this step, run from main +# in its own Environment, verifies what that run published before it +# writes. It binds independently: +# - the tag to its commit, which protected main must contain; +# - the run: native-release.yml, dispatched from that tag at that commit, +# completed successfully; +# - the image: both registries' exact version tags name one index, whose +# build-provenance attestation was signed by native-release.yml from that +# tag's ref at that commit, in that run. # GitHub Latest follows the registry: a final release is marked Latest only -# when the latest alias names its attested index in both registries, read -# live right before the release is created. The caller holds the rolling -# alias lock across this step, so no alias moves between the read and the -# write; a backport behind a newer release, or a release whose alias a newer -# one has since taken, is created without Latest. An alias that cannot be -# read stops the step before any write. A release candidate is a prerelease -# and never Latest. An existing release is left unchanged, so a rerun cannot -# move Latest either. +# when the latest alias names its index in both registries, read live right +# before the release is created. The caller holds the rolling-alias lock +# across this step, so no alias moves between the read and the write. A +# release candidate is a prerelease and never Latest. An existing release is +# left unchanged, so a rerun cannot move Latest either. Any failed read +# stops the step before it writes. # -# Env: RELEASE_TAG, RELEASE_COMMIT, VERSION, INDEX_DIGEST, RUN_URL, -# GHCR_IMAGE, DOCKERHUB_IMAGE. GH and DOCKER override the gh and docker -# commands. +# Env: RELEASE_TAG, RELEASE_RUN_ID, GITHUB_REPOSITORY, GHCR_IMAGE, +# DOCKERHUB_IMAGE. GH and DOCKER override the gh and docker commands. set -euo pipefail fail() { printf 'GitHub Release failed: %s\n' "$1" >&2; exit 1; } gh_bin=${GH:-gh} docker_bin=${DOCKER:-docker} -: "${RELEASE_TAG:?}" "${RELEASE_COMMIT:?}" "${VERSION:?}" "${RUN_URL:?}" "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" -[[ "${INDEX_DIGEST:-}" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "INDEX_DIGEST must be the verified index digest." -[[ "${RELEASE_TAG#native-}" == "$VERSION" ]] || fail "RELEASE_TAG $RELEASE_TAG does not name VERSION $VERSION." +: "${GITHUB_REPOSITORY:?}" "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" +repo=$GITHUB_REPOSITORY +workflow=.github/workflows/native-release.yml -if "$gh_bin" release view "$RELEASE_TAG" >/dev/null 2>&1; then - printf 'Release %s already exists; left unchanged.\n' "$RELEASE_TAG" +# A plain tag is native only from major 3; native- names the earlier ones. +tag=${RELEASE_TAG:-} +if ! [[ "$tag" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + fail "RELEASE_TAG must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)." +fi +version=${tag#native-} +run_id=${RELEASE_RUN_ID:-} +[[ "$run_id" =~ ^[1-9][0-9]*$ ]] || fail "RELEASE_RUN_ID must be the release run's numeric id." + +if "$gh_bin" release view "$tag" --repo "$repo" >/dev/null 2>&1; then + printf 'Release %s already exists; left unchanged.\n' "$tag" exit 0 fi +# The tag's commit, through an annotated tag object if there is one. +object=$("$gh_bin" api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha') \ + || fail "cannot read tag $tag." +if [[ "$object" == tag\ * ]]; then + object=$("$gh_bin" api "repos/$repo/git/tags/${object#tag }" --jq '.object.type + " " + .object.sha') \ + || fail "cannot read the tag object of $tag." +fi +[[ "$object" =~ ^commit\ ([0-9a-f]{40})$ ]] || fail "tag $tag does not name a commit ($object)." +commit=${BASH_REMATCH[1]} +status=$("$gh_bin" api "repos/$repo/compare/$commit...main" --jq .status) || fail "cannot compare $commit with main." +[[ "$status" == ahead || "$status" == identical ]] || fail "$commit is not on main (compare status: $status)." + +# The release run. +run=$("$gh_bin" api "repos/$repo/actions/runs/$run_id" \ + --jq '[.path, .event, .status, .conclusion, .head_branch, .head_sha] | join(" ")') \ + || fail "cannot read run $run_id." +expected="$workflow workflow_dispatch completed success $tag $commit" +[[ "$run" == "$expected" ]] || fail "run $run_id is '$run', not '$expected'." + +# Both exact tags name one index. +digest() { + "$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1" || fail "cannot read $1." +} +index=$(digest "$GHCR_IMAGE:$version") +[[ "$index" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "$GHCR_IMAGE:$version has no index digest ('$index')." +hub=$(digest "$DOCKERHUB_IMAGE:$version") +[[ "$hub" == "$index" ]] || fail "$DOCKERHUB_IMAGE:$version is $hub, not $index." + +# Its provenance: signed by the release workflow from the tag, at the +# commit, in this run. +invocations=$("$gh_bin" attestation verify "oci://$GHCR_IMAGE@$index" --repo "$repo" \ + --signer-workflow "github.com/$repo/$workflow" --source-ref "refs/tags/$tag" --source-digest "$commit" \ + --deny-self-hosted-runners --format json \ + --jq '.[].verificationResult.signature.certificate.runInvocationURI') \ + || fail "the provenance of $index does not verify for $tag at $commit." +grep -qE "^https://github\.com/$repo/actions/runs/$run_id/attempts/[0-9]+$" <<<"$invocations" \ + || fail "the provenance of $index names no attempt of run $run_id." + # The digest the latest alias names now; empty when it does not exist. latest_digest() { local output @@ -38,21 +92,21 @@ latest_digest() { } latest=false -if [[ "$VERSION" != *-* ]]; then +if [[ "$version" != *-* ]]; then latest=true for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do current=$(latest_digest "$image") - if [[ "$current" != "$INDEX_DIGEST" ]]; then + if [[ "$current" != "$index" ]]; then printf '%s:latest names %s, not this release; GitHub Latest stays where it is.\n' "$image" "${current:-nothing}" latest=false fi done fi -notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `%s` and `%s` |\n| Release run | %s |\n' \ - "$VERSION" "$RELEASE_TAG" "$RELEASE_COMMIT" "$INDEX_DIGEST" "$DOCKERHUB_IMAGE" "$GHCR_IMAGE" "$RUN_URL") -args=(release create "$RELEASE_TAG" --verify-tag --title "$VERSION" --notes "$notes") -if [[ "$VERSION" == *-* ]]; then +notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `%s` and `%s` |\n| Release run | https://github.com/%s/actions/runs/%s |\n' \ + "$version" "$tag" "$commit" "$index" "$DOCKERHUB_IMAGE" "$GHCR_IMAGE" "$repo" "$run_id") +args=(release create "$tag" --repo "$repo" --verify-tag --title "$version" --notes "$notes") +if [[ "$version" == *-* ]]; then args+=(--prerelease --latest=false) elif [[ "$latest" == true ]]; then args+=(--latest) From 448e5334fbc228f42ffc4568594b3b44da80c024 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:02:43 -0600 Subject: [PATCH 07/11] GitHub Release: verify an existing Release after every binding An existing Release used to end the step with success before the tag, run, index or provenance was checked. That made the workflow green for a stale or hand-made Release that needn't match the run. The existing-Release check now runs after all bindings. It succeeds only when the Release records exactly this verified release: tag, plain title, candidate state, index digest, commit and run link. It never edits the Release, so a rerun still cannot move Latest; any other existing Release is refused. check-native-release-tags.sh accepts reruns of the same final and candidate. It refuses an existing Release of another run, index or commit, with another title, or a final marked prerelease. It also checks that an existing Release does not excuse unverifiable provenance or a commit off main. Negative control: restoring the early exit fails. Co-Authored-By: Claude Opus 5.5 --- scripts/ci/check-native-release-tags.sh | 19 ++++++++++++-- scripts/ci/native-release-github-release.sh | 29 +++++++++++++++------ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index c0e9a4330..1d602ad0c 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -133,7 +133,7 @@ commit=0123456789abcdef0123456789abcdef01234567 cat > "$scratch/gh" <<'FAKE' #!/usr/bin/env bash case "$1 $2" in - "release view") [[ -n "${EXISTING:-}" ]]; exit ;; + "release view") [[ -n "${EXISTING:-}" ]] || exit 1; printf '%s\n' "$EXISTING"; exit 0 ;; "release create") printf '%s\n' "$*" >> "$GH_LOG"; exit 0 ;; "attestation verify") printf '%s\n' "$*" > "$GH_ATTEST_LOG" @@ -196,7 +196,22 @@ want_release "no latest alias yet" "$(gh_release 3.0.2 "")" "--latest=false" want_release "candidate" "$(gh_release 3.1.0-rc.1 "$ours")" "--prerelease --latest=false" want_release "native- final that owns latest" "$(gh_release native-3.0.2 "$ours")" "--latest" want_release "annotated tag" "$(gh_release 3.0.2 "$ours" TAG_OBJECT="tag 89abcdef0123456789abcdef0123456789abcdef")" "--latest" -want_release "rerun of an existing release" "$(gh_release 3.1.0 "$ours" EXISTING=yes)" "none" +# An existing Release is checked after every binding: one recording exactly +# this verified release is left unchanged; any other is refused unedited. +existing() { # tag, title, prerelease, index, commit, run + jq -cn --arg tag "$1" --arg title "$2" --argjson pre "$3" --arg body "index $4 commit $5 run https://github.com/example/server/actions/runs/$6" \ + '{tagName:$tag,name:$title,isPrerelease:$pre,body:$body}' +} +want_release "rerun of the same release" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 false "$index" "$commit" 41)")" "none" +want_release "rerun of the same candidate" "$(gh_release 3.1.0-rc.1 "$ours" EXISTING="$(existing 3.1.0-rc.1 3.1.0-rc.1 true "$index" "$commit" 41)")" "none" +want_release "existing release of another run" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 false "$index" "$commit" 42)")" "FAILED" +want_release "existing release of another index" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 false "$newer_index" "$commit" 41)")" "FAILED" +want_release "existing release of another commit" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 false "$index" fedcba9876543210fedcba9876543210fedcba98 41)")" "FAILED" +want_release "existing release with another title" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 native-3.1.0 false "$index" "$commit" 41)")" "FAILED" +want_release "existing final marked prerelease" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 true "$index" "$commit" 41)")" "FAILED" +# The bindings come first: an existing Release does not excuse a failed one. +want_release "existing release, unverifiable provenance" "$(gh_release 3.1.0 "$ours" ATTEST=fail EXISTING="$(existing 3.1.0 3.1.0 false "$index" "$commit" 41)")" "FAILED" +want_release "existing release, commit off main" "$(gh_release 3.1.0 "$ours" COMPARE=diverged EXISTING="$(existing 3.1.0 3.1.0 false "$index" "$commit" 41)")" "FAILED" # Every binding fails closed, before any write. want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "FAILED" want_release "a PHP tag" "$(gh_release 2.11.9 "$ours")" "FAILED" diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index 66c363caf..24ed9726f 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -15,9 +15,10 @@ # when the latest alias names its index in both registries, read live right # before the release is created. The caller holds the rolling-alias lock # across this step, so no alias moves between the read and the write. A -# release candidate is a prerelease and never Latest. An existing release is -# left unchanged, so a rerun cannot move Latest either. Any failed read -# stops the step before it writes. +# release candidate is a prerelease and never Latest. An existing Release +# that records exactly this verified release is left unchanged, so a rerun +# cannot move Latest either; any other existing Release is refused. Any +# failed read stops the step before it writes. # # Env: RELEASE_TAG, RELEASE_RUN_ID, GITHUB_REPOSITORY, GHCR_IMAGE, # DOCKERHUB_IMAGE. GH and DOCKER override the gh and docker commands. @@ -38,11 +39,6 @@ version=${tag#native-} run_id=${RELEASE_RUN_ID:-} [[ "$run_id" =~ ^[1-9][0-9]*$ ]] || fail "RELEASE_RUN_ID must be the release run's numeric id." -if "$gh_bin" release view "$tag" --repo "$repo" >/dev/null 2>&1; then - printf 'Release %s already exists; left unchanged.\n' "$tag" - exit 0 -fi - # The tag's commit, through an annotated tag object if there is one. object=$("$gh_bin" api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha') \ || fail "cannot read tag $tag." @@ -81,6 +77,23 @@ invocations=$("$gh_bin" attestation verify "oci://$GHCR_IMAGE@$index" --repo "$r grep -qE "^https://github\.com/$repo/actions/runs/$run_id/attempts/[0-9]+$" <<<"$invocations" \ || fail "the provenance of $index names no attempt of run $run_id." +# An existing Release is accepted only when its public record is this +# verified release: tag, plain title, candidate state, index, commit and run. +# It is never edited, so a rerun cannot move Latest; any other Release stops +# the step. +if existing=$("$gh_bin" release view "$tag" --repo "$repo" --json tagName,name,isPrerelease,body 2>/dev/null); then + candidate=false + [[ "$version" == *-* ]] && candidate=true + jq -e --arg tag "$tag" --arg title "$version" --argjson candidate "$candidate" --arg index "$index" --arg commit "$commit" \ + --arg run "https://github.com/$repo/actions/runs/$run_id" ' + .tagName == $tag and .name == $title and .isPrerelease == $candidate + and (.body | contains($index)) and (.body | contains($commit)) and (.body | contains($run))' \ + <<<"$existing" >/dev/null \ + || fail "Release $tag exists but does not record this verified release (tag, title, candidate state, index, commit and run)." + printf 'Release %s already records this verified release; left unchanged.\n' "$tag" + exit 0 +fi + # The digest the latest alias names now; empty when it does not exist. latest_digest() { local output From 56dcda85d76dfc1ea828dfd6cfd343346e79a076 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:15:18 -0600 Subject: [PATCH 08/11] GitHub Release: an existing Release must be exactly this one Security's review of 3b3336d9: the existing-Release check ignored draft and Latest state, and accepted any body that merely contained the index, commit and run. A draft or ambiguous public record would pass. The check now runs after the notes and the Latest decision. It accepts only a published (not draft) Release with this tag, plain title and candidate state, whose body is exactly the notes this step would write. releases/latest must name it exactly when the live alias owns latest, and must not otherwise, read under the same lock. Anything else is refused unedited. check-native-release-tags.sh captures the notes the step writes and accepts reruns of the same final (Latest), candidate and backport (not Latest). It refuses: - a draft; - other or ambiguous notes; - another title; - a final marked prerelease; - a final the alias owns that isn't Latest; - a backport marked Latest. An existing Release still never excuses a failed binding. Negative control: dropping the Latest check fails. Co-Authored-By: Claude Opus 5.5 --- scripts/ci/check-native-release-tags.sh | 43 +++++++++++++-------- scripts/ci/native-release-github-release.sh | 37 ++++++++++-------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 1d602ad0c..ce59c4a18 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -134,7 +134,10 @@ cat > "$scratch/gh" <<'FAKE' #!/usr/bin/env bash case "$1 $2" in "release view") [[ -n "${EXISTING:-}" ]] || exit 1; printf '%s\n' "$EXISTING"; exit 0 ;; - "release create") printf '%s\n' "$*" >> "$GH_LOG"; exit 0 ;; + "release create") + printf '%s\n' "$*" >> "$GH_LOG" + while [[ $# -gt 0 && "$1" != --notes ]]; do shift; done + printf '%s' "${2:-}" > "$GH_NOTES"; exit 0 ;; "attestation verify") printf '%s\n' "$*" > "$GH_ATTEST_LOG" [[ "${ATTEST:-ok}" == ok ]] || exit 1 @@ -145,6 +148,7 @@ case "$path" in repos/example/server/git/ref/tags/*) printf '%s\n' "${TAG_OBJECT:-commit $COMMIT}" ;; repos/example/server/git/tags/*) printf 'commit %s\n' "$COMMIT" ;; repos/example/server/compare/*) printf '%s\n' "${COMPARE:-ahead}" ;; + repos/example/server/releases/latest) printf '%s\n' "${LATEST_TAG:-}" ;; repos/example/server/actions/runs/*) printf '%s\n' "${RUN_LINE:-.github/workflows/native-release.yml workflow_dispatch completed success $RELEASE_TAG $COMMIT}" ;; *) exit 1 ;; esac @@ -168,7 +172,7 @@ newer_index=sha256:$(printf '%064d' 0 | tr 0 b) gh_release() { # release tag, latest state [, VAR=value ...] -> the Latest choice local tag=$1 state=$2; shift 2 : > "$scratch/gh_calls"; : > "$scratch/gh_attest" - env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" GH_ATTEST_LOG="$scratch/gh_attest" \ + env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" GH_ATTEST_LOG="$scratch/gh_attest" GH_NOTES="$scratch/gh_notes" \ DOCKER="$scratch/release-docker" "${images[@]}" GITHUB_REPOSITORY=example/server \ RELEASE_TAG="$tag" RELEASE_RUN_ID=41 RUN_ID=41 COMMIT="$commit" INDEX="$index" LATEST_STATE="$state" "$@" \ bash scripts/ci/native-release-github-release.sh >/dev/null 2>&1 || { echo FAILED; return; } @@ -196,22 +200,29 @@ want_release "no latest alias yet" "$(gh_release 3.0.2 "")" "--latest=false" want_release "candidate" "$(gh_release 3.1.0-rc.1 "$ours")" "--prerelease --latest=false" want_release "native- final that owns latest" "$(gh_release native-3.0.2 "$ours")" "--latest" want_release "annotated tag" "$(gh_release 3.0.2 "$ours" TAG_OBJECT="tag 89abcdef0123456789abcdef0123456789abcdef")" "--latest" -# An existing Release is checked after every binding: one recording exactly -# this verified release is left unchanged; any other is refused unedited. -existing() { # tag, title, prerelease, index, commit, run - jq -cn --arg tag "$1" --arg title "$2" --argjson pre "$3" --arg body "index $4 commit $5 run https://github.com/example/server/actions/runs/$6" \ - '{tagName:$tag,name:$title,isPrerelease:$pre,body:$body}' +# An existing Release is checked after every binding and the Latest +# decision: only exactly the Release this step would publish, with Latest +# where the live alias puts it, is left unchanged; anything else is refused. +gh_release 3.1.0 "$ours" >/dev/null; notes_final=$(cat "$scratch/gh_notes") +gh_release 3.1.0-rc.1 "$ours" >/dev/null; notes_candidate=$(cat "$scratch/gh_notes") +gh_release 3.0.2 "$theirs" >/dev/null; notes_backport=$(cat "$scratch/gh_notes") +existing() { # tag, title, draft, prerelease, notes + jq -cn --arg tag "$1" --arg title "$2" --argjson draft "$3" --argjson pre "$4" --arg body "$5" \ + '{tagName:$tag,name:$title,isDraft:$draft,isPrerelease:$pre,body:$body}' } -want_release "rerun of the same release" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 false "$index" "$commit" 41)")" "none" -want_release "rerun of the same candidate" "$(gh_release 3.1.0-rc.1 "$ours" EXISTING="$(existing 3.1.0-rc.1 3.1.0-rc.1 true "$index" "$commit" 41)")" "none" -want_release "existing release of another run" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 false "$index" "$commit" 42)")" "FAILED" -want_release "existing release of another index" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 false "$newer_index" "$commit" 41)")" "FAILED" -want_release "existing release of another commit" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 false "$index" fedcba9876543210fedcba9876543210fedcba98 41)")" "FAILED" -want_release "existing release with another title" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 native-3.1.0 false "$index" "$commit" 41)")" "FAILED" -want_release "existing final marked prerelease" "$(gh_release 3.1.0 "$ours" EXISTING="$(existing 3.1.0 3.1.0 true "$index" "$commit" 41)")" "FAILED" +want_release "rerun of the same final, Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "none" +want_release "rerun of the same candidate" "$(gh_release 3.1.0-rc.1 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0-rc.1 3.1.0-rc.1 false true "$notes_candidate")")" "none" +want_release "rerun of the same backport, not Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.1.0 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "none" +want_release "existing draft" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 true false "$notes_final")")" "FAILED" +want_release "existing release with other notes" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final extra")")" "FAILED" +want_release "existing notes naming everything but ambiguous" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$index $commit https://github.com/example/server/actions/runs/41")")" "FAILED" +want_release "existing release with another title" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 native-3.1.0 false false "$notes_final")")" "FAILED" +want_release "existing final marked prerelease" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false true "$notes_final")")" "FAILED" +want_release "existing final the alias owns but not Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" +want_release "existing backport marked Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.0.2 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "FAILED" # The bindings come first: an existing Release does not excuse a failed one. -want_release "existing release, unverifiable provenance" "$(gh_release 3.1.0 "$ours" ATTEST=fail EXISTING="$(existing 3.1.0 3.1.0 false "$index" "$commit" 41)")" "FAILED" -want_release "existing release, commit off main" "$(gh_release 3.1.0 "$ours" COMPARE=diverged EXISTING="$(existing 3.1.0 3.1.0 false "$index" "$commit" 41)")" "FAILED" +want_release "existing release, unverifiable provenance" "$(gh_release 3.1.0 "$ours" ATTEST=fail LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" +want_release "existing release, commit off main" "$(gh_release 3.1.0 "$ours" COMPARE=diverged LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" # Every binding fails closed, before any write. want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "FAILED" want_release "a PHP tag" "$(gh_release 2.11.9 "$ours")" "FAILED" diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index 24ed9726f..8fbdd298d 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -77,22 +77,6 @@ invocations=$("$gh_bin" attestation verify "oci://$GHCR_IMAGE@$index" --repo "$r grep -qE "^https://github\.com/$repo/actions/runs/$run_id/attempts/[0-9]+$" <<<"$invocations" \ || fail "the provenance of $index names no attempt of run $run_id." -# An existing Release is accepted only when its public record is this -# verified release: tag, plain title, candidate state, index, commit and run. -# It is never edited, so a rerun cannot move Latest; any other Release stops -# the step. -if existing=$("$gh_bin" release view "$tag" --repo "$repo" --json tagName,name,isPrerelease,body 2>/dev/null); then - candidate=false - [[ "$version" == *-* ]] && candidate=true - jq -e --arg tag "$tag" --arg title "$version" --argjson candidate "$candidate" --arg index "$index" --arg commit "$commit" \ - --arg run "https://github.com/$repo/actions/runs/$run_id" ' - .tagName == $tag and .name == $title and .isPrerelease == $candidate - and (.body | contains($index)) and (.body | contains($commit)) and (.body | contains($run))' \ - <<<"$existing" >/dev/null \ - || fail "Release $tag exists but does not record this verified release (tag, title, candidate state, index, commit and run)." - printf 'Release %s already records this verified release; left unchanged.\n' "$tag" - exit 0 -fi # The digest the latest alias names now; empty when it does not exist. latest_digest() { @@ -118,6 +102,27 @@ fi notes=$(printf 'Durable Workflow Server %s.\n\n| | |\n| --- | --- |\n| Git tag | `%s` → `%s` |\n| Image index | `%s` in `%s` and `%s` |\n| Release run | https://github.com/%s/actions/runs/%s |\n' \ "$version" "$tag" "$commit" "$index" "$DOCKERHUB_IMAGE" "$GHCR_IMAGE" "$repo" "$run_id") +# An existing Release is accepted only when it is exactly the Release this +# step would publish: published (not a draft), tag, plain title, candidate +# state and these exact notes, and Latest exactly when the live alias says +# so, read under the same lock. It is never edited, so a rerun cannot move +# Latest; any other Release stops the step. +if existing=$("$gh_bin" release view "$tag" --repo "$repo" --json tagName,name,isDraft,isPrerelease,body 2>/dev/null); then + candidate=false + [[ "$version" == *-* ]] && candidate=true + jq -e --arg tag "$tag" --arg title "$version" --argjson candidate "$candidate" --arg notes "$notes" ' + .tagName == $tag and .name == $title and .isDraft == false and .isPrerelease == $candidate + and ((.body // "") | sub("\\s+$"; "")) == ($notes | sub("\\s+$"; ""))' \ + <<<"$existing" >/dev/null \ + || fail "Release $tag exists but is not exactly this verified release (draft, tag, title, candidate state or notes)." + current=$("$gh_bin" api "repos/$repo/releases/latest" --jq .tag_name) || fail "cannot read the Latest Release." + if [[ "$latest" == true && "$current" != "$tag" ]] || [[ "$latest" != true && "$current" == "$tag" ]]; then + fail "Release $tag exists, but Latest is $current where the latest alias calls for $([[ "$latest" == true ]] && echo "$tag" || echo "another release")." + fi + printf 'Release %s is exactly this verified release; left unchanged.\n' "$tag" + exit 0 +fi + args=(release create "$tag" --repo "$repo" --verify-tag --title "$version" --notes "$notes") if [[ "$version" == *-* ]]; then args+=(--prerelease --latest=false) From 8286779811d212a53e2be0ace2e34efc79df1867 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:47:16 -0600 Subject: [PATCH 09/11] Release commit names its own image as the published Compose default General found that docker-compose.published.yml at each release tag defaulted to the previous release's image. native-3.0.2's file installs 3.0.1, and native-3.0.1's installs 3.0.0. - verify-native-release-dispatch.sh refuses to dispatch a final release whose docker-compose.published.yml, at the release commit, doesn't default to durableworkflow/server:. A candidate leaves the default on the last final release. - docker-compose.published.yml now defaults to 3.0.3, the release this commit is for, and says how the default moves. - The published Compose smoke, on a pull request whose default names a release not yet on Docker Hub (only between a release commit and its publication), smokes the file with durableworkflow/server:latest and says so. Any other unreadable default still fails. check-native-release-tags.sh gives each dispatch case a fixture Compose file. It refuses a default naming another release, no default and a missing file. It accepts a candidate, and checks the repository's file names one exact release. Negative control: dropping the gate fails. The smoke's selector was dry-run against Docker Hub: 3.0.3 (404) falls back to latest, and 3.0.2 (200) keeps the default. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/published-compose.yml | 16 ++++++++++++++ docker-compose.published.yml | 6 ++++-- scripts/ci/check-native-release-tags.sh | 22 ++++++++++++++++++-- scripts/ci/verify-native-release-dispatch.sh | 11 ++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/workflows/published-compose.yml b/.github/workflows/published-compose.yml index 4fc4c2b4c..0f5945812 100644 --- a/.github/workflows/published-compose.yml +++ b/.github/workflows/published-compose.yml @@ -57,6 +57,22 @@ jobs: run: | if [ -n "$INPUT_SERVER_IMAGE" ]; then echo "DW_SERVER_IMAGE=$INPUT_SERVER_IMAGE" >> "$GITHUB_ENV" + exit 0 + fi + # A release commit moves the default to the release it will be + # tagged as, before that image is published. Only then, a pull + # request smokes the Compose file with the current latest release; + # any other image that cannot be read still fails the smoke. + default="$(sed -n 's/^x-server-image: &server-image \${DW_SERVER_IMAGE:-\(.*\)}$/\1/p' docker-compose.published.yml)" + if [[ "$default" =~ ^durableworkflow/server:([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + status="$(curl -sS -o /dev/null -w '%{http_code}' "https://hub.docker.com/v2/repositories/durableworkflow/server/tags/${BASH_REMATCH[1]}")" + if [ "$status" = 404 ]; then + echo "::notice::$default is not published yet; smoking the Compose file with durableworkflow/server:latest." + echo "DW_SERVER_IMAGE=durableworkflow/server:latest" >> "$GITHUB_ENV" + elif [ "$status" != 200 ]; then + echo "cannot tell whether $default is published (HTTP $status)" >&2 + exit 1 + fi fi - name: Smoke published Compose stack diff --git a/docker-compose.published.yml b/docker-compose.published.yml index 356960ec1..948e8a82d 100644 --- a/docker-compose.published.yml +++ b/docker-compose.published.yml @@ -11,10 +11,12 @@ name: durable-workflow-server # server's Compose file is docker-compose.php.yml; to keep running PHP, use # that file (or pin a 2.x release of this one). # -# The default image is the current native release. Set DW_SERVER_IMAGE to run +# The default image is the native release this commit is tagged as: a final +# release's commit moves it (verify-native-release-dispatch.sh refuses a +# release whose default names another image). Set DW_SERVER_IMAGE to run # any other native image, by tag or digest; pin a digest for production # change control. -x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:3.0.1} +x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:3.0.3} services: server: diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index ce59c4a18..0f8d1d6b5 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -362,9 +362,15 @@ dispatch=scripts/ci/verify-native-release-dispatch.sh dispatch_scratch=$(mktemp -d) printf '#!/bin/sh\necho "${COMPARE_STATUS:-ahead}"\n' > "$dispatch_scratch/gh" chmod +x "$dispatch_scratch/gh" +# A final release's commit names its own image as the published Compose +# default; each case reads a fixture of that file. +compose_for() { # version -> a docker-compose.published.yml naming it + printf 'x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:%s}\n' "$1" > "$dispatch_scratch/compose-$1.yml" + printf '%s' "$dispatch_scratch/compose-$1.yml" +} commit=0123456789abcdef0123456789abcdef01234567 other=fedcba9876543210fedcba9876543210fedcba98 -ok_dispatch=(RELEASE_TAG=native-3.0.2 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh") +ok_dispatch=(RELEASE_TAG=native-3.0.2 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh" COMPOSE_FILE="$(compose_for 3.0.2)") expect "dispatch from refs/tags/native-3.0.2, not refs/heads/main" "${ok_dispatch[@]}" DISPATCH_REF=refs/heads/main bash "$dispatch" expect "dispatch from refs/tags/native-3.0.2, not refs/tags/native-3.0.1" "${ok_dispatch[@]}" DISPATCH_REF=refs/tags/native-3.0.1 bash "$dispatch" expect "is not RELEASE_COMMIT" RELEASE_TAG=native-3.0.2 RELEASE_COMMIT=$commit DISPATCH_SHA=$other DISPATCH_REF=refs/tags/native-3.0.2 GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh" bash "$dispatch" @@ -373,7 +379,19 @@ for status in behind diverged; do expect "is not on main (compare status: $status)" "${ok_dispatch[@]}" DISPATCH_REF=refs/tags/native-3.0.2 COMPARE_STATUS=$status bash "$dispatch" done plain_dispatch=(RELEASE_TAG=3.0.2 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit DISPATCH_REF=refs/tags/3.0.2 GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh") -env -i PATH="$PATH" "${plain_dispatch[@]}" bash "$dispatch" >/dev/null || fail "the dispatch verifier must accept a plain 3.x tag." +env -i PATH="$PATH" "${plain_dispatch[@]}" COMPOSE_FILE="$(compose_for 3.0.2)" bash "$dispatch" >/dev/null || fail "the dispatch verifier must accept a plain 3.x tag." +# The Compose default must name the final release being dispatched. +expect "defaults to durableworkflow/server:3.0.1, not durableworkflow/server:3.0.2" "${plain_dispatch[@]}" COMPOSE_FILE="$(compose_for 3.0.1)" bash "$dispatch" +: > "$dispatch_scratch/empty.yml" +expect "defaults to no image, not durableworkflow/server:3.0.2" "${plain_dispatch[@]}" COMPOSE_FILE="$dispatch_scratch/empty.yml" bash "$dispatch" +expect "cannot read" "${plain_dispatch[@]}" COMPOSE_FILE="$dispatch_scratch/missing.yml" bash "$dispatch" +# A candidate leaves the default on the last final release. +env -i PATH="$PATH" RELEASE_TAG=3.1.0-rc.1 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit DISPATCH_REF=refs/tags/3.1.0-rc.1 GITHUB_REPOSITORY=example/server \ + GH_CLI="$dispatch_scratch/gh" COMPOSE_FILE="$(compose_for 3.0.2)" bash "$dispatch" >/dev/null \ + || fail "a candidate must not need its own Compose default." +# This repository's own file names the release its commit is tagged as. +grep -q '^x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:[0-9][0-9.]*}$' docker-compose.published.yml \ + || fail "docker-compose.published.yml must default to one exact durableworkflow/server release." expect "must be X.Y.Z or X.Y.Z-rc.N from major 3" RELEASE_TAG=2.11.9 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit DISPATCH_REF=refs/tags/2.11.9 GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh" bash "$dispatch" for status in ahead identical; do env -i PATH="$PATH" "${ok_dispatch[@]}" DISPATCH_REF=refs/tags/native-3.0.2 COMPARE_STATUS=$status bash "$dispatch" >/dev/null \ diff --git a/scripts/ci/verify-native-release-dispatch.sh b/scripts/ci/verify-native-release-dispatch.sh index 3b4bf089a..1801c14bf 100755 --- a/scripts/ci/verify-native-release-dispatch.sh +++ b/scripts/ci/verify-native-release-dispatch.sh @@ -26,6 +26,17 @@ gh_cli="${GH_CLI:-gh}" || fail "the dispatched commit ${dispatch_sha:-(none)} is not RELEASE_COMMIT $release_commit." [[ "$repository" =~ ^[0-9A-Za-z_.-]+/[0-9A-Za-z_.-]+$ ]] \ || fail "GITHUB_REPOSITORY must identify the release repository." +# A final release's commit names its own image as the published Compose +# default, so the tagged docker-compose.published.yml installs that release. +# A candidate leaves the default on the last final release. +version="${release_tag#native-}" +if [[ "$version" != *-* ]]; then + compose="${COMPOSE_FILE:-docker-compose.published.yml}" + [ -f "$compose" ] || fail "cannot read $compose at the release commit." + default="$(sed -n 's/^x-server-image: &server-image \${DW_SERVER_IMAGE:-\(.*\)}$/\1/p' "$compose")" + [ "$default" = "durableworkflow/server:$version" ] \ + || fail "$compose defaults to ${default:-no image}, not durableworkflow/server:$version; move the default in the release commit." +fi if ! status="$("$gh_cli" api "repos/$repository/compare/$release_commit...main" --jq .status 2>/dev/null)"; then fail "cannot compare $release_commit with main." fi From ce4a2a0642a3906c187fb4687a50ee04b54ef00b Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:04:52 -0600 Subject: [PATCH 10/11] Compose default gate: candidates keep it; the smoke selector fails closed Security's review of 82867798: 1. Candidates were exempt, so an RC could move the default anywhere. Every release now needs exactly one official durableworkflow/server default (a duplicate, latest, another repository, malformed or absent is refused). A final names its own version. A candidate keeps its first parent's default, read through the GitHub API. 2. The smoke selector fell through for defaults outside its pattern. It is now scripts/ci/select-published-compose-image.sh: - a manual input wins; - otherwise it needs exactly one official default; - the Docker Hub lookup is bounded (--connect-timeout 10, --max-time 20); - 200 smokes the default, and 404 falls back to latest only on a pull request; - a dispatch's 404, any other status, or an unreachable Hub fails. check-native-release-tags.sh covers: - a candidate keeping, moving to its base final, and moving to another version; - duplicate, latest, unofficial and empty defaults, for candidates and finals; - an unreadable parent or parent file; - every selector outcome, with a fake curl; - that the lookup is bounded and the workflow uses the script. Negative control: dropping the candidate equality fails. A live run of the selector against Docker Hub gives latest on a pull request and refuses on dispatch for 3.0.3. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/published-compose.yml | 22 +------ scripts/ci/check-native-release-tags.sh | 64 ++++++++++++++++++-- scripts/ci/select-published-compose-image.sh | 38 ++++++++++++ scripts/ci/verify-native-release-dispatch.sh | 33 +++++++--- 4 files changed, 124 insertions(+), 33 deletions(-) create mode 100755 scripts/ci/select-published-compose-image.sh diff --git a/.github/workflows/published-compose.yml b/.github/workflows/published-compose.yml index 0f5945812..56952ffe6 100644 --- a/.github/workflows/published-compose.yml +++ b/.github/workflows/published-compose.yml @@ -53,27 +53,9 @@ jobs: - name: Set Compose image selector env: + EVENT_NAME: ${{ github.event_name }} INPUT_SERVER_IMAGE: ${{ github.event_name == 'workflow_dispatch' && inputs.server_image || '' }} - run: | - if [ -n "$INPUT_SERVER_IMAGE" ]; then - echo "DW_SERVER_IMAGE=$INPUT_SERVER_IMAGE" >> "$GITHUB_ENV" - exit 0 - fi - # A release commit moves the default to the release it will be - # tagged as, before that image is published. Only then, a pull - # request smokes the Compose file with the current latest release; - # any other image that cannot be read still fails the smoke. - default="$(sed -n 's/^x-server-image: &server-image \${DW_SERVER_IMAGE:-\(.*\)}$/\1/p' docker-compose.published.yml)" - if [[ "$default" =~ ^durableworkflow/server:([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - status="$(curl -sS -o /dev/null -w '%{http_code}' "https://hub.docker.com/v2/repositories/durableworkflow/server/tags/${BASH_REMATCH[1]}")" - if [ "$status" = 404 ]; then - echo "::notice::$default is not published yet; smoking the Compose file with durableworkflow/server:latest." - echo "DW_SERVER_IMAGE=durableworkflow/server:latest" >> "$GITHUB_ENV" - elif [ "$status" != 200 ]; then - echo "cannot tell whether $default is published (HTTP $status)" >&2 - exit 1 - fi - fi + run: scripts/ci/select-published-compose-image.sh - name: Smoke published Compose stack env: diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 0f8d1d6b5..c9c77daea 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -360,7 +360,17 @@ awk '/- name: Promote rolling image aliases/,/run: scripts\/ci\/promote-release- # The dispatch verifier: the release tag's own ref, at its commit, on main. dispatch=scripts/ci/verify-native-release-dispatch.sh dispatch_scratch=$(mktemp -d) -printf '#!/bin/sh\necho "${COMPARE_STATUS:-ahead}"\n' > "$dispatch_scratch/gh" +cat > "$dispatch_scratch/gh" <<'FAKE' +#!/usr/bin/env bash +# compare: COMPARE_STATUS; the release commit's first parent: PARENT_SHA; +# docker-compose.published.yml at that parent: the file PARENT_COMPOSE names. +case "$*" in + *"/compare/"*) echo "${COMPARE_STATUS:-ahead}" ;; + *"/commits/"*) [ -n "${PARENT_SHA:-}" ] || exit 1; echo "$PARENT_SHA" ;; + *"/contents/docker-compose.published.yml"*) [ -f "${PARENT_COMPOSE:-/nonexistent}" ] || exit 1; cat "$PARENT_COMPOSE" ;; + *) exit 1 ;; +esac +FAKE chmod +x "$dispatch_scratch/gh" # A final release's commit names its own image as the published Compose # default; each case reads a fixture of that file. @@ -383,12 +393,27 @@ env -i PATH="$PATH" "${plain_dispatch[@]}" COMPOSE_FILE="$(compose_for 3.0.2)" b # The Compose default must name the final release being dispatched. expect "defaults to durableworkflow/server:3.0.1, not durableworkflow/server:3.0.2" "${plain_dispatch[@]}" COMPOSE_FILE="$(compose_for 3.0.1)" bash "$dispatch" : > "$dispatch_scratch/empty.yml" -expect "defaults to no image, not durableworkflow/server:3.0.2" "${plain_dispatch[@]}" COMPOSE_FILE="$dispatch_scratch/empty.yml" bash "$dispatch" +expect "must default to exactly one durableworkflow/server:X.Y.Z image" "${plain_dispatch[@]}" COMPOSE_FILE="$dispatch_scratch/empty.yml" bash "$dispatch" expect "cannot read" "${plain_dispatch[@]}" COMPOSE_FILE="$dispatch_scratch/missing.yml" bash "$dispatch" -# A candidate leaves the default on the last final release. -env -i PATH="$PATH" RELEASE_TAG=3.1.0-rc.1 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit DISPATCH_REF=refs/tags/3.1.0-rc.1 GITHUB_REPOSITORY=example/server \ - GH_CLI="$dispatch_scratch/gh" COMPOSE_FILE="$(compose_for 3.0.2)" bash "$dispatch" >/dev/null \ - || fail "a candidate must not need its own Compose default." +# A candidate keeps its first parent's default, exactly. +parent=89abcdef0123456789abcdef0123456789abcdef +candidate=(RELEASE_TAG=3.1.0-rc.1 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit DISPATCH_REF=refs/tags/3.1.0-rc.1 GITHUB_REPOSITORY=example/server + GH_CLI="$dispatch_scratch/gh" PARENT_SHA=$parent PARENT_COMPOSE="$(compose_for 3.0.2)") +env -i PATH="$PATH" "${candidate[@]}" COMPOSE_FILE="$(compose_for 3.0.2)" bash "$dispatch" >/dev/null \ + || fail "a candidate that keeps its parent's Compose default must pass." +printf 'x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:3.0.2}\nx-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:3.0.2}\n' > "$dispatch_scratch/duplicate.yml" +printf 'x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:latest}\n' > "$dispatch_scratch/latest.yml" +printf 'x-server-image: &server-image ${DW_SERVER_IMAGE:-example/server:3.0.2}\n' > "$dispatch_scratch/unofficial.yml" +expect "a candidate keeps the Compose default" "${candidate[@]}" COMPOSE_FILE="$(compose_for 3.1.0)" bash "$dispatch" +expect "a candidate keeps the Compose default" "${candidate[@]}" COMPOSE_FILE="$(compose_for 3.0.9)" bash "$dispatch" +for bad in duplicate latest unofficial empty; do + expect "must default to exactly one durableworkflow/server:X.Y.Z image" "${candidate[@]}" COMPOSE_FILE="$dispatch_scratch/$bad.yml" bash "$dispatch" + expect "must default to exactly one durableworkflow/server:X.Y.Z image" "${plain_dispatch[@]}" COMPOSE_FILE="$dispatch_scratch/$bad.yml" bash "$dispatch" +done +expect "a candidate keeps the Compose default" "${candidate[@]}" PARENT_COMPOSE="$dispatch_scratch/duplicate.yml" COMPOSE_FILE="$(compose_for 3.0.2)" bash "$dispatch" +expect "cannot read the first parent" RELEASE_TAG=3.1.0-rc.1 RELEASE_COMMIT=$commit DISPATCH_SHA=$commit DISPATCH_REF=refs/tags/3.1.0-rc.1 \ + GITHUB_REPOSITORY=example/server GH_CLI="$dispatch_scratch/gh" COMPOSE_FILE="$(compose_for 3.0.2)" bash "$dispatch" +expect "cannot read docker-compose.published.yml at $parent" "${candidate[@]}" PARENT_COMPOSE=/nonexistent COMPOSE_FILE="$(compose_for 3.0.2)" bash "$dispatch" # This repository's own file names the release its commit is tagged as. grep -q '^x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:[0-9][0-9.]*}$' docker-compose.published.yml \ || fail "docker-compose.published.yml must default to one exact durableworkflow/server release." @@ -397,6 +422,33 @@ for status in ahead identical; do env -i PATH="$PATH" "${ok_dispatch[@]}" DISPATCH_REF=refs/tags/native-3.0.2 COMPARE_STATUS=$status bash "$dispatch" >/dev/null \ || fail "the dispatch verifier must accept a release commit main contains ($status)." done +# The published Compose smoke's image: one official default, smoked as is +# when Docker Hub has it; latest only for a pull request whose default is not +# published yet; anything else fails. +select=scripts/ci/select-published-compose-image.sh +printf '#!/usr/bin/env bash\nprintf "%%s" "${HUB_STATUS:-200}"\n' > "$dispatch_scratch/curl" +chmod +x "$dispatch_scratch/curl" +selected() { # event, compose file [, VAR=value ...] -> the selected image, "default" or FAILED + local event=$1 compose=$2; shift 2 + : > "$dispatch_scratch/github_env" + env -i PATH="$PATH" EVENT_NAME="$event" COMPOSE_FILE="$compose" CURL="$dispatch_scratch/curl" GITHUB_ENV="$dispatch_scratch/github_env" "$@" \ + bash "$select" >/dev/null 2>&1 || { echo FAILED; return; } + sed -n 's/^DW_SERVER_IMAGE=//p' "$dispatch_scratch/github_env" | grep . || echo default +} +want_image() { test "$2" = "$3" || fail "$1: expected '$3', got '$2'."; } +want_image "published default" "$(selected pull_request "$(compose_for 3.0.2)")" default +want_image "unpublished default on a pull request" "$(selected pull_request "$(compose_for 3.0.3)" HUB_STATUS=404)" "durableworkflow/server:latest" +want_image "unpublished default on dispatch" "$(selected workflow_dispatch "$(compose_for 3.0.3)" HUB_STATUS=404)" FAILED +want_image "Docker Hub error" "$(selected pull_request "$(compose_for 3.0.3)" HUB_STATUS=503)" FAILED +want_image "Docker Hub unreachable" "$(selected pull_request "$(compose_for 3.0.3)" HUB_STATUS=000)" FAILED +for bad in duplicate latest unofficial empty; do + want_image "$bad default" "$(selected pull_request "$dispatch_scratch/$bad.yml")" FAILED +done +want_image "missing file" "$(selected pull_request "$dispatch_scratch/missing.yml")" FAILED +want_image "manual input" "$(selected workflow_dispatch "$dispatch_scratch/duplicate.yml" INPUT_SERVER_IMAGE=ghcr.io/durable-workflow/server@sha256:abc)" "ghcr.io/durable-workflow/server@sha256:abc" +grep -q -- '--connect-timeout 10 --max-time 20' "$select" || fail "the Docker Hub lookup must be bounded." +grep -q '^ run: scripts/ci/select-published-compose-image.sh$' .github/workflows/published-compose.yml \ + || fail "the published Compose smoke must select its image with select-published-compose-image.sh." rm -rf "$dispatch_scratch" # Each job's own condition, exactly: plan has none, so a wrong ref fails in # its verifier rather than skipping every job; every publishing job is gated diff --git a/scripts/ci/select-published-compose-image.sh b/scripts/ci/select-published-compose-image.sh new file mode 100755 index 000000000..9dfb1dbcb --- /dev/null +++ b/scripts/ci/select-published-compose-image.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# The image the published Compose smoke runs (Server #325). +# +# A manual input wins. Otherwise docker-compose.published.yml must default to +# exactly one official durableworkflow/server:X.Y.Z image, which the smoke +# runs. A release commit moves that default to the release it will be tagged +# as, before the image is published; only then, and only on a pull request, +# does the smoke run the Compose file with the current latest release +# instead. Any other unpublished, unreadable or malformed default fails. +# +# Env: EVENT_NAME, INPUT_SERVER_IMAGE, GITHUB_ENV; COMPOSE_FILE and CURL +# override the file and the curl command. +set -euo pipefail +fail() { printf 'Published Compose image selection failed: %s\n' "$1" >&2; exit 1; } +compose="${COMPOSE_FILE:-docker-compose.published.yml}" +curl_bin="${CURL:-curl}" +: "${GITHUB_ENV:?}" + +if [ -n "${INPUT_SERVER_IMAGE:-}" ]; then + printf 'DW_SERVER_IMAGE=%s\n' "$INPUT_SERVER_IMAGE" >> "$GITHUB_ENV" + exit 0 +fi + +[ -f "$compose" ] || fail "cannot read $compose." +[ "$(grep -c '^x-server-image:' "$compose" || true)" = 1 ] || fail "$compose must declare exactly one x-server-image." +default="$(sed -n 's/^x-server-image: &server-image \${DW_SERVER_IMAGE:-\(durableworkflow\/server:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)}$/\1/p' "$compose")" +[ -n "$default" ] || fail "$compose must default to one official durableworkflow/server:X.Y.Z image." + +status="$("$curl_bin" -sS -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 20 \ + "https://hub.docker.com/v2/repositories/durableworkflow/server/tags/${default#durableworkflow/server:}" 2>/dev/null || true)" +if [ "$status" = 200 ]; then + printf 'Smoking the Compose default %s.\n' "$default" +elif [ "$status" = 404 ] && [ "${EVENT_NAME:-}" = pull_request ]; then + printf '::notice::%s is not published yet; smoking the Compose file with durableworkflow/server:latest.\n' "$default" + printf 'DW_SERVER_IMAGE=durableworkflow/server:latest\n' >> "$GITHUB_ENV" +else + fail "$default is not available to smoke (Docker Hub answered ${status:-nothing}, event ${EVENT_NAME:-unknown})." +fi diff --git a/scripts/ci/verify-native-release-dispatch.sh b/scripts/ci/verify-native-release-dispatch.sh index 1801c14bf..5e0b235c1 100755 --- a/scripts/ci/verify-native-release-dispatch.sh +++ b/scripts/ci/verify-native-release-dispatch.sh @@ -26,16 +26,35 @@ gh_cli="${GH_CLI:-gh}" || fail "the dispatched commit ${dispatch_sha:-(none)} is not RELEASE_COMMIT $release_commit." [[ "$repository" =~ ^[0-9A-Za-z_.-]+/[0-9A-Za-z_.-]+$ ]] \ || fail "GITHUB_REPOSITORY must identify the release repository." -# A final release's commit names its own image as the published Compose -# default, so the tagged docker-compose.published.yml installs that release. -# A candidate leaves the default on the last final release. +# The published Compose default at the release commit: exactly one official +# durableworkflow/server:X.Y.Z image. A final release's commit names its own +# version, so the tagged docker-compose.published.yml installs that release; a +# candidate's commit keeps its first parent's default. +compose_default() { # file -> the one official default, or nothing + local lines + lines="$(grep -c '^x-server-image:' "$1" || true)" + [ "$lines" = 1 ] || return 0 + sed -n 's/^x-server-image: &server-image \${DW_SERVER_IMAGE:-\(durableworkflow\/server:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)}$/\1/p' "$1" +} version="${release_tag#native-}" +compose="${COMPOSE_FILE:-docker-compose.published.yml}" +[ -f "$compose" ] || fail "cannot read $compose at the release commit." +default="$(compose_default "$compose")" +[ -n "$default" ] || fail "$compose must default to exactly one durableworkflow/server:X.Y.Z image." if [[ "$version" != *-* ]]; then - compose="${COMPOSE_FILE:-docker-compose.published.yml}" - [ -f "$compose" ] || fail "cannot read $compose at the release commit." - default="$(sed -n 's/^x-server-image: &server-image \${DW_SERVER_IMAGE:-\(.*\)}$/\1/p' "$compose")" [ "$default" = "durableworkflow/server:$version" ] \ - || fail "$compose defaults to ${default:-no image}, not durableworkflow/server:$version; move the default in the release commit." + || fail "$compose defaults to $default, not durableworkflow/server:$version; move the default in the release commit." +else + parent_file="$(mktemp)" + trap 'rm -f "$parent_file"' EXIT + parent="$("$gh_cli" api "repos/$repository/commits/$release_commit" --jq '.parents[0].sha' 2>/dev/null)" \ + || fail "cannot read the first parent of $release_commit." + [[ "$parent" =~ ^[0-9a-f]{40}$ ]] || fail "cannot read the first parent of $release_commit." + "$gh_cli" api -H 'Accept: application/vnd.github.raw' "repos/$repository/contents/docker-compose.published.yml?ref=$parent" \ + > "$parent_file" 2>/dev/null || fail "cannot read docker-compose.published.yml at $parent." + previous="$(compose_default "$parent_file")" + [ -n "$previous" ] && [ "$default" = "$previous" ] \ + || fail "a candidate keeps the Compose default: $compose defaults to $default, its first parent to ${previous:-no single official image}." fi if ! status="$("$gh_cli" api "repos/$repository/compare/$release_commit...main" --jq .status 2>/dev/null)"; then fail "cannot compare $release_commit with main." From 3b703e6bec6f105740e973e4eda56add36bea6ea Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:09:03 -0600 Subject: [PATCH 11/11] Compose smoke selector: only a completed lookup is an answer Security's review of ce4a2a06: the selector discarded curl's exit status, so a lookup that printed 200 and then failed (exit 7) read as published. The lookup must now complete; a failed one fails the smoke. A 404 still completes with exit 0, since the lookup doesn't use -f, and keeps its pull-request-only fallback. The selector script is now in the workflow's pull_request paths, so a change to it runs the real smoke. check-native-release-tags.sh refuses a lookup that printed 200 or 404 but exited non-zero, and checks the trigger path. Negative control: discarding the exit status again fails. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/published-compose.yml | 1 + scripts/ci/check-native-release-tags.sh | 6 +++++- scripts/ci/select-published-compose-image.sh | 7 +++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/published-compose.yml b/.github/workflows/published-compose.yml index 56952ffe6..cff310952 100644 --- a/.github/workflows/published-compose.yml +++ b/.github/workflows/published-compose.yml @@ -5,6 +5,7 @@ on: paths: - docker-compose.published.yml - scripts/smoke-published-compose.sh + - scripts/ci/select-published-compose-image.sh - .github/workflows/published-compose.yml workflow_dispatch: inputs: diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index c9c77daea..bb21b521c 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -426,7 +426,7 @@ done # when Docker Hub has it; latest only for a pull request whose default is not # published yet; anything else fails. select=scripts/ci/select-published-compose-image.sh -printf '#!/usr/bin/env bash\nprintf "%%s" "${HUB_STATUS:-200}"\n' > "$dispatch_scratch/curl" +printf '#!/usr/bin/env bash\nprintf "%%s" "${HUB_STATUS:-200}"\nexit "${CURL_EXIT:-0}"\n' > "$dispatch_scratch/curl" chmod +x "$dispatch_scratch/curl" selected() { # event, compose file [, VAR=value ...] -> the selected image, "default" or FAILED local event=$1 compose=$2; shift 2 @@ -441,12 +441,16 @@ want_image "unpublished default on a pull request" "$(selected pull_request "$(c want_image "unpublished default on dispatch" "$(selected workflow_dispatch "$(compose_for 3.0.3)" HUB_STATUS=404)" FAILED want_image "Docker Hub error" "$(selected pull_request "$(compose_for 3.0.3)" HUB_STATUS=503)" FAILED want_image "Docker Hub unreachable" "$(selected pull_request "$(compose_for 3.0.3)" HUB_STATUS=000)" FAILED +want_image "a lookup that printed 200 but failed" "$(selected pull_request "$(compose_for 3.0.2)" HUB_STATUS=200 CURL_EXIT=7)" FAILED +want_image "a lookup that printed 404 but failed" "$(selected pull_request "$(compose_for 3.0.3)" HUB_STATUS=404 CURL_EXIT=28)" FAILED for bad in duplicate latest unofficial empty; do want_image "$bad default" "$(selected pull_request "$dispatch_scratch/$bad.yml")" FAILED done want_image "missing file" "$(selected pull_request "$dispatch_scratch/missing.yml")" FAILED want_image "manual input" "$(selected workflow_dispatch "$dispatch_scratch/duplicate.yml" INPUT_SERVER_IMAGE=ghcr.io/durable-workflow/server@sha256:abc)" "ghcr.io/durable-workflow/server@sha256:abc" grep -q -- '--connect-timeout 10 --max-time 20' "$select" || fail "the Docker Hub lookup must be bounded." +grep -q '^ - scripts/ci/select-published-compose-image.sh$' .github/workflows/published-compose.yml \ + || fail "a change to the selector must run the published Compose smoke." grep -q '^ run: scripts/ci/select-published-compose-image.sh$' .github/workflows/published-compose.yml \ || fail "the published Compose smoke must select its image with select-published-compose-image.sh." rm -rf "$dispatch_scratch" diff --git a/scripts/ci/select-published-compose-image.sh b/scripts/ci/select-published-compose-image.sh index 9dfb1dbcb..89b358e8b 100755 --- a/scripts/ci/select-published-compose-image.sh +++ b/scripts/ci/select-published-compose-image.sh @@ -26,8 +26,11 @@ fi default="$(sed -n 's/^x-server-image: &server-image \${DW_SERVER_IMAGE:-\(durableworkflow\/server:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)}$/\1/p' "$compose")" [ -n "$default" ] || fail "$compose must default to one official durableworkflow/server:X.Y.Z image." -status="$("$curl_bin" -sS -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 20 \ - "https://hub.docker.com/v2/repositories/durableworkflow/server/tags/${default#durableworkflow/server:}" 2>/dev/null || true)" +# Only a lookup curl completes is an answer; a failed one fails the smoke. +if ! status="$("$curl_bin" -sS -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 20 \ + "https://hub.docker.com/v2/repositories/durableworkflow/server/tags/${default#durableworkflow/server:}" 2>/dev/null)"; then + fail "the Docker Hub lookup for $default did not complete." +fi if [ "$status" = 200 ]; then printf 'Smoking the Compose default %s.\n' "$default" elif [ "$status" = 404 ] && [ "${EVENT_NAME:-}" = pull_request ]; then