From 3f3b7eba698a35c0c3d5c385b62607a0df363252 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:24:56 -0600 Subject: [PATCH 1/2] Native release: dispatch from the release tag, and name the version at startup 3.0.1's attestations named main's tip at dispatch (ff96403e), not the release commit 923d45b3, because native-release.yml had to be dispatched from main. - The plan job now requires the dispatch ref to be refs/tags/, the dispatched commit to be release_commit, and main to contain that commit (verify-native-release-dispatch.sh). Then the attestation's source digest is the release commit, and main keeps moving. - The plan job has no job condition, so a dispatch from any other ref fails loudly instead of skipping every job. - The build, publish and rolling-tag jobs are gated on the tag ref. - check-native-release-tags.sh covers wrong refs, a commit mismatch, a commit main lacks, and both accepted compare statuses. - A release build's startup line names its version instead of "Rust development runtime". Prerequisite outside this PR: the dockerhub-native environment allows only main today; it needs a native-* tag deployment policy. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image.yml | 1 + .github/workflows/native-release.yml | 32 ++++++++++++++---- rust/src/main.rs | 15 ++++++--- scripts/ci/check-native-release-tags.sh | 27 +++++++++++++++ scripts/ci/verify-native-release-dispatch.sh | 35 ++++++++++++++++++++ 5 files changed, 100 insertions(+), 10 deletions(-) create mode 100755 scripts/ci/verify-native-release-dispatch.sh diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml index 026085a85..da44f9eed 100644 --- a/.github/workflows/native-image.yml +++ b/.github/workflows/native-image.yml @@ -19,6 +19,7 @@ on: - scripts/ci/promote-release-image-rolling-tags.sh - scripts/ci/check-native-release-tags.sh - scripts/ci/verify-release-tag-source.sh + - scripts/ci/verify-native-release-dispatch.sh - .github/workflows/native-image.yml - .github/workflows/native-release.yml - .github/workflows/release.yml diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 245eda4ab..02841f4da 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -18,7 +18,11 @@ name: Native release candidate # replaces a PHP image. A final release then moves X.Y, X and latest # forward to its index (operator decision, November 1); a candidate moves # none, and the PHP Release workflow leaves latest to a newer native -# release. It runs only from main, in the dockerhub-native Environment. +# release. It is dispatched from the release tag's own ref +# (refs/tags/native-), at the tag's commit, which protected main must +# contain; its build-provenance attestation then names the release commit as +# its source. It publishes from the dockerhub-native Environment, which allows +# native-* tags; their creation is restricted by the repository's tag ruleset. # # Every version, release candidates included, publishes to both registries. # Candidates 1 and 2 went to GHCR only. That was the porting agent's @@ -56,13 +60,29 @@ env: jobs: plan: name: Choose the registries for this tag - if: github.ref == 'refs/heads/main' + # No job condition: a dispatch from any other ref fails here, loudly, + # rather than skipping every job and finishing green. runs-on: ubuntu-latest timeout-minutes: 5 outputs: dockerhub: ${{ steps.plan.outputs.dockerhub }} version: ${{ steps.plan.outputs.version }} steps: + - name: Check out trusted release tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require a dispatch from the release tag, at its commit, on main + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_COMMIT: ${{ inputs.release_commit }} + DISPATCH_REF: ${{ github.ref }} + DISPATCH_SHA: ${{ github.sha }} + run: scripts/ci/verify-native-release-dispatch.sh + - name: Publish to both registries id: plan env: @@ -77,7 +97,7 @@ jobs: build-amd64: name: Build and smoke linux/amd64 needs: plan - if: github.ref == 'refs/heads/main' + if: github.ref == format('refs/tags/{0}', inputs.tag) runs-on: ubuntu-latest timeout-minutes: 75 environment: dockerhub-native @@ -170,7 +190,7 @@ jobs: build-arm64: name: Build and smoke linux/arm64 needs: plan - if: github.ref == 'refs/heads/main' + if: github.ref == format('refs/tags/{0}', inputs.tag) runs-on: ubuntu-24.04-arm timeout-minutes: 75 environment: dockerhub-native @@ -263,7 +283,7 @@ jobs: publish: name: Tag, verify and attest the native index needs: [plan, build-amd64, build-arm64] - if: github.ref == 'refs/heads/main' + if: github.ref == format('refs/tags/{0}', inputs.tag) runs-on: ubuntu-latest timeout-minutes: 20 environment: dockerhub-native @@ -331,7 +351,7 @@ jobs: aliases: name: Move the rolling tags of a final release needs: [plan, publish] - if: github.ref == 'refs/heads/main' && needs.plan.outputs.dockerhub == 'true' + if: github.ref == format('refs/tags/{0}', inputs.tag) && needs.plan.outputs.dockerhub == 'true' runs-on: ubuntu-latest timeout-minutes: 15 environment: dockerhub-native diff --git a/rust/src/main.rs b/rust/src/main.rs index a246f7af3..95a36c2c0 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -623,10 +623,17 @@ async fn run() -> Result<(), Box> { .with_role_tokens(worker_token, operator_token, admin_token) .with_auth_options(anonymous, runtime_credentials); let listener = tokio::net::TcpListener::bind(address).await?; - eprintln!( - "Rust development runtime listening on {}", - listener.local_addr()? - ); + // A release build names its version; a source build stays a development runtime. + match option_env!("DW_SERVER_VERSION").filter(|version| !version.is_empty()) { + Some(version) => eprintln!( + "Durable Workflow native server {version} listening on {}", + listener.local_addr()? + ), + None => eprintln!( + "Rust development runtime listening on {}", + listener.local_addr()? + ), + } axum::serve( listener, router(runtime.clone()).into_make_service_with_connect_info::(), diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 8439378bf..ab398a754 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -212,4 +212,31 @@ grep -q 'RELEASE_IMAGE_KNOWN_TAGS: ${{ steps.rolling.outputs.release_inventory } || 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 \ | grep -q RELEASE_IMAGE_TAG_SOURCE_URL && fail "the PHP promoter must not read the tags again." +# 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" +chmod +x "$dispatch_scratch/gh" +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") +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" +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 +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 +rm -rf "$dispatch_scratch" +# Every job is gated on the release tag's own ref, and the plan job verifies it first. +test "$(grep -c "^ if: github.ref == 'refs/heads/main'" .github/workflows/native-release.yml)" = 0 \ + || fail "native-release.yml must not run from main." +test "$(grep -c "^ if: github.ref == format('refs/tags/{0}', inputs.tag)" .github/workflows/native-release.yml)" -ge 3 \ + || fail "native-release.yml jobs must be gated on the release tag ref." +grep -q 'run: scripts/ci/verify-native-release-dispatch.sh' .github/workflows/native-release.yml \ + || fail "the plan job must run the dispatch verifier." 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/verify-native-release-dispatch.sh b/scripts/ci/verify-native-release-dispatch.sh new file mode 100755 index 000000000..c4c051ef9 --- /dev/null +++ b/scripts/ci/verify-native-release-dispatch.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# A native release runs only when dispatched from its own release tag ref, at +# that tag's commit, and that commit is on protected main (Server #325). +# Dispatching from the tag makes the build-provenance attestation's source +# digest the release commit itself; the tag ref alone would let tooling run +# from a commit main never accepted, so main must contain it. +set -euo pipefail +fail() { + printf 'Native release dispatch refused: %s\n' "$1" >&2 + exit 1 +} +release_tag="${RELEASE_TAG:-}" +release_commit="${RELEASE_COMMIT:-}" +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." +[[ "$release_commit" =~ ^[0-9a-f]{40}$ ]] \ + || fail "RELEASE_COMMIT must be a full lowercase Git commit SHA." +[ "$dispatch_ref" = "refs/tags/$release_tag" ] \ + || fail "dispatch from refs/tags/$release_tag, not ${dispatch_ref:-an empty ref}." +[ "$dispatch_sha" = "$release_commit" ] \ + || 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." +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 +case "$status" in + ahead | identical) ;; + *) fail "RELEASE_COMMIT $release_commit is not on main (compare status: ${status:-none})." ;; +esac +printf 'Dispatched from %s at %s, which main contains.\n' "$dispatch_ref" "$release_commit" From fb673862da0186135fce51b53fd9fbd691c6ab72 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:28:26 -0600 Subject: [PATCH 2/2] Release check: pin each job's condition exactly; plan has none Security's review of 3f3b7eba: counting tag gates (>=3) and the absence of a main gate missed both a job condition on plan and one job back on main. check-native-release-tags.sh now reads every job's condition and requires exactly: plan none, build-amd64/build-arm64/publish the tag-ref gate, aliases the tag-ref gate and the Docker Hub plan output. Both regressions are caught. The workflow comment names the environment's tag pattern native-[0-9]*. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-release.yml | 5 +++-- scripts/ci/check-native-release-tags.sh | 25 ++++++++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 02841f4da..f190778ee 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -21,8 +21,9 @@ name: Native release candidate # release. It is dispatched from the release tag's own ref # (refs/tags/native-), at the tag's commit, which protected main must # contain; its build-provenance attestation then names the release commit as -# its source. It publishes from the dockerhub-native Environment, which allows -# native-* tags; their creation is restricted by the repository's tag ruleset. +# its source. It publishes from the dockerhub-native Environment, whose only +# deployment rule is the tag pattern native-[0-9]*; creating those tags is +# restricted by the repository's tag ruleset. # # Every version, release candidates included, publishes to both registries. # Candidates 1 and 2 went to GHCR only. That was the porting agent's diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index ab398a754..00f47ed2c 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -232,11 +232,26 @@ for status in ahead identical; do || fail "the dispatch verifier must accept a release commit main contains ($status)." done rm -rf "$dispatch_scratch" -# Every job is gated on the release tag's own ref, and the plan job verifies it first. -test "$(grep -c "^ if: github.ref == 'refs/heads/main'" .github/workflows/native-release.yml)" = 0 \ - || fail "native-release.yml must not run from main." -test "$(grep -c "^ if: github.ref == format('refs/tags/{0}', inputs.tag)" .github/workflows/native-release.yml)" -ge 3 \ - || fail "native-release.yml jobs must be gated on the release tag ref." +# 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 +# on the release tag's ref. +job_conditions=$(awk ' + /^jobs:/ { jobs = 1; next } + jobs && /^ [a-z0-9-]+:$/ { if (job != "") print job "=" cond; job = substr($1, 1, length($1) - 1); cond = "none"; next } + jobs && /^ if: / { sub(/^ if: /, ""); cond = $0 } + END { if (job != "") print job "=" cond } +' .github/workflows/native-release.yml) +tag_gate="github.ref == format('refs/tags/{0}', inputs.tag)" +expected_conditions="plan=none +build-amd64=$tag_gate +build-arm64=$tag_gate +publish=$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 +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." printf 'Native release tags: %d release.yml pattern(s) checked; verifier, publisher and rolling tags behave as intended.\n' "${#patterns[@]}"