From 8f0356a84ad0778644809137881e1df3505e1bef Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:04:41 +0000 Subject: [PATCH 1/2] Continue release verification for existing immutable images --- .github/workflows/phpunit-feature.yml | 2 +- .github/workflows/release.yml | 52 ++- docs/helm-validation.md | 9 + .../ci/promote-release-image-rolling-tags.sh | 11 +- scripts/ci/validate-release-image-publish.sh | 17 + scripts/ci/verify-release-exact-images.sh | 20 ++ tests/Unit/ReleaseImageContinuationTest.php | 328 ++++++++++++++++++ tests/Unit/Support/composer-sinks.json | 2 +- 8 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/ReleaseImageContinuationTest.php diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index ccfc38983..e2cfc9e01 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -353,7 +353,7 @@ jobs: -w /app \ --entrypoint sh \ durable-workflow-server-corpus-validator \ - -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' + -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/ReleaseImageContinuationTest.php 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' - name: Select production image qualification for changed build inputs id: image_inputs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daa771a2c..17dd534e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,11 @@ on: release_commit: description: 'Full source commit for the existing immutable release tag' required: true + existing_image_digest: + description: 'Continue verification of this existing sha256 index without rebuilding images' + required: false + default: '' + type: string permissions: contents: read @@ -104,6 +109,8 @@ jobs: id: release_publish env: INPUT_TAG: ${{ github.event.inputs.tag }} + INPUT_RELEASE_COMMIT: ${{ inputs.release_commit }} + INPUT_EXISTING_IMAGE_DIGEST: ${{ inputs.existing_image_digest }} SOURCE_RELEASE_VERSION: ${{ steps.source_release.outputs.version }} DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} GHCR_IMAGE: ${{ env.GHCR_IMAGE }} @@ -179,6 +186,7 @@ jobs: - name: Resolve shared release image cache id: cache + if: ${{ steps.release_publish.outputs.verification_only != 'true' }} env: RELEASE_IMAGE_CACHE_ROOT: release-source RELEASE_SOURCE_COMMIT: ${{ steps.release_source.outputs.commit }} @@ -188,16 +196,19 @@ jobs: run: node scripts/ci/resolve-release-image-cache.mjs - name: Refuse replacement of published release images + if: ${{ steps.release_publish.outputs.verification_only != 'true' }} env: RELEASE_TAG: ${{ steps.release_publish.outputs.tag }} run: sh scripts/ci/guard-unpublished-release-images.sh - name: Start exact image build timer id: build_started + if: ${{ steps.release_publish.outputs.verification_only != 'true' }} run: printf 'epoch_seconds=%s\n' "$(date +%s)" >> "$GITHUB_OUTPUT" - name: Build and push exact image tags id: build + if: ${{ steps.release_publish.outputs.verification_only != 'true' }} # Bound cold multi-architecture extension and application builds. timeout-minutes: 120 continue-on-error: true @@ -239,9 +250,11 @@ jobs: DOCKER_BUILD_OUTCOME: ${{ steps.build.outcome || 'skipped' }} BUILT_IMAGE_DIGEST: ${{ steps.build.outputs.digest }} BUILT_IMAGE_METADATA: ${{ steps.build.outputs.metadata }} + RELEASE_IMAGE_VERIFICATION_ONLY: ${{ steps.release_publish.outputs.verification_only }} + EXPECTED_IMAGE_DIGEST: ${{ steps.release_publish.outputs.existing_image_digest }} RELEASE_COMMIT: ${{ steps.release_source.outputs.commit }} - RELEASE_RUN_ID: ${{ github.run_id }} - RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} + RELEASE_RUN_ID: ${{ steps.release_publish.outputs.verification_only != 'true' && github.run_id || '' }} + RELEASE_RUN_ATTEMPT: ${{ steps.release_publish.outputs.verification_only != 'true' && github.run_attempt || '' }} WORKFLOW_PACKAGE_SOURCE: ${{ steps.workflow.outputs.source }} WORKFLOW_PACKAGE_REF: ${{ steps.workflow.outputs.ref }} WORKFLOW_PACKAGE_COMMIT: ${{ steps.workflow.outputs.commit }} @@ -250,15 +263,19 @@ jobs: - name: Verify published image trust stores id: trust_stores if: ${{ always() && success() && steps.exact.outputs.exact_publish_outcome == 'success' }} + timeout-minutes: 10 env: - RELEASE_IMAGE: ${{ env.DOCKERHUB_IMAGE }}:${{ steps.release_publish.outputs.tag }} + RELEASE_IMAGE: ${{ env.DOCKERHUB_IMAGE }}@${{ steps.exact.outputs.image_digest }} + DW_PULL_ATTEMPTS: "3" + DW_PULL_BACKOFF_SECONDS: "2" run: | set -euo pipefail # GHSA-6q48-6c7w-9gw2: every published architecture's system, PHP, # Composer, Node and venv stores, checked offline in the image. IFS=',' read -ra platforms <<<"$RELEASE_IMAGE_PLATFORMS" for platform in "${platforms[@]}"; do - docker run --rm --pull always --platform "$platform" --network none \ + DOCKER_DEFAULT_PLATFORM="$platform" bash scripts/ci/pull-images.sh "$RELEASE_IMAGE" + docker run --rm --pull never --platform "$platform" --network none \ --entrypoint server-verify-trust "$RELEASE_IMAGE" done @@ -266,7 +283,7 @@ jobs: id: first_run_readiness if: ${{ always() && success() && steps.exact.outputs.exact_publish_outcome == 'success' }} env: - DW_SERVER_IMAGE: ${{ env.DOCKERHUB_IMAGE }}:${{ steps.release_publish.outputs.tag }} + DW_SERVER_IMAGE: ${{ env.DOCKERHUB_IMAGE }}@${{ steps.exact.outputs.image_digest }} DW_FIRST_RUN_SCOPE: release-first-run-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} working-directory: release-source run: scripts/smoke-published-first-run.sh @@ -275,7 +292,7 @@ jobs: id: source_free_compose if: ${{ always() && success() && steps.first_run_readiness.outcome == 'success' }} env: - DW_SERVER_IMAGE: ${{ env.DOCKERHUB_IMAGE }}:${{ steps.release_publish.outputs.tag }} + DW_SERVER_IMAGE: ${{ env.DOCKERHUB_IMAGE }}@${{ steps.exact.outputs.image_digest }} DW_SERVER_PLATFORM: linux/amd64 DW_PUBLISHED_COMPOSE_PROFILE: local DW_PUBLISHED_COMPOSE_PROJECT: release-compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -337,7 +354,7 @@ jobs: if: ${{ always() && success() && steps.exact.outputs.exact_publish_outcome == 'success' }} env: RELEASE_TAG: ${{ steps.release_publish.outputs.tag }} - SERVER_IMAGE: ${{ env.DOCKERHUB_IMAGE }}:${{ steps.release_publish.outputs.tag }} + SERVER_IMAGE: ${{ env.DOCKERHUB_IMAGE }}@${{ steps.exact.outputs.image_digest }} WORKFLOW_PACKAGE_SOURCE: ${{ steps.workflow.outputs.source }} WORKFLOW_PACKAGE_REF: ${{ steps.workflow.outputs.ref }} WORKFLOW_PACKAGE_COMMIT: ${{ steps.workflow.outputs.commit }} @@ -357,6 +374,25 @@ jobs: docker volume rm -f "${RELEASE_PROTOCOL_CATALOG_SCOPE}-database" \ >/dev/null 2>&1 || true + - name: Recheck immutable release identities before promotion + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release_publish.outputs.tag }} + RELEASE_COMMIT: ${{ steps.release_source.outputs.commit }} + RELEASE_IMAGE_VERIFICATION_ONLY: "true" + EXPECTED_IMAGE_DIGEST: ${{ steps.exact.outputs.image_digest }} + DOCKER_BUILD_OUTCOME: skipped + DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + RELEASE_RUN_ID: ${{ steps.release_publish.outputs.verification_only != 'true' && github.run_id || '' }} + RELEASE_RUN_ATTEMPT: ${{ steps.release_publish.outputs.verification_only != 'true' && github.run_attempt || '' }} + WORKFLOW_PACKAGE_SOURCE: ${{ steps.workflow.outputs.source }} + WORKFLOW_PACKAGE_REF: ${{ steps.workflow.outputs.ref }} + WORKFLOW_PACKAGE_COMMIT: ${{ steps.workflow.outputs.commit }} + run: | + scripts/ci/verify-release-tag-source.sh + scripts/ci/verify-release-exact-images.sh + - name: Resolve rolling image aliases id: rolling if: >- @@ -380,6 +416,7 @@ jobs: DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} GHCR_IMAGE: ${{ env.GHCR_IMAGE }} ROLLING_SHOULD_PROMOTE: ${{ steps.rolling.outputs.rolling_should_promote }} + VERIFIED_IMAGE_DIGEST: ${{ steps.exact.outputs.image_digest }} RELEASE_IMAGE_TAG_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git run: scripts/ci/promote-release-image-rolling-tags.sh @@ -445,6 +482,7 @@ jobs: needs: publish if: >- always() && + needs.publish.result == 'success' && needs.publish.outputs.exact_publish_outcome == 'success' && needs.publish.outputs.published_database_tls_outcome == 'success' && github.repository == 'durable-workflow/server' && diff --git a/docs/helm-validation.md b/docs/helm-validation.md index 436f31a9e..1957f53da 100644 --- a/docs/helm-validation.md +++ b/docs/helm-validation.md @@ -29,6 +29,15 @@ TLS probes on the verified digest. It does not rebuild or replace an image. Both release paths require the TLS gate to pass before publishing the matching chart. +If exact images were uploaded but verification failed before the GitHub Release +was created, dispatch the protected `Release` workflow from `main` with `tag`, +the full `release_commit`, and `existing_image_digest` from the original upload. +This explicit continuation skips image build and push. It verifies both +registries and architectures against that index and source, then runs all normal +trust-store, first-run, Compose, database TLS and protocol checks before the +existing alias, GitHub Release and chart steps. The images retain their original +publisher provenance. Do not rerun the image producer or replace immutable tags. + For a local repeat, set `TLS_TEST_MODE=published`, `TLS_TEST_IMAGE` to an immutable `repository@sha256:...` reference, and unique `TLS_TEST_PROJECT` and `TLS_TEST_ROOT` values, then run `scripts/ci/qualify-database-tls.sh`. The diff --git a/scripts/ci/promote-release-image-rolling-tags.sh b/scripts/ci/promote-release-image-rolling-tags.sh index 01547769e..0e8995549 100755 --- a/scripts/ci/promote-release-image-rolling-tags.sh +++ b/scripts/ci/promote-release-image-rolling-tags.sh @@ -7,6 +7,7 @@ dockerhub_image="${DOCKERHUB_IMAGE:-durableworkflow/server}" ghcr_image="${GHCR_IMAGE:-ghcr.io/durable-workflow/server}" docker_bin="${DOCKER:-docker}" tag_source_url="${RELEASE_IMAGE_TAG_SOURCE_URL:-}" +verified_image_digest="${VERIFIED_IMAGE_DIGEST:-}" fail() { printf '::error title=%s::%s\n' "$1" "$2" >&2 @@ -82,6 +83,10 @@ if ! is_stable_semver_tag "$release_tag"; then fail "Stable semver tag required" "Rolling image aliases can only be promoted from stable semver tags; got '${release_tag}'." fi +if [ -n "$verified_image_digest" ] && ! printf '%s' "$verified_image_digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then + fail "Invalid verified image digest" "Rolling aliases require an exact lowercase sha256 digest when VERIFIED_IMAGE_DIGEST is supplied." +fi + major="${release_tag%%.*}" minor_patch="${release_tag#*.}" minor="${minor_patch%%.*}" @@ -102,12 +107,16 @@ fi promote_image() { image="$1" + source_ref="${image}:${release_tag}" + if [ -n "$verified_image_digest" ]; then + source_ref="${image}@${verified_image_digest}" + fi "$docker_bin" buildx imagetools create \ --tag "${image}:${minor_alias}" \ --tag "${image}:${major_alias}" \ --tag "${image}:latest" \ - "${image}:${release_tag}" + "$source_ref" } promote_image "$dockerhub_image" diff --git a/scripts/ci/validate-release-image-publish.sh b/scripts/ci/validate-release-image-publish.sh index ae0228fec..db7a6db9d 100755 --- a/scripts/ci/validate-release-image-publish.sh +++ b/scripts/ci/validate-release-image-publish.sh @@ -6,6 +6,8 @@ dockerhub_image="${DOCKERHUB_IMAGE:-durableworkflow/server}" ghcr_image="${GHCR_IMAGE:-ghcr.io/durable-workflow/server}" release_tag="" missing_credentials="" +existing_image_digest="${INPUT_EXISTING_IMAGE_DIGEST:-}" +verification_only=false fail() { title="$1" @@ -79,6 +81,19 @@ if [ -n "$source_release_version" ]; then fi fi +if [ -n "$existing_image_digest" ]; then + if [ "${GITHUB_EVENT_NAME:-}" != workflow_dispatch ] || [ "${GITHUB_REF:-}" != refs/heads/main ]; then + fail "Invalid release continuation context" "Existing image verification requires workflow_dispatch from main." + fi + if ! printf '%s' "$existing_image_digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then + fail "Invalid existing image digest" "Existing image verification requires an exact lowercase sha256 index digest." + fi + if ! printf '%s' "${INPUT_RELEASE_COMMIT:-}" | grep -Eq '^[0-9a-f]{40}$'; then + fail "Invalid release continuation source" "Existing image verification requires the full lowercase source commit for the immutable tag." + fi + verification_only=true +fi + if [ -z "${DOCKERHUB_USERNAME:-}" ]; then append_missing_credential "DOCKERHUB_USERNAME" fi @@ -98,6 +113,8 @@ fi if [ -n "${GITHUB_OUTPUT:-}" ]; then { printf 'tag=%s\n' "$release_tag" + printf 'verification_only=%s\n' "$verification_only" + printf 'existing_image_digest=%s\n' "$existing_image_digest" if is_semver_tag "$release_tag"; then printf 'is_semver=true\n' else diff --git a/scripts/ci/verify-release-exact-images.sh b/scripts/ci/verify-release-exact-images.sh index cf4a24f24..16bfd5bdb 100755 --- a/scripts/ci/verify-release-exact-images.sh +++ b/scripts/ci/verify-release-exact-images.sh @@ -9,6 +9,7 @@ docker_bin="${DOCKER:-docker}" build_outcome="${DOCKER_BUILD_OUTCOME:-}" built_image_digest="${BUILT_IMAGE_DIGEST:-}" built_image_metadata="${BUILT_IMAGE_METADATA:-}" +requested_image_digest="${EXPECTED_IMAGE_DIGEST:-}" release_commit="${RELEASE_COMMIT:-}" run_id="${RELEASE_RUN_ID:-}" run_attempt="${RELEASE_RUN_ATTEMPT:-}" @@ -136,6 +137,19 @@ case "$verification_only" in ;; esac +if [ -n "$requested_image_digest" ]; then + if [ "$verification_only" != true ] || ! printf '%s' "$requested_image_digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then + fail "Invalid release continuation digest" "An expected existing-image digest requires verification-only mode and an exact lowercase sha256 index." "exact_continuation_digest_invalid" + fi + if [ "${build_outcome:-skipped}" != skipped ] || [ -n "$built_image_digest" ] || [ -n "$built_image_metadata" ]; then + fail "Release continuation must not build images" "Existing immutable-image continuation requires the producer to be skipped with no new build metadata." "exact_continuation_producer_ran" + fi + if [ -z "$workflow_package_source" ] || [ -z "$workflow_package_ref" ] \ + || ! printf '%s' "$workflow_package_commit" | grep -Eq '^[0-9a-f]{40}$'; then + fail "Release continuation package authority missing" "Existing immutable-image continuation requires the complete locked Workflow source, version and commit." "exact_continuation_package_authority_missing" + fi +fi + if ! printf '%s' "$release_tag" | grep -Eq '^[0-9A-Za-z._-]+$'; then fail "Invalid exact release image tag" "Release image tag '${release_tag}' contains characters that are not safe for Docker tags." "exact_release_tag_invalid" fi @@ -190,6 +204,9 @@ if [ -n "$direct_build_digest" ]; then else expected_image_digest="$metadata_build_digest" fi +if [ -n "$requested_image_digest" ]; then + expected_image_digest="$requested_image_digest" +fi if [ -z "$expected_image_digest" ]; then if [ "$verify_published_metadata" != "true" ]; then @@ -234,6 +251,9 @@ inspect_ref() { fi if [ -n "$expected_image_digest" ] && [ "$expected_image_digest" != "$digest" ]; then + if [ -n "$requested_image_digest" ]; then + fail "Exact release continuation digest mismatch" "Published exact image manifest ${ref} has digest ${digest}, but continuation requires ${expected_image_digest}." "exact_continuation_digest_mismatch" + fi fail "Exact release image build digest mismatch" "Published exact image manifest ${ref} has digest ${digest}, but this release build produced ${expected_image_digest}." "exact_manifest_build_digest_mismatch" fi diff --git a/tests/Unit/ReleaseImageContinuationTest.php b/tests/Unit/ReleaseImageContinuationTest.php new file mode 100644 index 000000000..0a1274dba --- /dev/null +++ b/tests/Unit/ReleaseImageContinuationTest.php @@ -0,0 +1,328 @@ +directory = sys_get_temp_dir().'/release-continuation-'.bin2hex(random_bytes(8)); + mkdir($this->directory, 0700); + file_put_contents($this->directory.'/docker', <<<'SH' +#!/usr/bin/env sh +set -eu +printf '%s\n' "$*" >> "$CALL_LOG" +if [ "$1" = pull ]; then + counter="$CALL_LOG-${DOCKER_DEFAULT_PLATFORM##*/}" + count=0 + if [ -f "$counter" ]; then count=$(cat "$counter"); fi + count=$((count + 1)) + printf '%s\n' "$count" > "$counter" + if [ "$STATE" = pull_permanent ] || { [ "$STATE" = pull_transient ] && [ "$count" -lt 3 ]; }; then + echo '500 Internal Server Error' >&2 + exit 1 + fi + exit 0 +fi +if [ "$1" = run ]; then + if [ "$STATE" = trust_failure ]; then echo 'trust assertion failed' >&2; exit 1; fi + exit 0 +fi +if [ "$1" = manifest ] && [ "$2" = inspect ]; then + printf '{"schemaVersion":2}\n' + exit 0 +fi +test "$1" = buildx +test "$2" = imagetools +if [ "$3" = create ]; then exit 0; fi +test "$3" = inspect +if [ "$4" = --format ]; then + if [ "$STATE" = wrong_arm_config ]; then + case "$5" in *linux/arm64*) printf '%s\n' "$BAD_METADATA"; exit 0 ;; esac + fi + printf '%s\n' "$METADATA" +else + if [ "$STATE" = missing ]; then echo 'manifest unknown' >&2; exit 1; fi + digest="$INDEX_DIGEST" + if [ "$STATE" = registry_mismatch ]; then + case "$4" in ghcr.io/*) digest=sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd ;; esac + fi + printf 'Digest: %s\nPlatform: linux/amd64\n' "$digest" + if [ "$STATE" != missing_arm ]; then printf 'Platform: linux/arm64\n'; fi +fi +SH); + chmod($this->directory.'/docker', 0700); + } + + protected function tearDown(): void + { + foreach (glob($this->directory.'/*') as $file) { + unlink($file); + } + rmdir($this->directory); + } + + public static function invalidContinuationInputs(): array + { + return [ + 'tag push' => [['GITHUB_EVENT_NAME' => 'push', 'GITHUB_REF' => 'refs/tags/2.11.6']], + 'feature branch' => [['GITHUB_REF' => 'refs/heads/feature']], + 'missing source' => [['INPUT_RELEASE_COMMIT' => '']], + 'abbreviated source' => [['INPUT_RELEASE_COMMIT' => 'aaaaaaaa']], + 'non-digest' => [['INPUT_EXISTING_IMAGE_DIGEST' => 'latest']], + 'digest suffix' => [['INPUT_EXISTING_IMAGE_DIGEST' => self::DIGEST.'-extra']], + ]; + } + + #[DataProvider('invalidContinuationInputs')] + public function test_invalid_continuation_cannot_admit_a_producer(array $overrides): void + { + $result = $this->runScript('validate-release-image-publish.sh', $this->context($overrides)); + self::assertSame(1, $result['status'], $result['text']); + self::assertStringNotContainsString('verification_only=true', $result['output']); + self::assertSame('', $result['calls']); + } + + public function test_explicit_continuation_selects_verification_without_a_new_run_identity(): void + { + $context = $this->runScript('validate-release-image-publish.sh', $this->context()); + self::assertSame(0, $context['status'], $context['text']); + self::assertStringContainsString('verification_only=true', $context['output']); + self::assertStringContainsString('existing_image_digest='.self::DIGEST, $context['output']); + + $verified = $this->runScript('verify-release-exact-images.sh', $this->verification()); + self::assertSame(0, $verified['status'], $verified['text']); + self::assertStringContainsString('exact_publish_outcome=success', $verified['output']); + self::assertStringContainsString('image_digest='.self::DIGEST, $verified['output']); + self::assertSame(2, substr_count($verified['calls'], '--format {{json (index .Image "linux/amd64")}}')); + self::assertSame(2, substr_count($verified['calls'], '--format {{json (index .Image "linux/arm64")}}')); + self::assertStringNotContainsString('imagetools create', $verified['calls']); + } + + public function test_no_digest_preserves_new_release_and_existing_image_refusal(): void + { + $context = $this->runScript('validate-release-image-publish.sh', $this->context([ + 'INPUT_EXISTING_IMAGE_DIGEST' => '', + ])); + self::assertSame(0, $context['status'], $context['text']); + self::assertStringContainsString('verification_only=false', $context['output']); + $guard = $this->runScript('guard-unpublished-release-images.sh', [ + 'RELEASE_TAG' => '2.11.6', + ]); + self::assertSame(1, $guard['status'], $guard['text']); + self::assertStringNotContainsString('imagetools create', $guard['calls']); + } + + public static function invalidExistingImages(): array + { + return [ + 'unexpected digest' => [['INDEX_DIGEST' => 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd']], + 'registries disagree' => [['STATE' => 'registry_mismatch']], + 'missing arm64' => [['STATE' => 'missing_arm']], + 'wrong arm64 source' => [['STATE' => 'wrong_arm_config']], + 'wrong tag' => [['RELEASE_TAG' => '2.11.7']], + 'wrong source' => [['RELEASE_COMMIT' => 'dddddddddddddddddddddddddddddddddddddddd']], + 'wrong Core commit' => [['WORKFLOW_PACKAGE_COMMIT' => 'dddddddddddddddddddddddddddddddddddddddd']], + 'new consumer run identity' => [['RELEASE_RUN_ID' => 'new-verification-run']], + 'missing image' => [['STATE' => 'missing']], + 'producer ran' => [['DOCKER_BUILD_OUTCOME' => 'success']], + 'new build digest' => [['BUILT_IMAGE_DIGEST' => self::DIGEST]], + 'incomplete Core authority' => [['WORKFLOW_PACKAGE_COMMIT' => '']], + 'missing full source' => [['RELEASE_COMMIT' => '']], + 'normal mode with continuation digest' => [['RELEASE_IMAGE_VERIFICATION_ONLY' => 'false']], + ]; + } + + #[DataProvider('invalidExistingImages')] + public function test_existing_image_failures_stop_before_any_registry_write(array $overrides): void + { + $result = $this->runScript('verify-release-exact-images.sh', $this->verification($overrides)); + self::assertSame(1, $result['status'], $result['text']); + self::assertStringContainsString('exact_publish_outcome=failure', $result['output']); + self::assertStringNotContainsString('imagetools create', $result['calls']); + } + + public function test_rolling_aliases_use_verified_bytes_and_never_write_the_exact_tag(): void + { + $result = $this->runScript('promote-release-image-rolling-tags.sh', [ + 'RELEASE_TAG' => '2.11.6', 'VERIFIED_IMAGE_DIGEST' => self::DIGEST, + 'RELEASE_IMAGE_KNOWN_TAGS' => "2.11.5\n2.11.6", + ]); + self::assertSame(0, $result['status'], $result['text']); + self::assertSame(2, substr_count($result['calls'], 'imagetools create')); + foreach (['durableworkflow/server', 'ghcr.io/durable-workflow/server'] as $image) { + self::assertStringContainsString($image.'@'.self::DIGEST, $result['calls']); + self::assertStringNotContainsString('--tag '.$image.':2.11.6', $result['calls']); + } + } + + public function test_invalid_alias_digest_stops_before_registry_writes(): void + { + $result = $this->runScript('promote-release-image-rolling-tags.sh', [ + 'RELEASE_TAG' => '2.11.6', 'VERIFIED_IMAGE_DIGEST' => 'latest', + ]); + self::assertSame(1, $result['status'], $result['text']); + self::assertSame('', $result['calls']); + } + + public function test_tag_push_preserves_the_normal_new_release_mode(): void + { + $result = $this->runScript('validate-release-image-publish.sh', $this->context([ + 'GITHUB_EVENT_NAME' => 'push', 'GITHUB_REF' => 'refs/tags/2.11.6', + 'INPUT_EXISTING_IMAGE_DIGEST' => '', + ])); + self::assertSame(0, $result['status'], $result['text']); + self::assertStringContainsString('verification_only=false', $result['output']); + } + + public function test_existing_released_artifact_verification_remains_compatible(): void + { + $result = $this->runScript('verify-release-exact-images.sh', $this->verification([ + 'EXPECTED_IMAGE_DIGEST' => '', 'WORKFLOW_PACKAGE_SOURCE' => '', + 'WORKFLOW_PACKAGE_REF' => '', 'WORKFLOW_PACKAGE_COMMIT' => '', + ])); + self::assertSame(0, $result['status'], $result['text']); + self::assertStringContainsString('image_digest='.self::DIGEST, $result['output']); + } + + public function test_workflow_wires_continuation_to_all_normal_verification_gates(): void + { + $workflow = Yaml::parse(file_get_contents(dirname(__DIR__, 2).'/.github/workflows/release.yml')); + self::assertArrayHasKey('existing_image_digest', $workflow['on']['workflow_dispatch']['inputs']); + self::assertFalse($workflow['on']['workflow_dispatch']['inputs']['existing_image_digest']['required']); + $job = $workflow['jobs']['publish']; + self::assertSame('dockerhub', $job['environment']); + self::assertSame(['contents' => 'write', 'packages' => 'write'], $job['permissions']); + $steps = array_column($job['steps'], null, 'name'); + foreach (['Resolve shared release image cache', 'Refuse replacement of published release images', + 'Start exact image build timer', 'Build and push exact image tags'] as $producer) { + self::assertSame("\${{ steps.release_publish.outputs.verification_only != 'true' }}", $steps[$producer]['if']); + } + foreach (['Verify published image trust stores' => 'RELEASE_IMAGE', + 'Verify bare image first-run readiness' => 'DW_SERVER_IMAGE', + 'Verify source-free Compose bootstrap' => 'DW_SERVER_IMAGE', + 'Verify published image database TLS and certificate refusals' => 'TLS_TEST_IMAGE', + 'Verify published protocol catalog convergence' => 'SERVER_IMAGE'] as $name => $image) { + self::assertStringContainsString('@${{ steps.exact.outputs.image_digest }}', $steps[$name]['env'][$image]); + self::assertStringContainsString('success()', $steps[$name]['if']); + self::assertArrayNotHasKey('continue-on-error', $steps[$name]); + } + self::assertSame('${{ steps.release_publish.outputs.existing_image_digest }}', $steps['Verify exact image publication']['env']['EXPECTED_IMAGE_DIGEST']); + self::assertSame('${{ steps.exact.outputs.image_digest }}', $steps['Promote rolling image aliases']['env']['VERIFIED_IMAGE_DIGEST']); + $names = array_keys($steps); + $guard = $steps['Recheck immutable release identities before promotion']; + self::assertLessThan(array_search('Promote rolling image aliases', $names, true), array_search('Recheck immutable release identities before promotion', $names, true)); + self::assertArrayNotHasKey('if', $guard); + self::assertSame('${{ steps.exact.outputs.image_digest }}', $guard['env']['EXPECTED_IMAGE_DIGEST']); + self::assertStringContainsString('scripts/ci/verify-release-tag-source.sh', $guard['run']); + self::assertStringContainsString('scripts/ci/verify-release-exact-images.sh', $guard['run']); + self::assertStringContainsString("needs.publish.result == 'success'", $workflow['jobs']['publish-chart']['if']); + } + + public static function trustPullOutcomes(): array + { + return [ + 'transient pulls recover on both platforms' => ['pull_transient', 0, 6, 2], + 'permanent pull failure stops without trust execution' => ['pull_permanent', 1, 3, 0], + 'trust content failure is never retried' => ['trust_failure', 1, 1, 1], + ]; + } + + #[DataProvider('trustPullOutcomes')] + public function test_only_pulls_are_retried_and_trust_uses_the_cached_digest(string $state, int $status, int $pulls, int $runs): void + { + $workflow = Yaml::parse(file_get_contents(dirname(__DIR__, 2).'/.github/workflows/release.yml')); + $steps = array_column($workflow['jobs']['publish']['steps'], null, 'name'); + $step = $steps['Verify published image trust stores']; + $script = $this->directory.'/trust.sh'; + file_put_contents($script, $step['run']); + $result = $this->runProcess(['bash', $script], [ + 'STATE' => $state, 'RELEASE_IMAGE_PLATFORMS' => 'linux/amd64,linux/arm64', + 'RELEASE_IMAGE' => 'durableworkflow/server@'.self::DIGEST, + 'DW_PULL_ATTEMPTS' => $step['env']['DW_PULL_ATTEMPTS'] ?? '3', + 'DW_PULL_BACKOFF_SECONDS' => '0', + ]); + self::assertSame($status, $result['status'], $result['text']); + self::assertSame($pulls, substr_count($result['calls'], 'pull --quiet')); + self::assertSame($runs, substr_count($result['calls'], 'run --rm')); + if ($runs > 0) { + self::assertSame($runs, substr_count($result['calls'], '--pull never')); + self::assertSame($runs, substr_count($result['calls'], '--network none')); + } + if ($status === 0) { + self::assertSame(3, (int) file_get_contents($this->directory.'/calls-amd64')); + self::assertSame(3, (int) file_get_contents($this->directory.'/calls-arm64')); + } + } + + private function context(array $overrides = []): array + { + return array_replace([ + 'GITHUB_EVENT_NAME' => 'workflow_dispatch', 'GITHUB_REF' => 'refs/heads/main', + 'INPUT_TAG' => '2.11.6', 'SOURCE_RELEASE_VERSION' => '2.11.6', + 'INPUT_RELEASE_COMMIT' => self::SOURCE, 'INPUT_EXISTING_IMAGE_DIGEST' => self::DIGEST, + 'DOCKERHUB_USERNAME' => 'fixture-user', 'DOCKERHUB_TOKEN' => 'fixture-token', + 'GHCR_TOKEN' => 'fixture-token', + ], $overrides); + } + + private function verification(array $overrides = []): array + { + return array_replace([ + 'RELEASE_TAG' => '2.11.6', 'RELEASE_COMMIT' => self::SOURCE, + 'RELEASE_IMAGE_VERIFICATION_ONLY' => 'true', 'EXPECTED_IMAGE_DIGEST' => self::DIGEST, + 'DOCKER_BUILD_OUTCOME' => 'skipped', + 'WORKFLOW_PACKAGE_SOURCE' => 'https://github.com/durable-workflow/workflow.git', + 'WORKFLOW_PACKAGE_REF' => '2.15.7', 'WORKFLOW_PACKAGE_COMMIT' => self::CORE, + ], $overrides); + } + + private function runScript(string $script, array $environment): array + { + return $this->runProcess(['sh', dirname(__DIR__, 2).'/scripts/ci/'.$script], $environment); + } + + private function runProcess(array $command, array $environment): array + { + $labels = [ + 'org.opencontainers.image.revision' => self::SOURCE, 'dev.durable-workflow.release.tag' => '2.11.6', + 'dev.durable-workflow.release.run-id' => 'original-publisher', 'dev.durable-workflow.release.run-attempt' => '1', + 'dev.durable-workflow.workflow.source' => 'https://github.com/durable-workflow/workflow.git', + 'dev.durable-workflow.workflow.version' => '2.15.7', 'dev.durable-workflow.workflow.commit' => self::CORE, + ]; + $badLabels = array_replace($labels, ['org.opencontainers.image.revision' => str_repeat('d', 40)]); + file_put_contents($this->directory.'/output', ''); + file_put_contents($this->directory.'/calls', ''); + $environment = array_replace([ + 'PATH' => $this->directory.':'.getenv('PATH'), 'DOCKER' => $this->directory.'/docker', + 'GITHUB_OUTPUT' => $this->directory.'/output', 'CALL_LOG' => $this->directory.'/calls', + 'STATE' => 'valid', 'INDEX_DIGEST' => self::DIGEST, + 'METADATA' => json_encode(['config' => ['Labels' => $labels]], JSON_THROW_ON_ERROR), + 'BAD_METADATA' => json_encode(['config' => ['Labels' => $badLabels]], JSON_THROW_ON_ERROR), + ], $environment); + $process = proc_open($command, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, dirname(__DIR__, 2), $environment); + self::assertIsResource($process); + $text = stream_get_contents($pipes[1]).stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return ['status' => proc_close($process), 'text' => $text, + 'output' => file_get_contents($this->directory.'/output'), + 'calls' => file_get_contents($this->directory.'/calls')]; + } +} diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 2207dfa5d..e05aafb5d 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -19,7 +19,7 @@ }, { "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'", + "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/ReleaseImageContinuationTest.php 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'", "count": 2, "confinement": "Runs in the source-free corpus tool image. The feature step checks its system bundle, hashed directory and Composer selection before fresh source and dependency installation, on cold builds and cache hits.", "guards": [ From fbc2e05ac2d932f95c2e51c9660f949cb906bc08 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:55:57 +0000 Subject: [PATCH 2/2] Install jq in cached PHP CI tools and verify readiness --- .github/workflows/phpunit-feature.yml | 6 ++++-- tests/Unit/PhpunitFeatureWorkflowContractTest.php | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 4bab1e4cf..468385667 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -179,7 +179,7 @@ jobs: - name: Key the source-free PHP tool images id: php_tools_key - run: echo "key=server-php-tools-v1-${{ runner.os }}-${{ runner.arch }}-$(date -u +%Y%m%d)-${{ hashFiles('Dockerfile', 'Dockerfile.dockerignore', '.dockerignore', 'docker/**') }}" >> "$GITHUB_OUTPUT" + run: echo "key=server-php-tools-v2-${{ runner.os }}-${{ runner.arch }}-$(date -u +%Y%m%d)-${{ hashFiles('Dockerfile', 'Dockerfile.dockerignore', '.dockerignore', 'docker/**') }}" >> "$GITHUB_OUTPUT" - name: Restore the source-free PHP tool images id: php_tools_cache @@ -201,7 +201,7 @@ jobs: docker build -t durable-workflow-server-corpus-validator - <<'DOCKERFILE' FROM durable-workflow-server-phpunit-base RUN apt-get update \ - && apt-get install -y --no-install-recommends git \ + && apt-get install -y --no-install-recommends git jq \ && rm -rf /var/lib/apt/lists/* \ && server-verify-trust final DOCKERFILE @@ -215,6 +215,8 @@ jobs: docker run --rm --network none --entrypoint server-verify-trust durable-workflow-server-phpunit-base docker run --rm --network none --entrypoint server-verify-trust durable-workflow-server-corpus-validator final docker run --rm --network none --entrypoint server-verify-brotli durable-workflow-server-corpus-validator + docker run --rm --network none --entrypoint sh durable-workflow-server-corpus-validator \ + -c 'git --version && jq --version' # Cache writes precede the dependency checkout and all application code. # These images contain only toolchains, qualified by the checks above. diff --git a/tests/Unit/PhpunitFeatureWorkflowContractTest.php b/tests/Unit/PhpunitFeatureWorkflowContractTest.php index 73cfd90fe..1f4fd37b4 100644 --- a/tests/Unit/PhpunitFeatureWorkflowContractTest.php +++ b/tests/Unit/PhpunitFeatureWorkflowContractTest.php @@ -156,6 +156,9 @@ public function test_php_tools_cache_preserves_trust_and_fresh_source_qualificat $restore = $this->step('Restore the source-free PHP tool images'); $prepare = $this->step('Prepare the source-free PHP tool images'); $save = $this->step('Save qualified source-free PHP tools from main'); + $this->assertStringContainsString('server-php-tools-v2-', $key['run']); + $this->assertStringContainsString('apt-get install -y --no-install-recommends git jq', $prepare['run']); + $this->assertStringContainsString('git --version && jq --version', $prepare['run']); $execution = $this->step('Run feature, Nexus, and regression corpus suites'); $this->assertStringContainsString('$(date -u +%Y%m%d)', $key['run']);