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 f190778ee..be1010e28 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -1,9 +1,13 @@ 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. +# 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 @@ -41,7 +45,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 +72,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 +95,16 @@ 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. + 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 + echo "tag_prefix=$prefix" >> "$GITHUB_OUTPUT" build-amd64: name: Build and smoke linux/amd64 @@ -126,7 +137,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 +230,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 diff --git a/.github/workflows/published-compose.yml b/.github/workflows/published-compose.yml index 4fc4c2b4c..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: @@ -53,11 +54,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" - fi + run: scripts/ci/select-published-compose-image.sh - name: Smoke published Compose stack env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2b8356dd..7a5aff6aa 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,18 @@ 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. + 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 with: 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 00f47ed2c..bb21b521c 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)" "" @@ -118,15 +125,130 @@ 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 +# 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 +case "$1 $2" in + "release view") [[ -n "${EXISTING:-}" ]] || exit 1; printf '%s\n' "$EXISTING"; 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 + 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/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 +FAKE +cat > "$scratch/release-docker" <<'FAKE' +#!/usr/bin/env bash +ref=${!#} +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/release-docker" +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" 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; } + 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" +# 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" +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" +want_release "3.0.2 released after 3.1.0 took latest" "$(gh_release 3.0.2 "$theirs")" "--latest=false" +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 "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 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 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 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" +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. 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." @@ -204,10 +326,33 @@ 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 +# 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 } + 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." +# 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 \ @@ -215,22 +360,99 @@ 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. +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" -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[@]}" 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 "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 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." +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)." 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}"\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 + : > "$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 +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" # 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 @@ -254,4 +476,8 @@ 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." 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 new file mode 100755 index 000000000..8fbdd298d --- /dev/null +++ b/scripts/ci/native-release-github-release.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# 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 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 +# 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. +set -euo pipefail +fail() { printf 'GitHub Release failed: %s\n' "$1" >&2; exit 1; } +gh_bin=${GH:-gh} +docker_bin=${DOCKER:-docker} +: "${GITHUB_REPOSITORY:?}" "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" +repo=$GITHUB_REPOSITORY +workflow=.github/workflows/native-release.yml + +# 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." + +# 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 + 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" ]]; 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 | 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) +elif [[ "$latest" == true ]]; then + args+=(--latest) +else + args+=(--latest=false) +fi +"$gh_bin" "${args[@]}" 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/select-published-compose-image.sh b/scripts/ci/select-published-compose-image.sh new file mode 100755 index 000000000..89b358e8b --- /dev/null +++ b/scripts/ci/select-published-compose-image.sh @@ -0,0 +1,41 @@ +#!/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." + +# 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 + 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 c4c051ef9..5e0b235c1 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" ] \ @@ -25,6 +26,36 @@ 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." +# 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 + [ "$default" = "durableworkflow/server:$version" ] \ + || 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." fi