From 875ad38785d34548dc47d42277a05840aad254fa Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:00:48 +0000 Subject: [PATCH 1/6] Qualify a native image over the shared fixture corpus Release-candidate qualification has to compare the published native image with the published PHP reference, not only a source build (Server #325). native-image-corpus.sh runs the given image as an operator would: its entrypoint, its user, its maintenance loop, and SQLite in its /app/database volume, with the fixtures' file:///state storage roots mounted too. It records the shared corpus on SQLite against published PHP 2.11.4, embedded PHP and that image, with CI's selection: the reviewed corpus plus its pending candidates, with update-validation and service-calls recorded separately by update-validation-sqlite.sh with PHP's maintenance loop stopped (#723, #796). It compares the records and writes a durable-workflow.native-image-corpus.v1 receipt naming the image reference, its image id, the PHP reference, the runner revision and each step's status. The image must be pinned by digest or be the job's local build. Containers and the network use dw-parity---, the name the shared PHP maintenance window expects. native-image-parity.yml runs it on dispatch with an image digest. A pull request that changes the comparison or its helpers builds the image from its own source into the runner's daemon and compares that build, so the comparison is exercised before anyone relies on it. Nothing is pushed; an always step removes the containers and keeps the evidence. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image-parity.yml | 82 +++++++++++ .../server-parity/native-image-corpus.sh | 138 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 .github/workflows/native-image-parity.yml create mode 100755 scripts/conformance/server-parity/native-image-corpus.sh diff --git a/.github/workflows/native-image-parity.yml b/.github/workflows/native-image-parity.yml new file mode 100644 index 000000000..17127bd00 --- /dev/null +++ b/.github/workflows/native-image-parity.yml @@ -0,0 +1,82 @@ +name: Native image parity + +# Differential qualification of a native server image (Server #325). It +# records the shared fixture corpus on SQLite against the published PHP +# reference, embedded PHP and the native image itself, and compares the three +# records, as the Shared Server fixtures corpus part does for a source build. +# +# Dispatch it with a published native image by digest to qualify that +# artifact. A pull request that changes the comparison itself builds the image +# from its own source and compares that build, so the comparison is exercised +# before it is relied on. Nothing is pushed. + +on: + workflow_dispatch: + inputs: + native_image: + description: 'Native image by digest, for example ghcr.io/durable-workflow/server-native@sha256:' + required: true + pull_request: + paths: + - scripts/conformance/server-parity/native-image-corpus.sh + - scripts/conformance/server-parity/update-validation-sqlite.sh + - scripts/conformance/server-parity/php-maintenance-window.sh + - .github/workflows/native-image-parity.yml + +permissions: + contents: read + +concurrency: + group: native-image-parity-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + corpus: + name: Compare the native image over the shared fixture corpus (SQLite) + runs-on: ubuntu-latest + timeout-minutes: 75 + env: + # The name the shared PHP maintenance window expects. + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + LOCAL_IMAGE: dw-native-corpus-${{ github.run_id }}-${{ github.run_attempt }}:corpus + steps: + - name: Check out + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Docker Buildx + if: github.event_name == 'pull_request' + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Build this pull request's native image into the local daemon + if: github.event_name == 'pull_request' + timeout-minutes: 45 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: Dockerfile.native + platforms: linux/amd64 + pull: true + provenance: false + load: true + push: false + tags: ${{ env.LOCAL_IMAGE }} + + - name: Record and compare the corpus against the native image + env: + NATIVE_IMAGE: ${{ github.event_name == 'pull_request' && env.LOCAL_IMAGE || inputs.native_image }} + run: bash scripts/conformance/server-parity/native-image-corpus.sh "$NATIVE_IMAGE" + + - name: Remove this job's containers and network + if: always() + run: | + docker rm -f "$RESOURCE_SCOPE-rust" "$RESOURCE_SCOPE-server" "$RESOURCE_SCOPE-php-maintenance" || true + docker network rm "$RESOURCE_SCOPE" || true + + - name: Keep the comparison evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-image-parity + path: parity-evidence + retention-days: 14 + if-no-files-found: ignore diff --git a/scripts/conformance/server-parity/native-image-corpus.sh b/scripts/conformance/server-parity/native-image-corpus.sh new file mode 100755 index 000000000..3880b33ab --- /dev/null +++ b/scripts/conformance/server-parity/native-image-corpus.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Differential qualification of a native server image (Server #325): record +# the shared fixture corpus on SQLite against the published PHP reference, +# embedded PHP and the given native image, and compare the three records, as +# the Shared Server fixtures corpus part does for a native source build. +# +# native-image-corpus.sh +# +# The reference is a digest-pinned image (name@sha256:...), such as a +# published native release-candidate index or a pull request's local build +# tag. The native server runs from the image itself: its entrypoint, its user +# and its maintenance loop, with SQLite in its /app/database volume. +# +# Containers and the network are named from RESOURCE_SCOPE exactly as the +# corpus part names them, dw-parity---, because the shared +# PHP maintenance window finds its container by that name. So +# update-validation-sqlite.sh records the separate update-validation fixture +# unchanged. The workflow removes them in an always step. Evidence lands in +# parity-evidence/, with a receipt naming the image. +set -euo pipefail +shopt -s inherit_errexit +test "$#" = 1 || { echo "usage: $0 " >&2; exit 2; } +native=$1 +[[ $native =~ @sha256:[0-9a-f]{64}$ || $native =~ ^dw-native-[a-z0-9-]+:[a-z0-9.-]+$ ]] \ + || { echo "The native image must be pinned by digest (name@sha256:...) or be this job's local build." >&2; exit 2; } +scope="dw-parity-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${GITHUB_JOB:-parity}" +[[ "${RESOURCE_SCOPE:-$scope}" == "$scope" ]] \ + || { echo "RESOURCE_SCOPE must be $scope, the name the PHP maintenance window expects." >&2; exit 2; } +export RESOURCE_SCOPE="$scope" +export DW_PARITY_RUNNER_REVISION +DW_PARITY_RUNNER_REVISION=$(git rev-parse HEAD) +image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json) + +# CI's corpus selection: the reviewed corpus without update-validation and +# service-calls, which record separately below with PHP's maintenance loop +# stopped (Server #723, #796), plus the pending candidates it carries. +selection=(--exclude-fixture tests/Fixtures/ServerParity/update-validation.json + --exclude-fixture tests/Fixtures/ServerParity/service-calls.json) +for pending in patch-insertion-activity-pending patch-insertion-activity-completed patch-created-activity-pending \ + patch-created-activity-completed workflow-waiting-history-type workflow-waiting-history-message workflow-task-transient-retry; do + selection+=(--include-fixture "tests/Fixtures/ServerParityPending/$pending.json") +done + +mkdir -p parity-state parity-evidence +chmod 0777 parity-state parity-evidence +touch parity-state/php.sqlite parity-state/embedded.sqlite +chmod 0666 parity-state/*.sqlite +bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ + install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress +docker network create "$RESOURCE_SCOPE" >/dev/null + +# The native image under test, as an operator runs it. The shared state +# directory is also its /state, where fixtures put their external payload +# storage roots (file:///state/...), as on every corpus server. +docker run -d --name "$RESOURCE_SCOPE-rust" --network "$RESOURCE_SCOPE" --network-alias=parity-rust \ + --cpus=1 --memory=256m --memory-swap=256m -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token \ + -e DB_DATABASE=/app/database/rust.sqlite -v "$PWD/parity-state:/app/database" -v "$PWD/parity-state:/state" \ + "$native" >/dev/null + +# Published PHP and embedded PHP, as the corpus part runs them. +for target in php embedded; do + docker run --rm --user=1000:1000 --entrypoint php \ + -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE="/state/$target.sqlite" \ + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ + "$image" artisan server:bootstrap --force >> parity-evidence/bootstrap.txt +done +docker run -d --name "$RESOURCE_SCOPE-server" --network "$RESOURCE_SCOPE" --network-alias=parity-server \ + --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ + -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/php.sqlite -e DB_SQLITE_BUSY_TIMEOUT=30000 \ + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token "$image" >/dev/null +for target in server rust; do + ready=0 + for _ in $(seq 1 30); do + if docker run --rm --user=1000:1000 --entrypoint curl --network "$RESOURCE_SCOPE" "$image" \ + -fsS --connect-timeout 2 --max-time 5 "http://parity-$target:8080/api/ready" > "parity-evidence/$target-readiness.json"; then + ready=1 + break + fi + sleep 1 + done + test "$ready" = 1 +done +docker run -d --name "$RESOURCE_SCOPE-php-maintenance" --network "$RESOURCE_SCOPE" \ + --user=1000:1000 --cpus=0.25 --memory=256m --memory-swap=256m --entrypoint sh \ + -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/php.sqlite -e DB_SQLITE_BUSY_TIMEOUT=30000 \ + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ + "$image" -c 'while :; do php artisan workflow:v2:repair-pass --json; php artisan schedule:evaluate --limit=100 --json; sleep 1; done' >/dev/null + +mkdir -p parity-consumers +curl --fail --location --retry 3 --silent --output parity-consumers/dw.phar https://github.com/durable-workflow/cli/releases/download/2.2.0/dw.phar +printf '%s %s\n' "$(jq -r '.visibility.cli.phar_sha256' tests/Fixtures/ServerParity/visibility-current-runs.json)" parity-consumers/dw.phar \ + | sha256sum -c > parity-evidence/cli-artifact.txt +export DW_PARITY_CLI_PHAR=/source/parity-consumers/dw.phar + +http_flags=(--network "$RESOURCE_SCOPE" -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source + -e DW_PARITY_TOKEN=parity-test-token -e DW_PARITY_RUNNER_REVISION -e DW_PARITY_CLI_PHAR) +record_http() { + local target=$1 url=$2 + docker run --rm --user=1000:1000 --entrypoint node "${http_flags[@]}" "$image" scripts/conformance/server-parity.mjs record \ + --mode http --url "$url" --target "$target" --output "/evidence/$target.json" "${selection[@]}" +} +record_embedded() { + docker run --rm --user=1000:1000 --entrypoint node \ + -v "$PWD:/source:ro" -v "$PWD/parity-state:/state" -v "$PWD/parity-evidence:/evidence" -w /source \ + -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/embedded.sqlite -e DW_MODE=embedded \ + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_PARITY_RUNNER_REVISION "$image" scripts/conformance/server-parity.mjs record \ + --mode embedded --application-root /app --target embedded --output /evidence/embedded.json "${selection[@]}" +} +status=0 +record_http php http://parity-server:8080 || status=$? +record_embedded || status=$? +native_status=0 +record_http rust http://parity-rust:8080 || native_status=$? +compare_status=0 +docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ + scripts/conformance/server-parity.mjs compare /evidence/php.json /evidence/rust.json /evidence/embedded.json "${selection[@]}" \ + > parity-evidence/comparison.json || compare_status=$? +update_validation_status=0 +bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 || update_validation_status=$? +service_calls_status=0 +bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 service-calls \ + || service_calls_status=$? + +native_id=$(docker image inspect --format '{{.Id}}' "$native") +jq -n --arg native "$native" --arg id "$native_id" --arg php "$image" --arg revision "$DW_PARITY_RUNNER_REVISION" \ + --argjson reference "$status" --argjson nativeStatus "$native_status" --argjson compare "$compare_status" \ + --argjson updateValidation "$update_validation_status" --argjson serviceCalls "$service_calls_status" \ + '{schema:"durable-workflow.native-image-corpus.v1", native_image:$native, native_image_id:$id, php_reference:$php, + runner_revision:$revision, backend:"sqlite", + status:{reference_records:$reference, native_record:$nativeStatus, compare:$compare, update_validation:$updateValidation, + service_calls:$serviceCalls}}' \ + > parity-evidence/native-image-corpus.json +cat parity-evidence/native-image-corpus.json +test "$status" -eq 0 +test "$native_status" -eq 0 +test "$compare_status" -eq 0 +test "$update_validation_status" -eq 0 +test "$service_calls_status" -eq 0 From 2e1b63e151676b0c06bfa6ed31d693d4870950e9 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:02:32 +0000 Subject: [PATCH 2/6] Publish native release candidates from native- Git tags General's release decision (server-325 chat 2922): a native release candidate is its own immutable Git tag, native- (for example native-2.12.0-rc.1), bound to one full commit. The PHP Release workflow runs on every pushed tag matching [0-9]+.[0-9]+.[0-9]+*, so a plain version tag would also start a PHP release; a native- tag never matches. - verify-release-tag-source.sh takes RELEASE_TAG_PREFIX, which may only be empty (the PHP release, unchanged) or native-. The full Git tag is verified against the planned commit exactly as before. - native-release.yml passes native- to it in both architecture jobs. - native-release-publish.sh accepts only native- and tags both registries with the version, the prefix stripped. - check-native-release-tags.sh proves it: release.yml's push-tag patterns match no native- tag and still match plain versions; the verifier and the publisher accept native- only as intended (each stopped at its input checks, before any API or registry call). Native image runs it on pull requests that change any of these files or release.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image.yml | 6 +++ .github/workflows/native-release.yml | 10 ++-- scripts/ci/check-native-release-tags.sh | 61 +++++++++++++++++++++++++ scripts/ci/native-release-publish.sh | 24 ++++++---- scripts/ci/verify-release-tag-source.sh | 9 +++- 5 files changed, 95 insertions(+), 15 deletions(-) create mode 100755 scripts/ci/check-native-release-tags.sh diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml index ecb295a0b..de1cd62dd 100644 --- a/.github/workflows/native-image.yml +++ b/.github/workflows/native-image.yml @@ -13,8 +13,11 @@ on: - Dockerfile.native.dockerignore - scripts/ci/native-image-smoke.sh - scripts/ci/native-release-publish.sh + - scripts/ci/check-native-release-tags.sh + - scripts/ci/verify-release-tag-source.sh - .github/workflows/native-image.yml - .github/workflows/native-release.yml + - .github/workflows/release.yml permissions: contents: read @@ -44,6 +47,9 @@ jobs: - name: Check out uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Check that native tags start no PHP release + run: bash scripts/ci/check-native-release-tags.sh + - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 767b8b16c..bedd49024 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -1,7 +1,9 @@ name: Native release candidate -# Publishes the native (Rust) server image for an existing immutable release -# tag (Server #325). Each architecture builds on its own runner and pushes by +# 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 # 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 @@ -14,7 +16,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Existing immutable release tag to publish natively (for example 2.12.0-rc.1)' + description: 'Existing immutable native Git tag, native- (for example native-2.12.0-rc.1); the image tag drops native-' required: true release_commit: description: 'Full source commit of that tag' @@ -62,6 +64,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} + RELEASE_TAG_PREFIX: native- RELEASE_COMMIT: ${{ inputs.release_commit }} run: | set -euo pipefail @@ -150,6 +153,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} + RELEASE_TAG_PREFIX: native- RELEASE_COMMIT: ${{ inputs.release_commit }} run: | set -euo pipefail diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh new file mode 100755 index 000000000..ee85da9c6 --- /dev/null +++ b/scripts/ci/check-native-release-tags.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Native release-candidate tags are their own Git tags, native- +# (Server #325). Check that: +# - the PHP Release workflow's push-tag filter matches no native- tag, so a +# native tag starts no PHP release, and still matches plain versions; +# - the tag verifier accepts native- only when asked for it; +# - the native publisher accepts only native- tags. +# Each script is stopped at its input checks, before any API or registry call. +set -euo pipefail +fail() { printf 'Native release tag check failed: %s\n' "$1" >&2; exit 1; } + +# The tag patterns under release.yml's push trigger. +mapfile -t patterns < <(awk ' + /^ push:/ { push = 1; next } + push && /^ [a-z_]+:/ { push = 0 } + push && /^ tags:/ { tags = 1; next } + tags && /^ - / { sub(/^ - /, ""); gsub(/\x27|"/, ""); print; next } + tags { tags = 0 } +' .github/workflows/release.yml) +test "${#patterns[@]}" -ge 1 || fail "release.yml has no push tag patterns." + +# A GitHub filter pattern as an anchored extended regular expression: +# [..] and + keep their meaning, * matches anything but /, the rest is literal. +matches() { + local pattern=$1 tag=$2 regex + regex=$(printf '%s' "$pattern" | sed -e 's/[.]/\\./g' -e 's#[*]#[^/]*#g') + [[ $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 + if matches "$pattern" "$tag"; then fail "release.yml tag pattern $pattern matches $tag."; fi + done +done +for tag in 2.11.4 2.12.0-rc.1; do + matched=0 + for pattern in "${patterns[@]}"; do + if matches "$pattern" "$tag"; then matched=1; fi + done + test "$matched" = 1 || fail "no release.yml tag pattern matches the plain version $tag." +done + +expect() { + local message=$1; shift + local output + if output=$(env -i PATH="$PATH" "$@" 2>&1); then fail "expected a refusal containing: $message"; fi + grep -qF -- "$message" <<<"$output" || fail "expected '$message', got: $output" +} +verify=scripts/ci/verify-release-tag-source.sh +# An invalid commit stops the verifier right after the tag format is accepted. +expect "RELEASE_COMMIT must be" RELEASE_TAG=2.12.0-rc.1 RELEASE_COMMIT=x bash "$verify" +expect "RELEASE_COMMIT must be" RELEASE_TAG=native-2.12.0-rc.1 RELEASE_TAG_PREFIX=native- RELEASE_COMMIT=x bash "$verify" +expect "must be an exact native-release version" RELEASE_TAG=2.12.0-rc.1 RELEASE_TAG_PREFIX=native- RELEASE_COMMIT=x bash "$verify" +expect "must be an exact release version" RELEASE_TAG=native-2.12.0-rc.1 RELEASE_COMMIT=x bash "$verify" +expect "may only be empty or native-" RELEASE_TAG=x-2.12.0 RELEASE_TAG_PREFIX=x- RELEASE_COMMIT=x bash "$verify" + +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[@]}" bash "$publish" +# No digests stops the publisher right after the native tag is accepted. +expect "exact smoked digest" RELEASE_TAG=native-2.12.0-rc.1 "${images[@]}" bash "$publish" +printf 'Native release tags: %d release.yml pattern(s) checked; verifier and publisher accept native- only as intended.\n' "${#patterns[@]}" diff --git a/scripts/ci/native-release-publish.sh b/scripts/ci/native-release-publish.sh index e5b6df6bd..36cc8e588 100755 --- a/scripts/ci/native-release-publish.sh +++ b/scripts/ci/native-release-publish.sh @@ -1,12 +1,16 @@ #!/usr/bin/env bash # Give the smoked native platform digests (Server #325) one release tag in # both registries, as one index, and verify it. +# RELEASE_TAG is the native Git tag, native-; the image tag +# is that version without the native- prefix. # Env: RELEASE_TAG, GHCR_IMAGE, DOCKERHUB_IMAGE, and DIGEST_AMD64 and # DIGEST_ARM64 from the build jobs, which push by digest and fail unless that # 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:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] || fail "RELEASE_TAG must be an exact release version." +[[ "${RELEASE_TAG:-}" =~ ^native-([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?)$ ]] \ + || fail "RELEASE_TAG must be native-." +image_tag=${BASH_REMATCH[1]} : "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" digests=("${DIGEST_AMD64:-}" "${DIGEST_ARM64:-}") @@ -18,35 +22,35 @@ done # Only a registry's own "not found" counts as absent; any other failure to # look (authentication, network) stops here rather than risk an overwrite. for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do - if lookup=$(docker buildx imagetools inspect "$image:$RELEASE_TAG" 2>&1); then - fail "$image:$RELEASE_TAG already exists." + if lookup=$(docker buildx imagetools inspect "$image:$image_tag" 2>&1); then + fail "$image:$image_tag already exists." fi grep -qiE 'not found|manifest unknown|name unknown' <<<"$lookup" \ - || fail "could not confirm that $image:$RELEASE_TAG is absent: $(head -c 300 <<<"$lookup")" + || fail "could not confirm that $image:$image_tag is absent: $(head -c 300 <<<"$lookup")" done for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do - docker buildx imagetools create --tag "$image:$RELEASE_TAG" \ + docker buildx imagetools create --tag "$image:$image_tag" \ "$image@${digests[0]}" "$image@${digests[1]}" done # Both registries hold the same index, naming exactly the smoked digests. index="" for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do - raw=$(docker buildx imagetools inspect --raw "$image:$RELEASE_TAG") - digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$RELEASE_TAG") + raw=$(docker buildx imagetools inspect --raw "$image:$image_tag") + digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$image_tag") if test -z "$index"; then index=$digest; fi - test "$digest" = "$index" || fail "$image:$RELEASE_TAG is $digest, not $index." + test "$digest" = "$index" || fail "$image:$image_tag is $digest, not $index." jq -e --arg amd64 "${digests[0]}" --arg arm64 "${digests[1]}" ' [.manifests[] | {digest, platform: "\(.platform.os)/\(.platform.architecture)"}] | sort_by(.platform) == [{digest: $amd64, platform: "linux/amd64"}, {digest: $arm64, platform: "linux/arm64"}]' <<<"$raw" >/dev/null \ - || fail "$image:$RELEASE_TAG does not name exactly the smoked platform digests." + || fail "$image:$image_tag does not name exactly the smoked platform digests." done printf 'digest=%s\n' "$index" >> "${GITHUB_OUTPUT:-/dev/null}" { printf '### Native %s\n\n' "$RELEASE_TAG" - printf -- '- index `%s`, in `%s` and `%s`\n' "$index" "$GHCR_IMAGE:$RELEASE_TAG" "$DOCKERHUB_IMAGE:$RELEASE_TAG" + printf -- '- index `%s`, in `%s` and `%s`\n' "$index" "$GHCR_IMAGE:$image_tag" "$DOCKERHUB_IMAGE:$image_tag" printf -- '- linux/amd64 `%s`, linux/arm64 `%s`, each smoke-tested on its own architecture\n' "${digests[0]}" "${digests[1]}" } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" printf '%s\n' "$index" diff --git a/scripts/ci/verify-release-tag-source.sh b/scripts/ci/verify-release-tag-source.sh index 17c0a7be3..b7b0cbdd6 100755 --- a/scripts/ci/verify-release-tag-source.sh +++ b/scripts/ci/verify-release-tag-source.sh @@ -12,8 +12,13 @@ release_commit="${RELEASE_COMMIT:-}" repository="${GITHUB_REPOSITORY:-}" gh_cli="${GH_CLI:-gh}" -[[ "$release_tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \ - || fail "RELEASE_TAG must be an exact release version." +# A native release-candidate tag is its own Git tag, native-, so it +# never starts the PHP Release workflow; it is verified the same way. +release_tag_prefix="${RELEASE_TAG_PREFIX:-}" +[[ -z "$release_tag_prefix" || "$release_tag_prefix" = native- ]] \ + || fail "RELEASE_TAG_PREFIX may only be empty or native-." +[[ "$release_tag" =~ ^${release_tag_prefix}[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \ + || fail "RELEASE_TAG must be an exact ${release_tag_prefix}release version." [[ "$release_commit" =~ ^[0-9a-f]{40}$ ]] \ || fail "RELEASE_COMMIT must be a full lowercase Git commit SHA." [[ "$repository" =~ ^[0-9A-Za-z_.-]+/[0-9A-Za-z_.-]+$ ]] \ From 02dbad62b5d3dcb3b2aeb923d050402861785564 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:10:03 +0000 Subject: [PATCH 3/6] Rust #325: external-worker completions meet the pending-count limits Workflow #933: the package's bridge never applied the embedded executor's pending activity, child and timer limits to external workers, and native followed it. Native now checks each before writing a schedule_activity, start_child_workflow or start_timer: a run already holding the limit refuses the whole completion with PHP's WorkerController answer for StructuralLimitExceededException (422 structural_limit_exceeded, the limit kind, current value and configured limit), nothing is written, and the task keeps its lease. DW_V2_LIMIT_PENDING_ACTIVITIES, _CHILDREN and _TIMERS are honored instead of refused or ignored, with PHP's defaults (2000, 1000, 2000), and the structural limit snapshot reports the values in force. Core 2.14.1 shipped the package side (Workflow #939), so the published 2.11.4 reference enforces these limits; native had not, until now. Co-Authored-By: Claude Opus 5.5 --- rust/src/config_audit.rs | 6 +-- rust/src/runtime/memo_limits.rs | 47 +++++++++++++++++++ rust/src/runtime/operator_metrics.rs | 6 +-- rust/src/runtime/store.rs | 33 ++++++++++++++ rust/tests/execution.rs | 2 + rust/tests/execution/pending_limits.rs | 62 ++++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 rust/tests/execution/pending_limits.rs diff --git a/rust/src/config_audit.rs b/rust/src/config_audit.rs index 1e583efed..97085af75 100644 --- a/rust/src/config_audit.rs +++ b/rust/src/config_audit.rs @@ -685,10 +685,10 @@ pub const SETTINGS: &[Setting] = &[ Setting { name: "DW_V2_LIMIT_HISTORY_TRANSACTION_SIZE", legacy: Some("WORKFLOW_V2_LIMIT_HISTORY_TRANSACTION_SIZE"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_MEMO_SIZE_BYTES", legacy: Some("WORKFLOW_V2_LIMIT_MEMO_SIZE_BYTES"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_PAYLOAD_SIZE_BYTES", legacy: Some("WORKFLOW_V2_LIMIT_PAYLOAD_SIZE_BYTES"), disposition: Honored }, - Setting { name: "DW_V2_LIMIT_PENDING_ACTIVITIES", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_ACTIVITIES"), disposition: Refused(Int(2000), STRUCTURAL) }, - Setting { name: "DW_V2_LIMIT_PENDING_CHILDREN", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_CHILDREN"), disposition: Ignored }, + Setting { name: "DW_V2_LIMIT_PENDING_ACTIVITIES", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_ACTIVITIES"), disposition: Honored }, + Setting { name: "DW_V2_LIMIT_PENDING_CHILDREN", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_CHILDREN"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_PENDING_SIGNALS", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_SIGNALS"), disposition: Honored }, - Setting { name: "DW_V2_LIMIT_PENDING_TIMERS", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_TIMERS"), disposition: Ignored }, + Setting { name: "DW_V2_LIMIT_PENDING_TIMERS", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_TIMERS"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_PENDING_UPDATES", legacy: Some("WORKFLOW_V2_LIMIT_PENDING_UPDATES"), disposition: Honored }, Setting { name: "DW_V2_LIMIT_SEARCH_ATTRIBUTE_SIZE_BYTES", legacy: Some("WORKFLOW_V2_LIMIT_SEARCH_ATTRIBUTE_SIZE_BYTES"), disposition: Ignored }, Setting { name: "DW_V2_LIMIT_WARNING_THRESHOLD_PERCENT", legacy: Some("WORKFLOW_V2_LIMIT_WARNING_THRESHOLD_PERCENT"), disposition: Ignored }, diff --git a/rust/src/runtime/memo_limits.rs b/rust/src/runtime/memo_limits.rs index 595b91f33..d29d02596 100644 --- a/rust/src/runtime/memo_limits.rs +++ b/rust/src/runtime/memo_limits.rs @@ -84,6 +84,13 @@ fn configured(suffix: &str, default: usize, invalid: &'static str) -> Result, + /// Open child workflows a run may hold (DW_V2_LIMIT_PENDING_CHILDREN). + pub pending_children: Option, + /// Pending timers a run may hold (DW_V2_LIMIT_PENDING_TIMERS). + pub pending_timers: Option, /// Received signals a run may hold (DW_V2_LIMIT_PENDING_SIGNALS). pub pending_signals: Option, /// Accepted updates a run may hold (DW_V2_LIMIT_PENDING_UPDATES). @@ -106,6 +113,9 @@ pub struct StructuralLimits { impl Default for StructuralLimits { fn default() -> Self { Self { + pending_activities: Some(2000), + pending_children: Some(1000), + pending_timers: Some(2000), pending_signals: Some(5000), pending_updates: Some(500), payload_bytes: Some(2_097_152), @@ -121,6 +131,9 @@ impl StructuralLimits { let defaults = Self::default(); let invalid = "invalid_structural_limit_configuration"; Ok(Self { + pending_activities: configured("PENDING_ACTIVITIES", defaults.pending_activities.unwrap(), invalid)?, + pending_children: configured("PENDING_CHILDREN", defaults.pending_children.unwrap(), invalid)?, + pending_timers: configured("PENDING_TIMERS", defaults.pending_timers.unwrap(), invalid)?, pending_signals: configured("PENDING_SIGNALS", defaults.pending_signals.unwrap(), invalid)?, pending_updates: configured("PENDING_UPDATES", defaults.pending_updates.unwrap(), invalid)?, payload_bytes: configured("PAYLOAD_SIZE_BYTES", defaults.payload_bytes.unwrap(), invalid)?, @@ -131,6 +144,14 @@ impl StructuralLimits { } } +/// The pending rows a completion command adds one of. +#[derive(Clone, Copy, Debug)] +pub(super) enum Pending { + Activities, + Children, + Timers, +} + /// One exceeded ceiling, as `StructuralLimitExceededException` reports it. #[derive(Clone, Debug)] pub(super) struct Exceeded { @@ -146,6 +167,32 @@ impl Exceeded { Self { kind, value: int(value), configured: int(configured), message } } + /// `guardPendingActivities`, `guardPendingChildren` and + /// `guardPendingTimers` (Workflow #933): a run's pending rows of that kind + /// at or above the ceiling. + pub(super) fn pending(kind: Pending, limit: Option, count: i64) -> Option { + let (limit, count) = (limit?, usize::try_from(count).unwrap_or(0)); + let (name, noun) = match kind { + Pending::Activities => ("pending_activity_count", "pending activities"), + Pending::Children => ("pending_child_count", "pending child workflows"), + Pending::Timers => ("pending_timer_count", "pending timers"), + }; + (count >= limit).then(|| Self::new(name, count, limit, + format!("Structural limit exceeded: {count} {noun} (limit {limit})."))) + } + + /// The worker's refusal of a completion that met this ceiling, as + /// `WorkerController` answers `StructuralLimitExceededException`: 422, + /// nothing recorded. + pub(super) fn worker_refusal(&self, task_id: &str, attempt: &serde_json::Value) -> super::RuntimeError { + super::RuntimeError::Protocol { + status: axum::http::StatusCode::UNPROCESSABLE_ENTITY, + response: serde_json::json!({"task_id":task_id,"workflow_task_attempt":attempt,"outcome":"rejected", + "recorded":false,"error":self.message,"reason":"structural_limit_exceeded","limit_kind":self.kind, + "current_value":self.value,"configured_limit":self.configured}), + } + } + /// `guardPendingSignals`: received signals at or above the ceiling. pub(super) fn pending_signals(limit: Option, count: i64) -> Option { let (limit, count) = (limit?, usize::try_from(count).unwrap_or(0)); diff --git a/rust/src/runtime/operator_metrics.rs b/rust/src/runtime/operator_metrics.rs index 3409aede0..7da7254c9 100644 --- a/rust/src/runtime/operator_metrics.rs +++ b/rust/src/runtime/operator_metrics.rs @@ -1623,9 +1623,9 @@ fn empty_repair_snapshot() -> Value { fn structural_limits(connection: &str, limits: &super::StructuralLimits, memo: Option) -> Value { let shown = |limit: Option| limit.unwrap_or(0); let configured = json!({ - "pending_activity_count": 2000, - "pending_child_count": 1000, - "pending_timer_count": 2000, + "pending_activity_count": shown(limits.pending_activities), + "pending_child_count": shown(limits.pending_children), + "pending_timer_count": shown(limits.pending_timers), "pending_signal_count": shown(limits.pending_signals), "pending_update_count": shown(limits.pending_updates), "command_batch_size": 1000, diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index 4bebaffac..98781bd4a 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -1123,6 +1123,36 @@ where Self::enqueue_next_message(tx, write.run_id, queue).await } + /// `StructuralLimits::guardPendingActivities`, `guardPendingChildren` and + /// `guardPendingTimers` before the bridge writes one more (Workflow #933): + /// a run already holding the limit refuses the whole completion. + async fn guard_pending( + tx: &mut Transaction<'_, DB>, + run_id: &str, + kind: super::memo_limits::Pending, + limits: &super::memo_limits::StructuralLimits, + task_id: &str, + attempt: &Value, + ) -> Result<()> { + use super::memo_limits::Pending; + let (limit, sql) = match kind { + Pending::Activities => (limits.pending_activities, + "SELECT COUNT(*) FROM activity_executions WHERE workflow_run_id=$1 AND status IN ('pending','running')"), + Pending::Timers => (limits.pending_timers, + "SELECT COUNT(*) FROM workflow_run_timers WHERE workflow_run_id=$1 AND status='pending'"), + Pending::Children => (limits.pending_children, + "SELECT COUNT(*) FROM workflow_links l JOIN workflow_runs r ON r.id=l.child_workflow_run_id WHERE l.parent_workflow_run_id=$1 AND l.link_type='child_workflow' AND r.status NOT IN ('completed','failed','cancelled','terminated')"), + }; + if limit.is_none() { + return Ok(()); + } + let count = Self::scalar(sql).bind(run_id).fetch_one(&mut **tx).await?; + match super::memo_limits::Exceeded::pending(kind, limit, count) { + Some(exceeded) => Err(exceeded.worker_refusal(task_id, attempt)), + None => Ok(()), + } + } + async fn advance_signal_sequences(tx: &mut Transaction<'_, DB>, write: &SignalWrite<'_>) -> Result<()> { Self::query("UPDATE workflow_runs SET last_command_sequence=$1 WHERE id=$2") .bind(write.command_sequence) @@ -2962,6 +2992,7 @@ where created.push(self.continue_run(&mut tx, &run, &task, command, sequence).await?); } } else if command["type"] == "start_child_workflow" { + Self::guard_pending(&mut tx, &run_id, super::memo_limits::Pending::Children, &limits, task_id, &body["workflow_task_attempt"]).await?; sequence += 1; created.push(Self::start_child(&mut tx, &run, task_id, command, sequence).await?); } else if command["type"] == super::local_activities::COMMAND { @@ -2998,6 +3029,7 @@ where self.upsert_search_attributes(&mut tx, &run, &task, command, index, sequence) .await?; } else if command["type"] == "schedule_activity" { + Self::guard_pending(&mut tx, &run_id, super::memo_limits::Pending::Activities, &limits, task_id, &body["workflow_task_attempt"]).await?; sequence += 1; let metadata = super::selections::metadata(command, "activity")?; super::selections::check_sequence(&metadata, sequence)?; @@ -3091,6 +3123,7 @@ where .execute(&mut *tx) .await?; } else if command["type"] == "start_timer" { + Self::guard_pending(&mut tx, &run_id, super::memo_limits::Pending::Timers, &limits, task_id, &body["workflow_task_attempt"]).await?; sequence += 1; let metadata = super::selections::metadata(command, "timer")?; super::selections::check_sequence(&metadata, sequence)?; diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index c1dfa961b..2b97f2278 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -206,6 +206,8 @@ mod start_request; mod command_requests; #[path = "execution/bridge_adapters.rs"] mod bridge_adapters; +#[path = "execution/pending_limits.rs"] +mod pending_limits; #[path = "execution/describe_actions.rs"] mod describe_actions; #[path = "execution/run_deadlines.rs"] diff --git a/rust/tests/execution/pending_limits.rs b/rust/tests/execution/pending_limits.rs new file mode 100644 index 000000000..befb278eb --- /dev/null +++ b/rust/tests/execution/pending_limits.rs @@ -0,0 +1,62 @@ +use super::*; +use durable_workflow_server::runtime::StructuralLimits; + +// Workflow #933: external-worker completions meet the pending-count limits +// the embedded executor applies. A command past its limit refuses the whole +// completion with PHP's `structural_limit_exceeded` answer, nothing of it is +// written, and the task keeps its lease. + +fn command(kind: &str) -> Value { + match kind { + "activity" => json!({"type":"schedule_activity","activity_type":"activity-echo","arguments":envelope(Payload::Array(vec![]))}), + "timer" => json!({"type":"start_timer","delay_seconds":60}), + _ => child_command(1), + } +} + +#[tokio::test] +async fn a_pending_count_past_its_limit_refuses_the_whole_completion() { + for (kind, limit_kind, noun, limits) in [ + ("activity", "pending_activity_count", "pending activities", + StructuralLimits { pending_activities: Some(1), ..StructuralLimits::default() }), + ("timer", "pending_timer_count", "pending timers", + StructuralLimits { pending_timers: Some(1), ..StructuralLimits::default() }), + ("child", "pending_child_count", "pending child workflows", + StructuralLimits { pending_children: Some(1), ..StructuralLimits::default() }), + ] { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap().with_structural_limits(limits)); + register(&app, "worker", json!(["echo", "child"]), json!(["activity-echo"])).await; + let run = start(&app, &format!("pending-{kind}")).await; + let task = poll(&app, "worker", "workflow").await; + let before = run_history(&app, &run["workflow_id"], &run["run_id"]).await; + + // The first command meets a limit of one; the second passes it. + let (status, body) = finish_task(&app, &task, json!([command(kind), command(kind)])).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{kind}: {body}"); + assert_eq!(body, json!({"task_id":task["task_id"],"workflow_task_attempt":task["workflow_task_attempt"], + "outcome":"rejected","recorded":false,"error":format!("Structural limit exceeded: 1 {noun} (limit 1)."), + "reason":"structural_limit_exceeded","limit_kind":limit_kind,"current_value":1,"configured_limit":1}), "{kind}"); + assert_eq!(run_history(&app, &run["workflow_id"], &run["run_id"]).await, before, "{kind}: nothing written"); + + // The lease is kept, and one command within the limit is admitted. + let (status, body) = finish_task(&app, &task, json!([command(kind)])).await; + assert_eq!(status, StatusCode::OK, "{kind}: {body}"); + } +} + +#[tokio::test] +async fn the_snapshot_reports_the_pending_limits_it_enforces() { + let database = TestDatabase::new().await; + let limits = StructuralLimits { pending_activities: Some(7), pending_children: Some(8), pending_timers: Some(9), + ..StructuralLimits::default() }; + let app = router(database.open().await.unwrap().with_structural_limits(limits)); + let (status, metrics) = request(&app, "GET", "/api/system/operator-metrics", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{metrics}"); + let (status, health) = request(&app, "GET", "/api/system/health", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{health}"); + for limits in [&metrics["operator_metrics"]["structural_limits"]["configured"], &health["health"]["structural_limits"]["configured"]] { + assert_eq!((&limits["pending_activity_count"], &limits["pending_child_count"], &limits["pending_timer_count"]), + (&json!(7), &json!(8), &json!(9)), "{limits}"); + } +} From 33a653f4a519416b680b5838a3caef4304cfbe5d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:46:33 +0000 Subject: [PATCH 4/6] Give native publication its own empty Environment and secret names native-release.yml used the PHP release's dockerhub Environment and the repository's DOCKERHUB_USERNAME / DOCKERHUB_TOKEN, so a dispatch would resolve the predecessor publisher credential even though native has never published. Native now names the dockerhub-native Environment and NATIVE_DOCKERHUB_USERNAME / NATIVE_DOCKERHUB_TOKEN. While they are absent the workflow fails closed: each job signs in to Docker Hub before it builds, so a dispatch stops at sign-in and pushes nothing to either registry. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-release.yml | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index bedd49024..ee87d19eb 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -9,6 +9,12 @@ name: Native release candidate # exist, gives both smoked digests # the release tag in both registries as one index, verifies it, and attests # its build provenance. The PHP image and its Release workflow are unchanged. +# +# Its credentials are its own: the dockerhub-native Environment and the +# NATIVE_DOCKERHUB_* secret names. While they are absent the workflow fails +# closed: each job signs in to Docker Hub before it builds anything, so a +# dispatch stops there and pushes nothing. It can never resolve the PHP +# release's Environment or repository credentials. run-name: Native ${{ inputs.tag }} at ${{ inputs.release_commit }} @@ -39,7 +45,7 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 75 - environment: dockerhub + environment: dockerhub-native permissions: contents: read packages: write @@ -77,8 +83,8 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ secrets.NATIVE_DOCKERHUB_USERNAME }} + password: ${{ secrets.NATIVE_DOCKERHUB_TOKEN }} - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 @@ -128,7 +134,7 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04-arm timeout-minutes: 75 - environment: dockerhub + environment: dockerhub-native permissions: contents: read packages: write @@ -166,8 +172,8 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ secrets.NATIVE_DOCKERHUB_USERNAME }} + password: ${{ secrets.NATIVE_DOCKERHUB_TOKEN }} - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 @@ -218,7 +224,7 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 20 - environment: dockerhub + environment: dockerhub-native permissions: contents: read packages: write @@ -236,8 +242,8 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ secrets.NATIVE_DOCKERHUB_USERNAME }} + password: ${{ secrets.NATIVE_DOCKERHUB_TOKEN }} - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 From 98334052bd5fdae29aa180ab1ce1eb5a5bee2a08 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:32:17 +0000 Subject: [PATCH 5/6] Record the image parity script's Composer install as a reviewed sink native-image-corpus.sh installs the conformance runner's dependencies with the same scripts/ci/run-composer.sh invocation the Shared Server fixtures workflow uses, so it carries the same confinement: the pinned Composer image after the fixed Brotli decoder, with the checked CA file and an empty CA directory set after the caller's options. Co-Authored-By: Claude Opus 5.5 --- tests/Unit/Support/composer-sinks.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index e716953a4..c1de3ba44 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -859,6 +859,17 @@ } ] }, + { + "path": "scripts/conformance/server-parity/native-image-corpus.sh", + "line": "bash scripts/ci/run-composer.sh --rm -v \"$PWD:/source\" -w /source -- \\", + "count": 1, + "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either, an entrypoint, an env file, /checked-ca or --volumes-from.", + "guards": [ + { + "type": "composer-wrapper" + } + ] + }, { "path": "scripts/conformance/server-parity/query-authority-cohort.sh", "line": "bash scripts/ci/run-composer.sh --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \\", From d51dd139cc93d6fb876954309a63ac2c4d4a6968 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:45:25 +0000 Subject: [PATCH 6/6] Pull the pinned Composer image with registry retries before fixture jobs Two of #810's fixture jobs (execution-and-codec corpus, postgres-storage restarts) failed at their first Composer run when Docker Hub answered the pinned composer:2.10.3 manifest with HTTP 500 (run 40474209808). Every other pinned image those jobs use is pulled first by pull-images.sh, which retries registry errors; the Composer image was left to the first docker run. The MySQL, PostgreSQL and SQLite fixture jobs, and the native image parity job, now pull it there too. The reference is recorded in composer-sinks.json as the same pinned Composer image. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image-parity.yml | 7 +++++ .github/workflows/server-parity.yml | 9 ++++-- tests/Unit/Support/composer-sinks.json | 36 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/workflows/native-image-parity.yml b/.github/workflows/native-image-parity.yml index 17127bd00..808b6f341 100644 --- a/.github/workflows/native-image-parity.yml +++ b/.github/workflows/native-image-parity.yml @@ -43,6 +43,13 @@ jobs: - name: Check out uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Pull the pinned images, retrying registry errors + run: | + set -euo pipefail + bash scripts/ci/pull-images.sh \ + "$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.4.json)" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee + - name: Set up Docker Buildx if: github.event_name == 'pull_request' uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 31dac1aa8..7a3a20418 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -416,7 +416,8 @@ jobs: mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ "$MYSQL_IMAGE" \ "$(baseline php-2.11.4)" \ - "$(baseline php-2.5.14)" + "$(baseline php-2.5.14)" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -1101,7 +1102,8 @@ jobs: mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ "$POSTGRES_IMAGE" \ "$(baseline php-2.11.4)" \ - "$(baseline php-2.5.14)" + "$(baseline php-2.5.14)" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -1800,7 +1802,8 @@ jobs: bash scripts/ci/pull-images.sh \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 \ "$(baseline php-2.11.4)" \ - "$(baseline php-2.5.14)" + "$(baseline php-2.5.14)" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Fetch this run's published SDK adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index c1de3ba44..6380f4d2c 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -4,6 +4,19 @@ "pinned_image": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "review": "composer_trust_test.py fails on any Composer sink not listed here with the same count.", "sinks": [ + { + "path": ".github/workflows/native-image-parity.yml", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image, pulled ahead with pull-images.sh's registry retries so a registry error cannot fail the job's first Composer run; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] + }, { "path": ".github/workflows/phpunit-feature.yml", "line": "-lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify \"$CORPUS_BASE_REF^{commit}\"; php scripts/ci/check-worker-openapi-evolution.php \"$CORPUS_BASE_REF\"; vendor/bin/phpunit tests/Feature tests/Unit/NexusContractTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref \"$CORPUS_BASE_REF\" --verify-counterfactual'", @@ -105,6 +118,29 @@ } ] }, + { + "path": ".github/workflows/server-parity.yml", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 3, + "confinement": "Pinned Composer 2.10.3 image, pulled ahead with pull-images.sh's registry retries so a registry error cannot fail the job's first Composer run; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] + }, { "path": ".github/workflows/server-perf.yml", "line": "-v \"$PWD:/app\" -w /app/benchmarks/capacity/v1/bindings/php composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\",