diff --git a/.github/workflows/native-alias-promotion.yml b/.github/workflows/native-alias-promotion.yml new file mode 100644 index 000000000..5e29be68e --- /dev/null +++ b/.github/workflows/native-alias-promotion.yml @@ -0,0 +1,105 @@ +name: Native alias promotion +run-name: Native alias promotion ${{ github.ref_name }} + +# Consumes existing exact-index qualification. Never dispatches a qualifier, +# builds/pulls an image, creates a Release, or changes candidate aliases. +on: + workflow_dispatch: + inputs: + tag: + description: 'Exact final native tag used as this dispatch ref and by the evidence manifest' + required: true + type: string + evidence: + description: 'Closed native-alias-promotion-evidence.v1 JSON manifest with exact current run attempts and artifact IDs/digests' + required: true + type: string + +permissions: {} + +jobs: + plan: + name: Validate exact promotion dispatch + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Check out dispatch commit + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse a wrong ref or source before privileged jobs + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_EVIDENCE: ${{ inputs.evidence }} + PROMOTION_TAG: ${{ inputs.tag }} + run: python3 scripts/ci/native_alias_promote.py plan + + verify: + name: Verify existing native qualification + needs: plan + if: github.ref == format('refs/tags/{0}', inputs.tag) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + attestations: read + packages: read + steps: + - name: Check out exact tag tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Verify provider-owned qualification without writes + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_EVIDENCE: ${{ inputs.evidence }} + PROMOTION_TAG: ${{ inputs.tag }} + run: python3 scripts/ci/native_alias_promote.py verify + + promote: + name: Promote qualified native aliases + needs: [plan, verify] + if: github.ref == format('refs/tags/{0}', inputs.tag) + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: dockerhub-native + concurrency: + group: durable-workflow-server-rolling-aliases + cancel-in-progress: false + permissions: + contents: read + actions: read + attestations: read + packages: write + steps: + - name: Check out exact tag tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Log in to Docker Hub + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Repeat qualification under approval and lock before promotion + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_EVIDENCE: ${{ inputs.evidence }} + PROMOTION_TAG: ${{ inputs.tag }} + run: python3 scripts/ci/native_alias_promote.py promote diff --git a/.github/workflows/native-github-release.yml b/.github/workflows/native-github-release.yml index 92fc5bcd8..38a355abd 100644 --- a/.github/workflows/native-github-release.yml +++ b/.github/workflows/native-github-release.yml @@ -27,6 +27,15 @@ on: required: true type: string + release_run_attempt: + description: 'Exact current attempt of the publication run' + required: true + type: string + promotion_evidence: + description: 'Final releases require the exact qualification manifest used for alias promotion; candidates leave empty' + required: false + type: string + permissions: contents: read @@ -71,4 +80,6 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} RELEASE_RUN_ID: ${{ inputs.release_run_id }} + RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} + PROMOTION_EVIDENCE: ${{ inputs.promotion_evidence }} run: bash scripts/ci/native-release-github-release.sh diff --git a/.github/workflows/native-image-parity.yml b/.github/workflows/native-image-parity.yml index 878cbd0f0..84d967f56 100644 --- a/.github/workflows/native-image-parity.yml +++ b/.github/workflows/native-image-parity.yml @@ -1,4 +1,5 @@ name: Native image parity +run-name: Native image parity ${{ inputs.native_image || github.ref }} # Differential qualification of a native server image (Server #325). It # records the shared fixture corpus on SQLite against the published PHP @@ -18,6 +19,9 @@ on: required: true pull_request: paths: + - scripts/ci/native_qualification.py + - scripts/ci/native_alias_transport.py + - scripts/ci/native_alias_evidence.py - scripts/conformance/server-parity/native-image-corpus.sh - scripts/conformance/server-parity/update-validation-sqlite.sh - scripts/conformance/server-parity/php-maintenance-window.sh @@ -25,6 +29,7 @@ on: permissions: contents: read + actions: read concurrency: group: native-image-parity-${{ github.event.pull_request.number || github.run_id }} @@ -51,7 +56,6 @@ jobs: composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - 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 @@ -69,15 +73,39 @@ jobs: tags: ${{ env.LOCAL_IMAGE }} - name: Record and compare the corpus against the native image + id: body 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 + - name: Verify owned qualification cleanup + id: cleanup if: always() run: | - docker rm -f "$RESOURCE_SCOPE-rust" "$RESOURCE_SCOPE-server" "$RESOURCE_SCOPE-php-maintenance" || true - docker network rm "$RESOURCE_SCOPE" || true + python3 scripts/ci/native_qualification.py cleanup --kind corpus \ + --scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json + + - name: Finalize promotion receipt + if: always() + env: + GH_TOKEN: ${{ github.token }} + NATIVE_IMAGE: ${{ github.event_name == 'pull_request' && env.LOCAL_IMAGE || inputs.native_image }} + QUALIFICATION_BODY_OUTCOME: ${{ steps.body.outcome }} + QUALIFICATION_CLEANUP_OUTCOME: ${{ steps.cleanup.outcome }} + run: >- + python3 scripts/ci/native_qualification.py finalize --kind corpus --image "$NATIVE_IMAGE" + --identity qualification-state/image.json + --observations parity-evidence/native-image-corpus.json + --cleanup qualification-state/cleanup.json --output promotion-receipt/receipt.json + + - name: Keep promotion receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-corpus-promotion-receipt + path: promotion-receipt/receipt.json + retention-days: 14 + if-no-files-found: error - name: Keep the comparison evidence if: always() diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml index da44f9eed..9a1bee384 100644 --- a/.github/workflows/native-image.yml +++ b/.github/workflows/native-image.yml @@ -12,6 +12,7 @@ on: - Dockerfile.native - Dockerfile.native.dockerignore - scripts/ci/native-image-smoke.sh + - scripts/ci/native-registry-absence.sh - scripts/ci/native-release-publish.sh - scripts/ci/native-release-rolling-tags.sh - scripts/ci/native-release-identity.sh diff --git a/.github/workflows/native-promotion-contracts.yml b/.github/workflows/native-promotion-contracts.yml new file mode 100644 index 000000000..c61550567 --- /dev/null +++ b/.github/workflows/native-promotion-contracts.yml @@ -0,0 +1,29 @@ +name: Native promotion contracts +on: + pull_request: + paths: + - scripts/ci/native_alias_*.py + - scripts/ci/native_qualification.py + - scripts/ci/native-registry-absence.sh + - scripts/ci/native-release-*.sh + - scripts/ci/check-native-release-tags.sh + - scripts/ci/select-published-compose-image.sh + - scripts/smoke-published-compose.sh + - scripts/conformance/server-parity/native-image-corpus.sh + - tests/Unit/Support/native_*test.py + - .github/workflows/native-*.yml + - .github/workflows/published-compose.yml +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Countercheck provider receipts, cleanup and fail-before writes + run: python3 -m unittest discover -s tests/Unit/Support -p 'native_*test.py' -v + - name: Preserve release, candidate and monotonic alias contracts + run: bash scripts/ci/check-native-release-tags.sh diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index be1010e28..97b4088ab 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -19,14 +19,14 @@ name: Native release candidate # decision, November 1), with the repository's Docker Hub credentials. A # native version is a major version above every PHP release, and the # publish job refuses a tag either registry already has, so it never -# replaces a PHP image. A final release then moves X.Y, X and latest -# forward to its index (operator decision, November 1); a candidate moves -# none, and the PHP Release workflow leaves latest to a newer native +# replaces a PHP image. Future final aliases move only in the separate +# protected native-alias-promotion.yml phase after exact-index corpus and +# four-cell Compose qualification (Server #1020); a candidate moves none, and the PHP Release workflow leaves latest to a newer native # release. It is dispatched from the release tag's own ref # (refs/tags/native-), at the tag's commit, which protected main must # contain; its build-provenance attestation then names the release commit as -# its source. It publishes from the dockerhub-native Environment, whose only -# deployment rule is the tag pattern native-[0-9]*; creating those tags is +# its source. It publishes from the dockerhub-native Environment, whose +# deployment rules permit native-[0-9]* and 3.[0-9]*.[0-9]*; creating those tags is # restricted by the repository's tag ruleset. # # Every version, release candidates included, publishes to both registries. @@ -353,51 +353,5 @@ jobs: subject-digest: ${{ steps.index.outputs.digest }} push-to-registry: true - # A final release (never a candidate) moves X.Y, X and latest forward to the - # attested index. This is its own job so a failed alias write can be rerun - # alone ("Re-run failed jobs") from the already published index; it first - # re-verifies that both exact tags name that index. One repository-wide - # group serializes alias writes across native releases, and the script - # rechecks each alias's current version in the registry right before - # writing, so an older release never moves an alias backward. - aliases: - name: Move the rolling tags of a final release - needs: [plan, publish] - if: github.ref == format('refs/tags/{0}', inputs.tag) && needs.plan.outputs.dockerhub == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: dockerhub-native - concurrency: - group: durable-workflow-server-rolling-aliases - cancel-in-progress: false - permissions: - contents: read - packages: write - steps: - - name: Check out trusted release tooling - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ github.sha }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - - - name: Log in to Docker Hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Log in to GHCR - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Reconcile the rolling tags with the attested index - env: - RELEASE_TAG: ${{ inputs.tag }} - INDEX_DIGEST: ${{ needs.publish.outputs.digest }} - RELEASE_IMAGE_TAG_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git - run: bash scripts/ci/native-release-rolling-tags.sh + # Rolling aliases are deliberately absent. Future final tags containing + # this source use native-alias-promotion.yml after existing qualification. diff --git a/.github/workflows/published-compose.yml b/.github/workflows/published-compose.yml index cff310952..a3878511f 100644 --- a/.github/workflows/published-compose.yml +++ b/.github/workflows/published-compose.yml @@ -1,9 +1,13 @@ name: Published Compose Smoke +run-name: Published Compose ${{ inputs.server_image || github.ref }} on: pull_request: paths: - docker-compose.published.yml + - scripts/ci/native_qualification.py + - scripts/ci/native_alias_transport.py + - scripts/ci/native_alias_evidence.py - scripts/smoke-published-compose.sh - scripts/ci/select-published-compose-image.sh - .github/workflows/published-compose.yml @@ -16,6 +20,7 @@ on: permissions: contents: read + actions: read concurrency: group: published-compose-${{ github.event_name }}-${{ github.ref }} @@ -26,6 +31,8 @@ jobs: name: ${{ matrix.platform }} ${{ matrix.profile }} published image runs-on: ubuntu-latest timeout-minutes: 35 + env: + DW_PUBLISHED_COMPOSE_PROJECT: published-compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.shard }} strategy: fail-fast: false @@ -58,20 +65,44 @@ jobs: INPUT_SERVER_IMAGE: ${{ github.event_name == 'workflow_dispatch' && inputs.server_image || '' }} run: scripts/ci/select-published-compose-image.sh + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Smoke published Compose stack + id: body env: DW_SERVER_PLATFORM: ${{ matrix.platform }} DW_PUBLISHED_COMPOSE_PROFILE: ${{ matrix.profile }} - DW_PUBLISHED_COMPOSE_PROJECT: published-compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.shard }} SERVER_PORT: "0" DW_AUTH_TOKEN: published-compose-token run: scripts/smoke-published-compose.sh - - name: Clean up published Compose stack + - name: Verify owned qualification cleanup + id: cleanup + if: always() + run: | + python3 scripts/ci/native_qualification.py cleanup --kind compose \ + --scope "$DW_PUBLISHED_COMPOSE_PROJECT" --output qualification-state/cleanup.json + + - name: Finalize promotion receipt if: always() env: - DW_PUBLISHED_COMPOSE_PROJECT: published-compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.shard }} + GH_TOKEN: ${{ github.token }} + QUALIFICATION_BODY_OUTCOME: ${{ steps.body.outcome }} + QUALIFICATION_CLEANUP_OUTCOME: ${{ steps.cleanup.outcome }} + PLATFORM: ${{ matrix.platform }} + PROFILE: ${{ matrix.profile }} run: >- - docker compose -p "$DW_PUBLISHED_COMPOSE_PROJECT" - -f docker-compose.published.yml down -v --remove-orphans - >/dev/null 2>&1 || true + python3 scripts/ci/native_qualification.py finalize --kind compose --image "${DW_SERVER_IMAGE:-}" + --platform "$PLATFORM" --profile "$PROFILE" + --identity qualification-state/image.json --observations qualification-state/observations.json + --cleanup qualification-state/cleanup.json --output promotion-receipt/receipt.json + + - name: Keep promotion receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-compose-promotion-receipt-${{ matrix.shard }} + path: promotion-receipt/receipt.json + retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41721262b..022321297 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: name: Build and push Docker image runs-on: ubuntu-latest environment: dockerhub - # The rolling-alias lock the native release's alias job also holds, so a + # The rolling-alias lock the native promotion job also holds, so a # PHP and a native release never move rolling tags at the same time. # cancel-in-progress is false: a running job is never cancelled. concurrency: diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 36bc297d0..66ce535cf 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -484,8 +484,8 @@ jobs: # Each database runs in six parts on separate runners: the native # database suites and TLS checks, the shared corpus, its profiles and # restart cases, the published-SDK direction profiles, the corrected - # marker cohort, and the other cohorts. The two MySQL 8 labels add a - # seventh, native-tests-b, for half their execution tests. Every part + # marker cohort, and the other cohorts. Each label adds a seventh, + # native-tests-b, for half its execution tests. Every part # bootstraps the same database and native build; no part depends on # another. matrix: @@ -496,9 +496,8 @@ jobs: image: mirror.gcr.io/library/mysql@sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b # The expanded original was OOM-killed at 1 GiB during native pressure. memory: 2g - # The two MySQL 8 labels' native suites were the slowest parts - # (about 21 min each, most of it the execution tests), so their - # execution tests run in two halves: native-tests runs the first + # The native execution suites run in two halves to keep one + # database label from holding the result: native-tests runs the first # and native-tests-b the second. The bytewise-sorted test list is # dealt round-robin, so each listed test runs in exactly one half # and a new test joins one. @@ -522,6 +521,12 @@ jobs: image: mirror.gcr.io/library/mariadb@sha256:7db29378d4fdab73f8123bbc2b48905c90d1a4b00cf848b028f1e81e623257f2 # Both expanded originals exhausted the 512 MiB DB fixture ceiling. memory: 1g + execution_shards: 2 + - label: mariadb-10.11 + part: native-tests-b + image: mirror.gcr.io/library/mariadb@sha256:7db29378d4fdab73f8123bbc2b48905c90d1a4b00cf848b028f1e81e623257f2 + memory: 1g + execution_shards: 2 env: DW_PARITY_PART: ${{ matrix.part }} DW_EXECUTION_SHARDS: ${{ matrix.execution_shards || 1 }} @@ -707,7 +712,7 @@ jobs: -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" \ -v "$PWD/parity-evidence:/evidence" -w /source/rust --entrypoint bash \ "${NATIVE_RUST_IMAGE}" -c \ - 'case "$DW_PARITY_PART" in native-tests) shard=1 suffix= ;; native-tests-b) shard=2 suffix=-b ;; *) shard= ;; esac; if [ "$DW_PARITY_PART" = native-tests ]; then cargo test --locked --offline --lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child --nocapture --test-threads=2 > /evidence/mysql-tests.txt || exit 1; fi; if [ -n "$shard" ]; then cargo test --locked --offline --test execution -- --list --format terse | sed -n "s/: test\$//p" | LC_ALL=C sort | awk -v count="$DW_EXECUTION_SHARDS" -v shard="$shard" "(NR - 1) % count == shard - 1" > /evidence/execution-shard$suffix.txt && test -s /evidence/execution-shard$suffix.txt && cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 $(cat /evidence/execution-shard$suffix.txt) > /evidence/mysql-execution-tests$suffix.txt || exit 1; fi; case "$DW_PARITY_PART" in native-tests|native-tests-b) cargo build --locked --offline ;; *) test -x /target/debug/durable-workflow-server ;; esac' + 'case "$DW_PARITY_PART" in native-tests) shard=1 suffix= ;; native-tests-b) shard=2 suffix=-b ;; *) shard= ;; esac; set -o pipefail; if [ "$DW_PARITY_PART" = native-tests ]; then cargo test --locked --offline --lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child --nocapture --test-threads=2 > /evidence/mysql-tests.txt || exit 1; fi; if [ -n "$shard" ]; then cargo test --locked --offline --test execution -- --list --format terse | sed -n "s/: test\$//p" | LC_ALL=C sort | tee /evidence/execution-listed-tests.txt | awk -v count="$DW_EXECUTION_SHARDS" -v shard="$shard" "(NR - 1) % count == shard - 1" > /evidence/execution-shard$suffix.txt && test -s /evidence/execution-shard$suffix.txt && cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 $(cat /evidence/execution-shard$suffix.txt) > /evidence/mysql-execution-tests$suffix.txt || exit 1; fi; case "$DW_PARITY_PART" in native-tests|native-tests-b) cargo build --locked --offline ;; *) test -x /target/debug/durable-workflow-server ;; esac' docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ -e DW_RUST_EXPERIMENTAL=1 -e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_DATABASE=native_ref \ -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password \ @@ -2400,6 +2405,7 @@ jobs: --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json \ --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json \ + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2415,6 +2421,7 @@ jobs: --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json \ --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json \ + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2430,6 +2437,7 @@ jobs: --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json \ --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json \ + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2444,6 +2452,7 @@ jobs: --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json \ --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json \ + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2465,6 +2474,9 @@ jobs: # under the frozen PHP reference's SQLite maintenance writer. namespace_delete_payloads_status=0 bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 namespace-delete-payloads || namespace_delete_payloads_status=$? + # Post-prune payload cleanup has the same frozen PHP writer contention. + retention_payloads_status=0 + bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 retention-external-payloads || retention_payloads_status=$? # A native fixture difference fails the step only after the # comparison has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 @@ -2472,6 +2484,7 @@ jobs: test "$service_calls_status" -eq 0 test "$schedule_catalog_status" -eq 0 test "$namespace_delete_payloads_status" -eq 0 + test "$retention_payloads_status" -eq 0 - name: Compare published patch, legacy-marker and range-repair profiles if: ${{ matrix.part == 'restarts' }} env: diff --git a/README.md b/README.md index db3c4229d..629b2a644 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,9 @@ docker compose up -d --wait Repository checks and focused test commands are documented in [CONTRIBUTING.md](CONTRIBUTING.md). Bugs and feature requests belong in -[GitHub Issues](https://github.com/durable-workflow/server/issues). +[GitHub Issues](https://github.com/durable-workflow/server/issues). The +[native publication and alias-promotion runbook](docs/native-release-promotion.md) +describes the separate qualification and protected release phases. ## License diff --git a/docs/native-release-promotion.md b/docs/native-release-promotion.md new file mode 100644 index 000000000..390049109 --- /dev/null +++ b/docs/native-release-promotion.md @@ -0,0 +1,156 @@ +# Native publication and rolling alias promotion + +This source contract applies to future release tags containing the #1020 +workflows and receipt producers. It does not change immutable older tag +workflows or qualify historical runs. An old green run without these receipts +cannot be annotated into eligibility. Release execution, registry writes and +any frozen release remain subject to their separate operator and Security gates. + +## When the rolling tags move + +`native-release.yml` publishes and attests exact version tags only. It never +moves `X.Y`, `X` or `latest`. A final release follows this order: + +1. Publish both architecture children and the same index in both registries. +2. Run the existing native-image corpus on that exact GHCR index from the release + tag, then the existing published-Compose four-cell matrix on that same index + from that tag. These are separately admitted invocations, not automatic + promotion workflow dispatches. +3. Review the exact current run attempts, provider job identities and five + canonical receipt artifacts. Dispatch `native-alias-promotion.yml` from the + exact release tag, a matching `tag` input and that data-only selection manifest. +4. An unconditional, unprivileged plan first binds ref, tag, manifest source and + protected-main ancestry. The read-only job then verifies the selection. After environment approval, the + mutation job repeats the entire verification while holding + `durable-workflow-server-rolling-aliases`, then calls the existing monotonic + alias helper. Only this job receives packages-write and existing Docker Hub + credentials; it has no contents-write. +5. Create the GitHub Release from protected main using + `native-github-release.yml`, the exact publication run attempt and the same + qualification manifest. Final Release creation verifies qualification again + and requires each relevant live alias in both registries to name this exact + index or a strictly newer final release. It cannot perform promotion. + +Candidates keep their existing publication/prerelease behavior and never move +aliases. A final Release that already exists before promotion causes an explicit +refusal. Escalate the exact legacy case to General; the promotion phase never +edits a Release or treats it as qualification. Missing historical receipts refuse. + +## Evidence selection and authority + +The input is at most 8,192 UTF-8 bytes of JSON with the closed schema +`durable-workflow.native-alias-promotion-evidence.v1`. Unknown/duplicate keys, +coerced IDs, extra/missing/duplicate cells and nonfinal tags refuse. + +| Object | Required fields | +| --- | --- | +| Root | `schema`, `release`, `corpus`, `compose` | +| `release` | `tag`, `source_commit`, `index_digest`, `run_id`, `run_attempt` | +| `corpus` | `run_id`, `run_attempt`, `runner_commit`, `workflow_blob`, `artifact_id`, `artifact_digest` | +| `compose` | `run_id`, `run_attempt`, `runner_commit`, `workflow_blob`, `cells` | +| Each Compose cell | `platform`, `profile`, `job_id`, `artifact_id`, `artifact_digest` | + +IDs/attempts are positive actual integers no greater than 2^53−1. Commits and Git +blob identities are lowercase full SHA-1; digests are `sha256:` plus 64 lowercase +hex digits. The three run IDs and five artifact IDs are distinct. Compose cells +are exactly `linux/amd64` and `linux/arm64`, each with `local` and `production`. +Both qualifier runner commits equal the release source commit, with workflow +blobs read at that exact commit; being another ancestor of main is insufficient. +No alternative runner allowlist exists in this version. + +Selection is not proof. Fixed `durable-workflow/server` provider reads establish +repository/head-repository IDs, numeric workflow identity/path, dispatch event, +tag/ref/source, exact current attempt, terminal successful jobs and every +critical step. All attempts' jobs are enumerated with a finite complete inventory; +newer/ambiguous attempts refuse. Trusted workflow run titles bind the original +canonical `ghcr.io/durable-workflow/server@sha256:...` dispatch input because the +run API does not expose dispatch inputs. Qualifiers must start after publication +completes. Tag resolution, protected-main ancestry, exact version tags and +provenance for the exact publication attempt are reverified in both registries. +There is no newest-run search, polling, automatic retry or invented age cutoff. + +The manifest selects exact artifact IDs and provider archive digests, never the +newest artifact by name. The verifier bounds each archive to 65,536 bytes and +accepts only one regular `receipt.json`, at most 32,768 expanded bytes, with a +bounded compression ratio. It refuses links, special files, directories, extra +members, prefixes/trailing carriers, encryption, duplicate/noncanonical JSON and +unknown ZIP extra fields other than a bounded standard timestamp. It verifies +downloaded SHA-256 before parsing and never extracts or executes the archive. +Artifacts must be nonexpired, belong to the selected run/source and have been +created within the selected attempt/job window. Current run and artifact metadata +are read again before verification returns; unavailable APIs are failures. + +## Producer receipts and cleanup + +The existing corpus job retains its broad comparison artifact and adds +`native-corpus-promotion-receipt`. Each existing Compose job adds +`native-compose-promotion-receipt--`. Each promotion artifact +contains only the canonical JSON receipt. PR/default/local image contexts emit a +separate diagnostic schema with `eligible=false`, never positive evidence. + +A positive `durable-workflow.native-qualification-receipt.v1` binds repository, +run/attempt/job, runner/workflow blob, original requested index, platform/profile, +resolved child manifest and image config, actual container config ID, +architecture/OS, source/release labels, whitelisted observations and cleanup. +The index, child manifest and config are separate identities. Registry membership +and both registries' source labels are checked independently by the consumer. + +Corpus observations require all six existing partition/status values to be +integer zero. Compose observations require serving health, readiness, exact +reported release version, worker registration, loopback binding, unset-token +refusal and, for production, default-token refusal. No token, registration body, +raw environment or host configuration enters a promotion receipt. Compose HTTP +responses use private temporary files and bounded requests; failures print a +static reason rather than authorization arguments or server logs. + +Provider CLI reads have a 30-second per-command deadline within a 180-second +read budget, bounded stdout and 4 KiB stderr. The synchronous helper owns each +direct child with default `SIGCHLD` handling. It observes exit using +[`waitid(WNOWAIT)`](https://man7.org/linux/man-pages/man2/wait.2.html), retaining +the child's kernel PID reservation until process-group cleanup finishes, then +reaps with at most one second of cleanup grace. It never reaps first and signals +the released number afterward. Missing non-reaping primitives or an automatic +reaper refuse before process creation. Unknown child authority refuses without +a numeric PID signal; signal/reap failure cannot return a successful read. + +Cleanup retains the original body result. It removes only exact owned Compose +project resources or containers carrying the exact corpus scope OWNER label, +owned network and observed owned volumes, including labelled standalone helper +containers. Names and network attachment discover candidates but never authorize +removal. Every discovered corpus container must have the exact OWNER label +before any removal; a foreign collision stops with zero removals. Successful independent Docker inventory reads must then show zero +owned containers, networks and volumes. API failure is unknown, never absence. +An anonymous volume must be mounted by an inspected owned container and have +either no labels or only the exact Engine anonymous marker. Foreign labels +refuse. Cleanup logs only a bounded static reason code when refusing. +There is no raw by-name fallback. The isolation checker recognizes only this +closed always-run kind/scope invocation bound to job identity and reviewed +helper/transport/predicate source bytes. Unknown ownership/API state retains +any remaining resource for escalation and never broadens deletion. +Only after successful body and cleanup steps does the existing job finalize its +receipt, repeating provider job and immutable workflow ownership. An `always()` +upload, failed/cancelled job or diagnostic receipt cannot satisfy promotion. +Arm64 Compose retains its existing QEMU scope, without a native-hardware claim. + +## Monotonic writes and recovery + +The alias helper preserves Git-version selection and native/PHP latest +arbitration. It reads current release labels and digests, then immediately before +each registry write re-reads both exact tags and every selected alias. A changed +or unreadable state stops. Absence is recognized only for the exact single-line +Buildx diagnostic `ERROR: : not found` for that requested tag. +Credential-helper, auth, transport, rate-limit, mixed/extra or unknown diagnostic +formats refuse. This is an explicit fail-closed client-format contract, not an +HTTP-status parser; a future client format requires separate source review. Previously confirmed writes must still name the +qualified index. Each write is verified before proceeding to the other registry. +Superseded aliases remain in place; a same-index recovery is idempotent. + +The shared lock coordinates repository-owned writers. It is not registry CAS: +an external actor can race after the last read. A write failure, timeout or +post-write mismatch stops with an explicit attempted/confirmed partial state; +there is no automatic rollback. General owns any concrete retry decision with +unchanged qualified evidence, including uncertain cross-registry outcomes. +Cloud retains its separate immutable-artifact/profile/installed-closure and +deployment gates; alias qualification does not grant deployment admission. +No production execution, release dispatch or registry mutation is admitted by +source tests or by merging these workflows. diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 83db79fb7..49be566d9 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import re import shlex import sys @@ -11,6 +12,15 @@ from dataclasses import dataclass from pathlib import Path +# Closed cleanup model, not a path exemption: every module used by the owner +# checks/CLI transport is bound to reviewed bytes. A source change requires an +# explicit model update and countercontrol review before gaining coverage. +QUALIFICATION_CLEANUP_SOURCES = { + "scripts/ci/native_qualification.py": "ea74b534004c04209843a0ca28bb5a338cd2f2bec56fde1e511da9ffc20a911e", + "scripts/ci/native_alias_evidence.py": "647d184c7a5e456410933a7188debc3fe23360d002ef6458ced742bb85f32c37", + "scripts/ci/native_alias_transport.py": "053af14689c7c69d6620860390801fc900a331126f32a4f68fac6eb0e6593fef", +} + WORKFLOW_SCOPE_PARTS = ( "${{ github.run_id }}", @@ -897,9 +907,47 @@ def resource_aliases( return aliases +def qualification_cleanup_resources( + command: str, resolution_source: str, root: Path | None, +) -> tuple[set[OwnedResource], list[str]]: + """Recognize one exact, idempotent, owner-gated helper invocation.""" + try: + tokens = shlex.split(re.sub(r"^run:\s*", "", command)) + except ValueError: + return set(), [] + if tokens[:3] != ["python3", "scripts/ci/native_qualification.py", "cleanup"]: + return set(), [] + reason = "strict qualification cleanup requires reviewed bytes, closed argv and job-scoped scope" + if (root is None or len(tokens) != 9 or tokens[3] != "--kind" + or tokens[4] not in {"corpus", "compose"} or tokens[5] != "--scope" + or tokens[7:] != ["--output", "qualification-state/cleanup.json"] + or re.fullmatch(r"\$(?:[A-Z][A-Z0-9_]*|\{[A-Z][A-Z0-9_]*\})", tokens[6]) is None + or not value_is_job_scoped(resolution_source, tokens[6])): + return set(), [reason] + try: + for relative, expected in QUALIFICATION_CLEANUP_SOURCES.items(): + path = root / relative + # Symlinked code could import a different adjacent module closure. + if path.resolve() != root.resolve() / relative: + return set(), [reason] + with path.open("rb") as stream: + data = stream.read(131073) + if len(data) > 131072 or hashlib.sha256(data).hexdigest() != expected: + return set(), [reason] + except OSError: + return set(), [reason] + scope = tokens[6] + if tokens[4] == "compose": + return {OwnedResource("compose", scope)}, [] + return {OwnedResource("network", scope), *( + OwnedResource("container", scope + "-" + suffix) + for suffix in ("rust", "server", "php-maintenance"))}, [] + + def cleanup_resources( job_source: str, resolution_source: str, + root: Path | None = None, ) -> tuple[set[OwnedResource], list[str]]: """Return resources removed by tolerant always-steps and cleanup violations.""" @@ -934,8 +982,17 @@ def cleanup_resources( for line_number, command in commands for scan_command in resource_command_variants(command) ] + unconditional_always = any(re.fullmatch( + r"\s+(?:-\s+)?if:\s*(?:always\(\)|\$\{\{\s*always\(\)\s*\}\})\s*", line) + for line in step.splitlines()) for _, command in commands: + modeled, errors = qualification_cleanup_resources(command, resolution_source, root) + if modeled and not unconditional_always: + modeled = set() + errors.append("strict qualification cleanup requires an unconditional always() step") + resources.update(modeled) + violations.extend(errors) for match in COMPOSE_COMMAND.finditer(command): arguments = command[match.end() :] if not COMPOSE_DOWN_OPERATION.search(arguments): @@ -2479,6 +2536,7 @@ def validate(root: Path) -> list[str]: cleaned, cleanup_violations = cleanup_resources( job_source, resource_source, + root, ) violations.extend( f"{workflow} job {job_name}: {violation}" diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index fd4ec8879..9527d1cce 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -8,6 +8,8 @@ # candidate (-rc.N) skip Docker Hub. # Each script is stopped at its input checks, before any API or registry call. set -euo pipefail +write_call="imagetools create" +failed="FAILED" fail() { printf 'Native release tag check failed: %s\n' "$1" >&2; exit 1; } # The retired PHP Release workflow (server#1002): no push trigger at all, so @@ -61,12 +63,34 @@ cat > "$scratch/docker" <<'FAKE' #!/usr/bin/env bash printf '%s\n' "$*" >> "$CALL_LOG" ref=${!#} +if [[ -n "${PUBLISH_LOOKUP_FAILURE:-}" && "$*" == *"imagetools inspect"* ]]; then + printf '%s\n' "$PUBLISH_LOOKUP_FAILURE" >&2; exit 73 +fi if [[ "$*" == *"imagetools inspect --format {{json .Image}}"* ]]; then + if [[ -n "${CURRENT_READ_FAILURE:-}" ]]; then + if [[ "$CURRENT_READ_FAILURE" == mixed-subject ]]; then printf 'ERROR: %s: not found\nerror getting credentials: helper not found\n' "$ref" >&2 + else printf '%s\n' "$CURRENT_READ_FAILURE" >&2; fi + exit 73 + fi + if [[ "${RACE_REF:-}" == "$ref" ]]; then + count=$(( $(cat "$RACE_COUNTER" 2>/dev/null || echo 0) + 1 )); echo "$count" > "$RACE_COUNTER" + if ((count > 1)); then echo '{"config":{"Labels":{"dev.durable-workflow.release.tag":"3.9.0"}}}'; exit 0; fi + fi label=$(awk -v r="$ref" '$1 == r { print $2 }' <<<"${ALIAS_STATE:-}") [[ -z "$label" ]] && { echo "ERROR: $ref: not found" >&2; exit 1; } [[ "$label" == unreadable ]] && { echo "ERROR: unauthorized" >&2; exit 1; } printf '{"linux/amd64":{"config":{"Labels":{"dev.durable-workflow.release.tag":"%s"}}}}\n' "$label" +elif [[ "$*" == *"imagetools create"* ]]; then + touch "$CALL_LOG.written" + if [[ "${FAIL_HUB_WRITE:-false}" == true && "$ref" == example/native@* ]]; then exit 73; fi elif [[ "$*" == *"imagetools inspect"* ]]; then + if [[ "${RACE_EXACT:-false}" == true && "$ref" == ghcr.io/example/native:3.0.0 ]]; then + count=$(( $(cat "$RACE_COUNTER" 2>/dev/null || echo 0) + 1 )); echo "$count" > "$RACE_COUNTER" + if ((count > 1)); then printf 'sha256:%064d\n' 0; exit 0; fi + fi + if [[ "${POST_WRITE_MISMATCH:-false}" == true && -f "$CALL_LOG.written" && "$ref" == ghcr.io/example/native:3.0 ]]; then + printf 'sha256:%064d\n' 0; exit 0 + fi if [[ "$ref" =~ :[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf '%s\n' "${EXACT_DIGEST:-$INDEX_DIGEST}"; else printf '%s\n' "$INDEX_DIGEST"; fi fi FAKE @@ -103,11 +127,73 @@ grep -q -- '--tag ghcr.io/example/native:3 \|--tag ghcr.io/example/native:latest && fail "an older release moved 3 or latest backward." # Exact tags that do not name the attested index, or an alias whose release # cannot be read, stop the run before any alias write. -want "exact tag mismatch" "$(rolling $'2.11.8' native-3.0.0 '' "sha256:$(printf '%064d' 0 | tr 0 b)")" "FAILED" -grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written after an exact-tag mismatch." -want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "FAILED" -grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written though an alias could not be read." +want "exact tag mismatch" "$(rolling $'2.11.8' native-3.0.0 '' "sha256:$(printf '%064d' 0 | tr 0 b)")" "$failed" +grep -q "$write_call" "$scratch/calls" && fail "aliases were written after an exact-tag mismatch." +want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "$failed" +grep -q "$write_call" "$scratch/calls" && fail "aliases were written though an alias could not be read." expect "INDEX_DIGEST must be the verified index digest" RELEASE_TAG=native-3.0.0 "${images[@]}" RELEASE_IMAGE_KNOWN_TAGS=x bash scripts/ci/native-release-rolling-tags.sh +# Detectable changes before the first write leave zero writes. A second +# registry failure or post-write mismatch records partial/uncertain state and +# never issues a rollback. These are fake registries, not network experiments. +rolling_control() { + : > "$scratch/calls"; : > "$scratch/summary"; rm -f "$scratch/counter" "$scratch/calls.written" + env -i PATH="$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" GITHUB_STEP_SUMMARY="$scratch/summary" \ + INDEX_DIGEST="$index" "${images[@]}" RELEASE_TAG=3.0.0 RELEASE_IMAGE_KNOWN_TAGS=3.0.0 \ + RACE_COUNTER="$scratch/counter" "$@" bash scripts/ci/native-release-rolling-tags.sh >/dev/null 2>&1 +} +for race in 'RACE_REF=ghcr.io/example/native:3.0' RACE_EXACT=true; do + if rolling_control "$race" ALIAS_STATE='ghcr.io/example/native:3.0 3.0.0'; then fail "changed pre-write state must refuse."; fi + grep -q "$write_call" "$scratch/calls" && fail "pre-write race wrote aliases." +done +if rolling_control FAIL_HUB_WRITE=true; then fail "second-registry failure must stop."; fi +[[ "$(grep -c "$write_call" "$scratch/calls")" = 2 ]] || fail "partial run must attempt exactly two forward writes." +grep -q 'confirmed=ghcr.io/example/native; no rollback' "$scratch/summary" || fail "partial run lost confirmed registry record." +[[ "$(grep -c 'alias write attempted:' "$scratch/summary")" = 2 ]] || fail "each attempt must be persisted before a failed or hanging write." +[[ "$(grep -c 'alias write confirmed:' "$scratch/summary")" = 1 ]] || fail "only verified writes may be persisted as confirmed." +if rolling_control POST_WRITE_MISMATCH=true; then fail "post-write mismatch must stop."; fi +[[ "$(grep -c "$write_call" "$scratch/calls")" = 1 ]] || fail "post-write mismatch must not touch registry two or roll back." +grep -q 'no rollback' "$scratch/summary" || fail "uncertain run must record no rollback." +# Final Release ordering cannot run the writer. Exact live aliases pass; +# missing/older aliases refuse; uniformly newer final aliases may supersede. +for state in "" 'ghcr.io/example/native:3.0 2.11.8'; do + if rolling_control VERIFY_ONLY=true ALIAS_STATE="$state"; then fail "unpromoted live state must refuse."; fi + grep -q "$write_call" "$scratch/calls" && fail "live-state verification mutated aliases." +done +live_state=$'ghcr.io/example/native:3.0 3.0.0\nexample/native:3.0 3.0.0\nghcr.io/example/native:3 3.0.0\nexample/native:3 3.0.0\nghcr.io/example/native:latest 3.0.0\nexample/native:latest 3.0.0' +rolling_control VERIFY_ONLY=true ALIAS_STATE="$live_state" || fail "qualified same-index live state must pass." +grep -q "$write_call" "$scratch/calls" && fail "same-index live-state verification wrote." + +# Credential/transport/auth/rate failures are never evidence of alias or +# version-tag absence, even when their text contains the words "not found". +read_failures=( + 'error getting credentials: credential helper not found' + 'error getting credentials: executable file not found in $PATH' + 'HTTP 401 Unauthorized: authentication required' + 'insufficient_scope: authorization failed' + 'dial tcp: registry host not found' + 'TLS CA file not found' + 'HTTP 429 Too Many Requests' + 'manifest unknown' + 'ERROR: unrelated.invalid/server:latest: not found' + mixed-subject +) +for failure in "${read_failures[@]}"; do + if rolling_control CURRENT_READ_FAILURE="$failure"; then fail "failed alias lookup was accepted as absent."; fi + grep -q "$write_call" "$scratch/calls" && fail "failed alias lookup reached registry writes." + : > "$scratch/calls" + if env -i PATH="$scratch:$PATH" CALL_LOG="$scratch/calls" PUBLISH_LOOKUP_FAILURE="$failure" \ + "${images[@]}" RELEASE_TAG=3.0.0 PUBLISH_DOCKERHUB=true DIGEST_AMD64="$index" DIGEST_ARM64="$index" \ + bash "$publish" >/dev/null 2>&1; then fail "failed exact-tag lookup was accepted as absent."; fi + grep -q "$write_call" "$scratch/calls" && fail "failed exact-tag lookup reached publication writes." +done +source scripts/ci/native-registry-absence.sh +native_registry_subject_missing ghcr.io/example/native:latest 'ERROR: ghcr.io/example/native:latest: not found' \ + || fail "the exact subject-bound not-found format must be recognized." +for output in 'not found' 'ERROR: example/native:latest: not found' 'ERROR: ghcr.io/example/native:latest: manifest unknown' \ + $'ERROR: ghcr.io/example/native:latest: not found\ncredential helper not found'; do + if native_registry_subject_missing ghcr.io/example/native:latest "$output"; then fail "unbound/mixed absence diagnostic accepted."; fi +done + # The GitHub Release, from protected main (native-github-release.yml): it # verifies the tag, the run, both exact tags and the provenance, then marks # a final Latest only when the live latest alias names its index in both @@ -122,8 +208,9 @@ case "$1 $2" in while [[ $# -gt 0 && "$1" != --notes ]]; do shift; done printf '%s' "${2:-}" > "$GH_NOTES"; exit 0 ;; "attestation verify") - printf '%s\n' "$*" > "$GH_ATTEST_LOG" + printf '%s\n' "$*" >> "$GH_ATTEST_LOG" [[ "${ATTEST:-ok}" == ok ]] || exit 1 + [[ "${ATTEST_FAIL_IMAGE:-}" != "$3" ]] || exit 1 printf '%s\n' "${ATTEST_URI:-https://github.com/example/server/actions/runs/${RUN_ID}/attempts/1}"; exit 0 ;; esac path=$2 @@ -132,7 +219,7 @@ case "$path" in repos/example/server/git/tags/*) printf 'commit %s\n' "$COMMIT" ;; repos/example/server/compare/*) printf '%s\n' "${COMPARE:-ahead}" ;; repos/example/server/releases/latest) printf '%s\n' "${LATEST_TAG:-}" ;; - repos/example/server/actions/runs/*) printf '%s\n' "${RUN_LINE:-.github/workflows/native-release.yml workflow_dispatch completed success $RELEASE_TAG $COMMIT}" ;; + repos/example/server/actions/runs/*) printf '%s\n' "${RUN_LINE:-.github/workflows/native-release.yml workflow_dispatch completed success $RELEASE_TAG $COMMIT ${RUN_ATTEMPT:-1}}" ;; *) exit 1 ;; esac FAKE @@ -140,6 +227,11 @@ cat > "$scratch/release-docker" <<'FAKE' #!/usr/bin/env bash ref=${!#} if [[ "$ref" == *:latest ]]; then + if [[ -n "${LATEST_READ_FAILURE:-}" ]]; then + if [[ "$LATEST_READ_FAILURE" == mixed-subject ]]; then printf 'ERROR: %s: not found\ncredential helper not found\n' "$ref" >&2 + else printf '%s\n' "$LATEST_READ_FAILURE" >&2; fi + exit 73 + fi digest=$(awk -v r="${ref%:latest}" '$1 == r { print $2 }' <<<"${LATEST_STATE:-}") [[ -z "$digest" ]] && { echo "ERROR: $ref: not found" >&2; exit 1; } [[ "$digest" == unreadable ]] && { echo "ERROR: unauthorized" >&2; exit 1; } @@ -151,13 +243,22 @@ else fi FAKE chmod +x "$scratch/gh" "$scratch/release-docker" +# The publication/Release unit controls stub only the independently tested +# qualification verifier; its actual provider/receipt/zero-write tests run below. +cat > "$scratch/python3" <<'FAKE' +#!/usr/bin/env bash +[[ "$1" == */native_alias_promote.py && "$2" == release-order ]] || exit 1 +[[ -n "${PROMOTION_EVIDENCE:-}" ]] || exit 1 +exit "${QUALIFICATION_STATUS:-0}" +FAKE +chmod +x "$scratch/python3" newer_index=sha256:$(printf '%064d' 0 | tr 0 b) gh_release() { # release tag, latest state [, VAR=value ...] -> the Latest choice local tag=$1 state=$2; shift 2 : > "$scratch/gh_calls"; : > "$scratch/gh_attest" - env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" GH_ATTEST_LOG="$scratch/gh_attest" GH_NOTES="$scratch/gh_notes" \ + env -i PATH="$scratch:$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" GH_ATTEST_LOG="$scratch/gh_attest" GH_NOTES="$scratch/gh_notes" \ DOCKER="$scratch/release-docker" "${images[@]}" GITHUB_REPOSITORY=example/server \ - RELEASE_TAG="$tag" RELEASE_RUN_ID=41 RUN_ID=41 COMMIT="$commit" INDEX="$index" LATEST_STATE="$state" "$@" \ + RELEASE_TAG="$tag" RELEASE_RUN_ID=41 RELEASE_RUN_ATTEMPT=1 PROMOTION_EVIDENCE=synthetic-selected-evidence RUN_ID=41 COMMIT="$commit" INDEX="$index" LATEST_STATE="$state" "$@" \ bash scripts/ci/native-release-github-release.sh >/dev/null 2>&1 || { echo FAILED; return; } grep -q -- '^release create ' "$scratch/gh_calls" || { echo none; return; } grep -q -- '--verify-tag --title ' "$scratch/gh_calls" || { echo FAILED; return; } @@ -174,6 +275,8 @@ grep -q -- "--source-ref refs/tags/3.1.0 --source-digest $commit" "$scratch/gh_a || fail "the provenance must be checked against the tag ref and its commit." grep -q -- "--signer-workflow github.com/example/server/.github/workflows/native-release.yml" "$scratch/gh_attest" \ || fail "the provenance must name native-release.yml as its signer." +grep -q 'oci://ghcr.io/example/native@' "$scratch/gh_attest" && grep -q 'oci://example/native@' "$scratch/gh_attest" \ + || fail "both registry provenance subjects must verify." backport=$(rolling $'2.11.8\nnative-3.0.1\n3.1.0' 3.0.2 $'ghcr.io/example/native:3 3.1.0\nexample/native:3 3.1.0\nghcr.io/example/native:latest 3.1.0\nexample/native:latest 3.1.0') want "3.0.2 backport behind 3.1.0" "$backport" "3.0" want_release "3.0.2 backport behind 3.1.0" "$(gh_release 3.0.2 "$theirs")" "--latest=false" @@ -196,41 +299,58 @@ existing() { # tag, title, draft, prerelease, notes want_release "rerun of the same final, Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "none" want_release "rerun of the same candidate" "$(gh_release 3.1.0-rc.1 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0-rc.1 3.1.0-rc.1 false true "$notes_candidate")")" "none" want_release "rerun of the same backport, not Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.1.0 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "none" -want_release "existing draft" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 true false "$notes_final")")" "FAILED" -want_release "existing release with other notes" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final extra")")" "FAILED" -want_release "existing notes naming everything but ambiguous" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$index $commit https://github.com/example/server/actions/runs/41")")" "FAILED" -want_release "existing release with another title" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 native-3.1.0 false false "$notes_final")")" "FAILED" -want_release "existing final marked prerelease" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false true "$notes_final")")" "FAILED" -want_release "existing final the alias owns but not Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" -want_release "existing backport marked Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.0.2 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "FAILED" +want_release "existing draft" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 true false "$notes_final")")" "$failed" +want_release "existing release with other notes" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final extra")")" "$failed" +want_release "existing notes naming everything but ambiguous" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$index $commit https://github.com/example/server/actions/runs/41")")" "$failed" +want_release "existing release with another title" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 native-3.1.0 false false "$notes_final")")" "$failed" +want_release "existing final marked prerelease" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false true "$notes_final")")" "$failed" +want_release "existing final the alias owns but not Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" +want_release "existing backport marked Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.0.2 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "$failed" # The bindings come first: an existing Release does not excuse a failed one. -want_release "existing release, unverifiable provenance" "$(gh_release 3.1.0 "$ours" ATTEST=fail LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" -want_release "existing release, commit off main" "$(gh_release 3.1.0 "$ours" COMPARE=diverged LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" +want_release "existing release, unverifiable provenance" "$(gh_release 3.1.0 "$ours" ATTEST=fail LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" +want_release "existing release, commit off main" "$(gh_release 3.1.0 "$ours" COMPARE=diverged LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" # Every binding fails closed, before any write. -want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "FAILED" -want_release "a PHP tag" "$(gh_release 2.11.9 "$ours")" "FAILED" -want_release "no run id" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ID=)" "FAILED" -want_release "tag commit not on main" "$(gh_release 3.0.2 "$ours" COMPARE=diverged)" "FAILED" -want_release "tag names a tree" "$(gh_release 3.0.2 "$ours" TAG_OBJECT="tree $commit")" "FAILED" -want_release "run failed" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed failure 3.0.2 $commit")" "FAILED" -want_release "run of another workflow" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/release.yml workflow_dispatch completed success 3.0.2 $commit")" "FAILED" -want_release "run from another ref" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success main $commit")" "FAILED" -want_release "run at another commit" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success 3.0.2 fedcba9876543210fedcba9876543210fedcba98")" "FAILED" -want_release "registries name different indexes" "$(gh_release 3.0.2 "$ours" HUB_EXACT="$newer_index")" "FAILED" -want_release "provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST=fail)" "FAILED" -want_release "provenance from another run" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/42/attempts/1)" "FAILED" -want_release "provenance from a run id prefix" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/411/attempts/1)" "FAILED" +want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "$failed" +want_release "a PHP tag" "$(gh_release 2.11.9 "$ours")" "$failed" +want_release "no run id" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ID=)" "$failed" +want_release "tag commit not on main" "$(gh_release 3.0.2 "$ours" COMPARE=diverged)" "$failed" +want_release "tag names a tree" "$(gh_release 3.0.2 "$ours" TAG_OBJECT="tree $commit")" "$failed" +want_release "run failed" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed failure 3.0.2 $commit")" "$failed" +want_release "run of another workflow" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/release.yml workflow_dispatch completed success 3.0.2 $commit")" "$failed" +want_release "run from another ref" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success main $commit")" "$failed" +want_release "run at another commit" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success 3.0.2 fedcba9876543210fedcba9876543210fedcba98")" "$failed" +want_release "registries name different indexes" "$(gh_release 3.0.2 "$ours" HUB_EXACT="$newer_index")" "$failed" +want_release "provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST=fail)" "$failed" +want_release "Docker Hub provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST_FAIL_IMAGE="oci://example/native@$index")" "$failed" +[[ ! -s "$scratch/gh_calls" ]] || fail "Release was created after Docker Hub provenance failure." +want_release "provenance from another run" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/42/attempts/1)" "$failed" +want_release "provenance from a run id prefix" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/411/attempts/1)" "$failed" + +want_release "missing exact publication attempt" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ATTEMPT=)" "$failed" +want_release "publication current attempt changed" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ATTEMPT=2)" "$failed" +want_release "provenance from another attempt" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/41/attempts/2)" "$failed" +want_release "qualification refusal" "$(gh_release 3.0.2 "$ours" QUALIFICATION_STATUS=1)" "$failed" +[[ ! -s "$scratch/gh_calls" ]] || fail "Release was created despite qualification refusal." +want_release "final missing qualification" "$(gh_release 3.0.2 "$ours" PROMOTION_EVIDENCE=)" "$failed" +want_release "candidate needs no alias qualification" "$(gh_release 3.1.0-rc.1 "$ours" PROMOTION_EVIDENCE= QUALIFICATION_STATUS=1)" "--prerelease --latest=false" + +for failure in "${read_failures[@]}"; do + want_release "failed latest read remains unknown" "$(gh_release 3.0.2 "$ours" LATEST_READ_FAILURE="$failure")" "$failed" + [[ ! -s "$scratch/gh_calls" ]] || fail "failed latest lookup reached Release creation." +done +want_release "malformed successful latest digest" "$(gh_release 3.0.2 'ghcr.io/example/native not-a-digest')" "$failed" +[[ ! -s "$scratch/gh_calls" ]] || fail "malformed latest digest reached Release creation." # Release identity: only a strict native- label admits a release image, and # only when its server reports exactly that version. identity=scripts/ci/native-release-identity.sh -test "$(bash "$identity" expect native-3.0.0)" = 3.0.0 || fail "native-3.0.0 must admit 3.0.0." -test "$(bash "$identity" expect 3.0.2)" = 3.0.2 || fail "3.0.2 must admit 3.0.2." -test "$(bash "$identity" expect 3.1.0-rc.1)" = 3.1.0-rc.1 || fail "3.1.0-rc.1 must admit 3.1.0-rc.1." +[[ "$(bash "$identity" expect native-3.0.0)" = 3.0.0 ]] || fail "native-3.0.0 must admit 3.0.0." +[[ "$(bash "$identity" expect 3.0.2)" = 3.0.2 ]] || fail "3.0.2 must admit 3.0.2." +[[ "$(bash "$identity" expect 3.1.0-rc.1)" = 3.1.0-rc.1 ]] || fail "3.1.0-rc.1 must admit 3.1.0-rc.1." if bash "$identity" expect 2.11.9 >/dev/null 2>&1; then fail "a plain 2.x tag is a PHP release, not native."; fi -test "$(bash "$identity" expect native-3.0.0-rc.3)" = 3.0.0-rc.3 || fail "native-3.0.0-rc.3 must admit 3.0.0-rc.3." -test -z "$(bash "$identity" expect '')" || fail "an unlabelled image is a development image." -test -z "$(bash "$identity" expect '')" || fail "an unlabelled image is a development image." +[[ "$(bash "$identity" expect native-3.0.0-rc.3)" = 3.0.0-rc.3 ]] || fail "native-3.0.0-rc.3 must admit 3.0.0-rc.3." +[[ -z "$(bash "$identity" expect '')" ]] || fail "an unlabelled image is a development image." +[[ -z "$(bash "$identity" expect '')" ]] || fail "an unlabelled image is a development image." for label in 2.11.9 3.0 3.0.0-beta native-3.0 native-3.0.0-beta native-3.0.0x 'native-3.0.0 '; do if bash "$identity" expect "$label" >/dev/null 2>&1; then fail "label '$label' must be refused."; fi done @@ -263,7 +383,7 @@ for script in promote-release-image-rolling-tags.sh resolve-release-image-rollin sh "scripts/ci/$script" >/dev/null 2>&1; then fail "$script must fail when the tag lookup fails." fi - test ! -s "$scratch/calls" || fail "$script wrote to a registry after a failed tag lookup." + [[ ! -s "$scratch/calls" ]] || fail "$script wrote to a registry after a failed tag lookup." done rm -f "$scratch/git" @@ -292,10 +412,10 @@ php_stage() { # $1: how the promoter gets tags (inventory|lookup) -> promoter ex fi printf '%s\n' "$status" } -test "$(php_stage lookup)" != 0 || fail "a promoter whose second lookup fails must exit nonzero." -test ! -s "$scratch/calls" || fail "a promoter whose second lookup fails wrote to a registry." -test "$(php_stage inventory)" = 0 || fail "the promoter must succeed from the resolver's inventory." -test "$(cat "$scratch/git-count")" = 1 || fail "the PHP stage must read the tags exactly once." +[[ "$(php_stage lookup)" != 0 ]] || fail "a promoter whose second lookup fails must exit nonzero." +[[ ! -s "$scratch/calls" ]] || fail "a promoter whose second lookup fails wrote to a registry." +[[ "$(php_stage inventory)" = 0 ]] || fail "the promoter must succeed from the resolver's inventory." +[[ "$(cat "$scratch/git-count")" = 1 ]] || fail "the PHP stage must read the tags exactly once." grep -q -- '--tag durableworkflow/server:2.11 --tag durableworkflow/server:2 ' "$scratch/calls" \ || fail "the PHP release must still move 2 and 2.11." grep -q ':latest' "$scratch/calls" && fail "the PHP release moved latest past native-3.0.0." @@ -305,13 +425,13 @@ if env -i PATH="$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" RELEAS VERIFIED_IMAGE_DIGEST="$index" sh scripts/ci/promote-release-image-rolling-tags.sh >/dev/null 2>&1; then fail "the promoter must refuse without a tag inventory." fi -test ! -s "$scratch/calls" || fail "the promoter wrote without a tag inventory." +[[ ! -s "$scratch/calls" ]] || fail "the promoter wrote without a tag inventory." rm -f "$scratch/git" "$scratch/git-count" # Both workflows hold the one rolling-alias lock, and the PHP promoter takes # the resolver's inventory rather than reading the tags again. # The GitHub Release job holds it too, so Latest is decided with no alias # moving underneath it. -for workflow in .github/workflows/release.yml .github/workflows/native-release.yml .github/workflows/native-github-release.yml; do +for workflow in .github/workflows/release.yml .github/workflows/native-alias-promotion.yml .github/workflows/native-github-release.yml; do test "$(grep -c '^ group: durable-workflow-server-rolling-aliases$' "$workflow")" = 1 \ || fail "$workflow must hold the durable-workflow-server-rolling-aliases lock in one job." done @@ -325,17 +445,17 @@ unprotected=$(awk ' jobs && /^ [a-z-]+: write$/ { writes = 1 } END { flush() } ' .github/workflows/native-release.yml | paste -sd' ' -) -test -z "$unprotected" || fail "native-release.yml jobs with a write scope outside dockerhub-native: $unprotected." +[[ -z "$unprotected" ]] || fail "native-release.yml jobs with a write scope outside dockerhub-native: $unprotected." # The GitHub Release workflow: one job, from protected main only, in its own # main-only Environment, the only contents: write in either workflow. github_release=.github/workflows/native-github-release.yml -test "$(grep -c '^ [a-z0-9-]*:$' <(sed -n '/^jobs:/,$p' "$github_release"))" = 1 || fail "$github_release must have one job." +[[ "$(grep -c '^ [a-z0-9-]*:$' <(sed -n '/^jobs:/,$p' "$github_release"))" = 1 ]] || fail "$github_release must have one job." grep -q "^ if: github.ref == 'refs/heads/main'$" "$github_release" || fail "$github_release must run only from main." grep -q '^ environment: github-release$' "$github_release" || fail "$github_release must run in the github-release Environment." grep -q '^ workflow_dispatch:$' "$github_release" && test "$(sed -n '/^on:/,/^permissions:/p' "$github_release" | grep -c '^ [a-z_]*:$')" = 1 \ || fail "$github_release must be triggered only by workflow_dispatch." -test "$(grep -c ' contents: write' "$github_release")" = 1 || fail "$github_release must hold contents: write once." -test "$(grep -c '^ *contents: write$' .github/workflows/native-release.yml)" = 0 || fail "native-release.yml must not hold contents: write." +[[ "$(grep -c ' contents: write' "$github_release")" = 1 ]] || fail "$github_release must hold contents: write once." +[[ "$(grep -c '^ *contents: write$' .github/workflows/native-release.yml)" = 0 ]] || fail "native-release.yml must not hold contents: write." grep -q 'RELEASE_IMAGE_KNOWN_TAGS: ${{ steps.rolling.outputs.release_inventory }}' .github/workflows/release.yml \ || fail "the PHP promoter must use the resolver's inventory." awk '/- name: Promote rolling image aliases/,/run: scripts\/ci\/promote-release-image-rolling-tags.sh/' .github/workflows/release.yml \ @@ -450,8 +570,7 @@ tag_gate="github.ref == format('refs/tags/{0}', inputs.tag)" expected_conditions="plan=none build-amd64=$tag_gate build-arm64=$tag_gate -publish=$tag_gate -aliases=$tag_gate && needs.plan.outputs.dockerhub == 'true'" +publish=$tag_gate" test "$job_conditions" = "$expected_conditions" \ || fail "native-release.yml job conditions changed; expected: $expected_conditions diff --git a/scripts/ci/native-registry-absence.sh b/scripts/ci/native-registry-absence.sh new file mode 100644 index 000000000..84936322b --- /dev/null +++ b/scripts/ci/native-registry-absence.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# The one recognized Buildx not-found diagnostic, for the exact requested tag. +# This is a fail-closed client-format contract, not a substring or HTTP parser. +# Unknown formats, multiple lines and all auth/helper/transport failures refuse. +native_registry_subject_missing() { + local expected_ref=$1 diagnostic=$2 + [[ ${#expected_ref} -le 512 && "$expected_ref" =~ ^[a-z0-9][a-z0-9./_-]*:[A-Za-z0-9_.-]+$ ]] || return 1 + [[ "$diagnostic" == "ERROR: $expected_ref: not found" ]] +} diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index 8fbdd298d..379441c3d 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -23,68 +23,27 @@ # Env: RELEASE_TAG, RELEASE_RUN_ID, GITHUB_REPOSITORY, GHCR_IMAGE, # DOCKERHUB_IMAGE. GH and DOCKER override the gh and docker commands. set -euo pipefail +source scripts/ci/native-registry-absence.sh fail() { printf 'GitHub Release failed: %s\n' "$1" >&2; exit 1; } -gh_bin=${GH:-gh} -docker_bin=${DOCKER:-docker} -: "${GITHUB_REPOSITORY:?}" "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" -repo=$GITHUB_REPOSITORY -workflow=.github/workflows/native-release.yml +# Reuse the same read-only authority checks as protected alias promotion. +source "$(dirname "${BASH_SOURCE[0]}")/native-release-verify-publication.sh" -# A plain tag is native only from major 3; native- names the earlier ones. -tag=${RELEASE_TAG:-} -if ! [[ "$tag" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then - fail "RELEASE_TAG must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)." -fi -version=${tag#native-} -run_id=${RELEASE_RUN_ID:-} -[[ "$run_id" =~ ^[1-9][0-9]*$ ]] || fail "RELEASE_RUN_ID must be the release run's numeric id." - -# The tag's commit, through an annotated tag object if there is one. -object=$("$gh_bin" api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha') \ - || fail "cannot read tag $tag." -if [[ "$object" == tag\ * ]]; then - object=$("$gh_bin" api "repos/$repo/git/tags/${object#tag }" --jq '.object.type + " " + .object.sha') \ - || fail "cannot read the tag object of $tag." +# A future final Release cannot precede exact qualification and live alias +# promotion (or a strictly newer live successor). Old/missing evidence refuses; +# the operator must decide a concrete legacy case, not annotate an old run. +if [[ "$version" != *-* ]]; then + python3 "$(dirname "${BASH_SOURCE[0]}")/native_alias_promote.py" release-order \ + || fail "qualified live-alias order is not proved." fi -[[ "$object" =~ ^commit\ ([0-9a-f]{40})$ ]] || fail "tag $tag does not name a commit ($object)." -commit=${BASH_REMATCH[1]} -status=$("$gh_bin" api "repos/$repo/compare/$commit...main" --jq .status) || fail "cannot compare $commit with main." -[[ "$status" == ahead || "$status" == identical ]] || fail "$commit is not on main (compare status: $status)." - -# The release run. -run=$("$gh_bin" api "repos/$repo/actions/runs/$run_id" \ - --jq '[.path, .event, .status, .conclusion, .head_branch, .head_sha] | join(" ")') \ - || fail "cannot read run $run_id." -expected="$workflow workflow_dispatch completed success $tag $commit" -[[ "$run" == "$expected" ]] || fail "run $run_id is '$run', not '$expected'." - -# Both exact tags name one index. -digest() { - "$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1" || fail "cannot read $1." -} -index=$(digest "$GHCR_IMAGE:$version") -[[ "$index" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "$GHCR_IMAGE:$version has no index digest ('$index')." -hub=$(digest "$DOCKERHUB_IMAGE:$version") -[[ "$hub" == "$index" ]] || fail "$DOCKERHUB_IMAGE:$version is $hub, not $index." - -# Its provenance: signed by the release workflow from the tag, at the -# commit, in this run. -invocations=$("$gh_bin" attestation verify "oci://$GHCR_IMAGE@$index" --repo "$repo" \ - --signer-workflow "github.com/$repo/$workflow" --source-ref "refs/tags/$tag" --source-digest "$commit" \ - --deny-self-hosted-runners --format json \ - --jq '.[].verificationResult.signature.certificate.runInvocationURI') \ - || fail "the provenance of $index does not verify for $tag at $commit." -grep -qE "^https://github\.com/$repo/actions/runs/$run_id/attempts/[0-9]+$" <<<"$invocations" \ - || fail "the provenance of $index names no attempt of run $run_id." - # The digest the latest alias names now; empty when it does not exist. latest_digest() { - local output - if ! output=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1:latest" 2>&1); then - grep -qiE 'not found|manifest unknown|name unknown' <<<"$output" && return 0 - fail "cannot read the current $1:latest: $(head -c 300 <<<"$output")" + local image="$1" output + if ! output=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:latest" 2>&1); then + native_registry_subject_missing "$image:latest" "$output" && return 0 + fail "cannot establish the current latest alias." fi + [[ "$output" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "latest alias returned no valid digest." printf '%s\n' "$output" } diff --git a/scripts/ci/native-release-publish.sh b/scripts/ci/native-release-publish.sh index c885000e0..62867bd0a 100755 --- a/scripts/ci/native-release-publish.sh +++ b/scripts/ci/native-release-publish.sh @@ -10,6 +10,7 @@ # 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 +source scripts/ci/native-registry-absence.sh fail() { printf 'Native release publication failed: %s\n' "$1" >&2; exit 1; } # A plain tag is native only from major 3, above every PHP release; native- # names the tags published before plain ones. @@ -41,8 +42,8 @@ for image in "${registries[@]}"; do 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:$image_tag is absent: $(head -c 300 <<<"$lookup")" + native_registry_subject_missing "$image:$image_tag" "$lookup" \ + || fail "could not establish exact version-tag absence." done for image in "${registries[@]}"; do diff --git a/scripts/ci/native-release-rolling-tags.sh b/scripts/ci/native-release-rolling-tags.sh index f4840d77e..cc8604435 100755 --- a/scripts/ci/native-release-rolling-tags.sh +++ b/scripts/ci/native-release-rolling-tags.sh @@ -22,9 +22,11 @@ # GHCR_IMAGE, DOCKERHUB_IMAGE, and the Git tags as RELEASE_IMAGE_KNOWN_TAGS # (one per line) or RELEASE_IMAGE_TAG_SOURCE_URL (git ls-remote). DOCKER # overrides the docker command. -set -euo pipefail +set -eEuo pipefail +source scripts/ci/native-registry-absence.sh fail() { printf 'Native rolling tags failed: %s\n' "$1" >&2; exit 1; } docker_bin=${DOCKER:-docker} +manifest_digest_format='{{.Manifest.Digest}}' # A plain tag is native only from major 3, above every PHP release; native- # names the tags published before plain ones. @@ -45,7 +47,7 @@ images=("$GHCR_IMAGE" "$DOCKERHUB_IMAGE") # The published release first: both exact tags must name the attested index. for image in "${images[@]}"; do - digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$version") \ + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$version") \ || fail "cannot read $image:$version." [[ "$digest" == "$INDEX_DIGEST" ]] || fail "$image:$version is $digest, not the attested $INDEX_DIGEST." done @@ -84,23 +86,49 @@ fi current_release() { local ref=$1 output label if ! output=$("$docker_bin" buildx imagetools inspect --format '{{json .Image}}' "$ref" 2>&1); then - grep -qiE 'not found|manifest unknown|name unknown' <<<"$output" && return 0 - fail "cannot read the current $ref: $(head -c 300 <<<"$output")" + native_registry_subject_missing "$ref" "$output" && return 0 + fail "cannot read a current alias." fi label=$(jq -r 'if has("linux/amd64") then .["linux/amd64"] else . end | .config.Labels["dev.durable-workflow.release.tag"] // empty' <<<"$output" 2>/dev/null) \ || fail "cannot read the release of the current $ref." label=${label#native-} [[ "$label" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]] \ - || fail "the current $ref carries no readable release tag ('$label')." + || fail "a current alias carries no readable release tag." printf '%s\n' "$label" } +# Final Release creation uses the same version rules, but cannot manufacture +# promotion: every applicable alias in BOTH registries must already name this +# index or a strictly newer final release. Candidates never enter this script. +if [[ "${VERIFY_ONLY:-false}" == true ]]; then + for alias in "$minor" "$major" latest; do + for image in "${images[@]}"; do + current=$(current_release "$image:$alias") + [[ -n "$current" ]] || fail "qualified live alias is absent." + [[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "qualified live alias must be final." + if newer "$current" "$version"; then continue; fi + [[ "$current" == "$version" ]] || fail "qualified live alias is older." + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$alias") + [[ "$digest" == "$INDEX_DIGEST" ]] || fail "qualified live alias names another index." + done + done + printf 'Qualified live alias state verified.\n' + exit 0 +fi + +declare -A snapshots=() aliases=() for alias in "${candidates[@]}"; do keep=1 for image in "${images[@]}"; do current=$(current_release "$image:$alias") + state=absent + if [[ -n "$current" ]]; then + state=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$alias") + [[ "$state" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "alias digest unreadable." + fi + snapshots["$image:$alias"]="$current $state" if [[ -n "$current" ]] && newer "$current" "$version"; then printf '%s:%s already names %s, newer than %s; it stays.\n' "$image" "$alias" "$current" "$version" keep=0 @@ -115,16 +143,58 @@ if ((${#aliases[@]} == 0)); then exit 0 fi +# This repository lock coordinates owned writers. Registries offer no CAS: +# an external writer can still race AFTER these reads. A mismatch is a stop, +# with explicit partial/uncertain state and no automatic rollback. +confirmed=() +attempted=none +partial() { + printf 'Native alias promotion stopped; attempted=%s confirmed=%s; no rollback.\n' \ + "$attempted" "${confirmed[*]:-none}" >&2 + printf -- '- alias promotion stopped; attempted=%s confirmed=%s; no rollback.\n' \ + "$attempted" "${confirmed[*]:-none}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" +} +trap partial ERR for image in "${images[@]}"; do + # Re-read BOTH exact tags and every selected alias immediately before EACH + # registry write. Previously confirmed writes must still name this index. + for checked in "${images[@]}"; do + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$checked:$version") + [[ "$digest" == "$INDEX_DIGEST" ]] || { partial; fail "exact tag changed before write."; } + for alias in "${aliases[@]}"; do + if [[ " ${confirmed[*]:-} " == *" $checked "* ]]; then + state=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$checked:$alias") + [[ "$state" == "$INDEX_DIGEST" ]] || { partial; fail "confirmed alias changed."; } + else + current=$(current_release "$checked:$alias") + state=absent + if [[ -n "$current" ]]; then + state=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$checked:$alias") + fi + [[ "$current $state" == "${snapshots["$checked:$alias"]}" ]] \ + || { partial; fail "alias changed before write."; } + fi + done + done args=() for alias in "${aliases[@]}"; do args+=(--tag "$image:$alias"); done + attempted=$image + printf 'Native alias write attempted: %s at %s.\n' "$image" "$INDEX_DIGEST" + printf -- '- alias write attempted: `%s` at `%s`\n' "$image" "$INDEX_DIGEST" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" "$docker_bin" buildx imagetools create "${args[@]}" "$image@$INDEX_DIGEST" + for alias in "${aliases[@]}"; do + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$alias") + [[ "$digest" == "$INDEX_DIGEST" ]] || { partial; fail "post-write alias mismatch."; } + done + confirmed+=("$image") + printf 'Native alias write confirmed: %s at %s.\n' "$image" "$INDEX_DIGEST" + printf -- '- alias write confirmed: `%s` at `%s`\n' "$image" "$INDEX_DIGEST" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" done # Each moved alias names exactly the attested index. for image in "${images[@]}"; do for alias in "${aliases[@]}"; do - digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$alias") [[ "$digest" == "$INDEX_DIGEST" ]] || fail "$image:$alias is $digest, not $INDEX_DIGEST." done done diff --git a/scripts/ci/native-release-verify-publication.sh b/scripts/ci/native-release-verify-publication.sh new file mode 100644 index 000000000..53e9f8136 --- /dev/null +++ b/scripts/ci/native-release-verify-publication.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Shared read-only tag/source/run-attempt/exact-tag/provenance verification. +# Sourced by Release creation; direct invocation emits only validated identity. +# No registry, Git ref or Release mutation is present in this helper. +set -euo pipefail +fail() { printf 'Native publication refused: %s\n' "$1" >&2; exit 1; } +gh_bin=${GH:-gh} +docker_bin=${DOCKER:-docker} +: "${GITHUB_REPOSITORY:?}" "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" +repo=$GITHUB_REPOSITORY +[[ "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail "invalid repository." +workflow=.github/workflows/native-release.yml + +# A plain tag is native only from major 3; native- names the earlier ones. +tag=${RELEASE_TAG:-} +if ! [[ "$tag" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + fail "RELEASE_TAG must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)." +fi +version=${tag#native-} +run_id=${RELEASE_RUN_ID:-} +[[ "$run_id" =~ ^[1-9][0-9]*$ ]] || fail "RELEASE_RUN_ID must be the release run's numeric id." + +run_attempt=${RELEASE_RUN_ATTEMPT:-} +[[ "$run_attempt" =~ ^[1-9][0-9]*$ ]] || fail "RELEASE_RUN_ATTEMPT must be the exact numeric attempt." + +# The tag's commit, through an annotated tag object if there is one. +object=$("$gh_bin" api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha') \ + || fail "cannot read tag $tag." +if [[ "$object" == tag\ * ]]; then + object=$("$gh_bin" api "repos/$repo/git/tags/${object#tag }" --jq '.object.type + " " + .object.sha') \ + || fail "cannot read the tag object of $tag." +fi +[[ "$object" =~ ^commit\ ([0-9a-f]{40})$ ]] || fail "tag $tag does not name a commit." +commit=${BASH_REMATCH[1]} +status=$("$gh_bin" api "repos/$repo/compare/$commit...main" --jq .status) || fail "cannot compare $commit with main." +[[ "$status" == ahead || "$status" == identical ]] || fail "$commit is not on main (compare status: $status)." + +# The release run. +run=$("$gh_bin" api "repos/$repo/actions/runs/$run_id" \ + --jq '[.path, .event, .status, .conclusion, .head_branch, .head_sha, (.run_attempt | tostring)] | join(" ")') \ + || fail "cannot read run $run_id." +expected="$workflow workflow_dispatch completed success $tag $commit $run_attempt" +[[ "$run" == "$expected" ]] || fail "release run identity, attempt or success does not match." + +# Both exact tags name one index. +digest() { + local subject="$1" + "$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$subject" || fail "cannot read $subject." +} +index=$(digest "$GHCR_IMAGE:$version") +[[ "$index" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "exact version tag has no index digest." +hub=$(digest "$DOCKERHUB_IMAGE:$version") +[[ "$hub" == "$index" ]] || fail "exact version tags disagree." + +# Its provenance: signed by the release workflow from the tag, at the +# commit, in this run. +for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do + invocations=$("$gh_bin" attestation verify "oci://$image@$index" --repo "$repo" \ + --signer-workflow "github.com/$repo/$workflow" --source-ref "refs/tags/$tag" --source-digest "$commit" \ + --deny-self-hosted-runners --format json \ + --jq '.[].verificationResult.signature.certificate.runInvocationURI') \ + || fail "release index provenance does not verify." + grep -Fxq "https://github.com/$repo/actions/runs/$run_id/attempts/$run_attempt" <<<"$invocations" \ + || fail "release provenance does not name the exact run attempt." +done + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + jq -nc --arg tag "$tag" --arg source "$commit" --arg index "$index" \ + --argjson run "$run_id" --argjson attempt "$run_attempt" \ + '{tag:$tag,source_commit:$source,index_digest:$index,run_id:$run,run_attempt:$attempt}' +fi diff --git a/scripts/ci/native_alias_evidence.py b/scripts/ci/native_alias_evidence.py new file mode 100644 index 000000000..c88935b3e --- /dev/null +++ b/scripts/ci/native_alias_evidence.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +"""Read-only qualification predicates for protected native alias promotion. + +A manifest selects provider objects. Only provider metadata, immutable source +bindings and bounded canonical receipts satisfy the predicates. This module +contains no registry mutation and never executes archive contents. +""" +from __future__ import annotations + +import hashlib +import io +import json +import re +import stat +import struct +import zipfile +import zlib +from datetime import datetime +from typing import Any, Protocol + +REPOSITORY = "durable-workflow/server" +REPOSITORY_ID = 1208272134 +GHCR_IMAGE = "ghcr.io/durable-workflow/server" +DOCKERHUB_IMAGE = "durableworkflow/server" +MANIFEST_SCHEMA = "durable-workflow.native-alias-promotion-evidence.v1" +RECEIPT_SCHEMA = "durable-workflow.native-qualification-receipt.v1" +MANIFEST_LIMIT = 8192 +RECEIPT_LIMIT = 32768 +ARCHIVE_LIMIT = 65536 +JSON_LIMIT = 1048576 +SHA = re.compile(r"[0-9a-f]{40}\Z") +DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +FINAL_TAG = re.compile(r"(?:native-\d+|[3-9]|[1-9]\d+)\.\d+\.\d+\Z", re.ASCII) +AMD64 = "linux/amd64" +PLATFORMS = (AMD64, "linux/arm64") +PROFILES = ("local", "production") +WORKFLOWS = { + "release": ".github/workflows/native-release.yml", + "corpus": ".github/workflows/native-image-parity.yml", + "compose": ".github/workflows/published-compose.yml", +} +CORPUS_JOB = "Compare the native image over the shared fixture corpus (SQLite)" +RELEASE_JOBS = { + "Choose the registries for this tag": ( + "Require a dispatch from the release tag, at its commit, on main", + "Publish to both registries", + ), + **{ + "Build and smoke " + platform: ( + "Verify the release tag names the planned commit", + "Build and push " + platform + " by digest only", + "Smoke-test the pushed " + platform + " digest on its own architecture", + ) for platform in PLATFORMS + }, + "Tag, verify and attest the native index": ( + "Tag and verify the index in each registry", + "Attest the index's build provenance in GHCR", + "Attest the index's build provenance in Docker Hub", + ), +} +QUALIFICATION_STEPS = { + "corpus": ( + "Record and compare the corpus against the native image", + "Verify owned qualification cleanup", + "Finalize promotion receipt", + "Keep promotion receipt", + ), + "compose": ( + "Smoke published Compose stack", + "Verify owned qualification cleanup", + "Finalize promotion receipt", + "Keep promotion receipt", + ), +} + + +class Refusal(ValueError): + """A bounded static reason, safe to print without provider payloads.""" + + +def require(condition: bool, reason: str) -> None: + if not condition: + raise Refusal(reason) + + +def closed(value: Any, keys: set[str], reason: str) -> dict[str, Any]: + require(type(value) is dict and set(value) == keys, reason) + return value + + +def positive(value: Any) -> bool: + return type(value) is int and 0 < value <= 9007199254740991 + + +def literal(actual: Any, expected: Any) -> bool: + return type(actual) is type(expected) and actual == expected + + +def matching(value: Any, expression: re.Pattern[str]) -> bool: + return type(value) is str and expression.fullmatch(value) is not None + + +def canonical(value: Any) -> bytes: + try: + return (json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8") + except (ValueError, RecursionError): + raise Refusal("invalid_canonical_json") from None + + +def zip_extra(data: bytes) -> None: + # GitHub's ZIP writer may carry the standard extended timestamp. Unknown + # fields are unnecessary carriers; no ZIP64 is needed for a <=32-KiB member. + seen = set() + while data: + require(len(data) >= 4, "artifact_extra_field") + kind, length = struct.unpack_from("= 4 + length, "artifact_extra_field") + value, data = data[4:4 + length], data[4 + length:] + require(value[0] <= 7 and length == 1 + 4 * value[0].bit_count(), "artifact_extra_timestamp") + seen.add(kind) + + +def strict_json(data: bytes, limit: int) -> Any: + require(type(data) is bytes and len(data) <= limit, "json_size_refused") + + def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in items: + require(key not in result, "duplicate_json_member") + result[key] = value + return result + + def not_integer(_: str) -> Any: + raise Refusal("noninteger_json_number") + + try: + return json.loads(data.decode("utf-8", errors="strict"), + object_pairs_hook=pairs, parse_float=not_integer, + parse_constant=not_integer) + except (RecursionError, ValueError) as error: + if isinstance(error, Refusal): + raise + raise Refusal("invalid_json") from None + + +def manifest(data: bytes) -> dict[str, Any]: + value = closed(strict_json(data, MANIFEST_LIMIT), + {"schema", "release", "corpus", "compose"}, "manifest_shape") + require(value["schema"] == MANIFEST_SCHEMA, "manifest_schema") + release = closed(value["release"], + {"tag", "source_commit", "index_digest", "run_id", "run_attempt"}, + "release_shape") + require(matching(release["tag"], FINAL_TAG), "final_release_required") + require(matching(release["source_commit"], SHA), "release_source") + require(matching(release["index_digest"], DIGEST), "release_index") + require(positive(release["run_id"]) and positive(release["run_attempt"]), "release_run") + corpus = closed(value["corpus"], + {"run_id", "run_attempt", "runner_commit", "workflow_blob", + "artifact_id", "artifact_digest"}, "corpus_shape") + compose = closed(value["compose"], + {"run_id", "run_attempt", "runner_commit", "workflow_blob", "cells"}, + "compose_shape") + for role, selected in (("corpus", corpus), ("compose", compose)): + require(positive(selected["run_id"]) and positive(selected["run_attempt"]), role + "_run") + require(selected["runner_commit"] == release["source_commit"], role + "_runner_source") + require(matching(selected["workflow_blob"], SHA), role + "_workflow_blob") + require(len({release["run_id"], corpus["run_id"], compose["run_id"]}) == 3, + "distinct_workflow_runs_required") + require(positive(corpus["artifact_id"]) and matching(corpus["artifact_digest"], DIGEST), + "corpus_artifact") + cells = compose["cells"] + require(type(cells) is list and len(cells) == 4, "four_compose_cells_required") + keys, jobs, artifacts = set(), set(), {corpus["artifact_id"]} + for cell in cells: + cell = closed(cell, {"platform", "profile", "job_id", "artifact_id", "artifact_digest"}, + "compose_cell_shape") + require(cell["platform"] in PLATFORMS and cell["profile"] in PROFILES, + "compose_cell_key") + key = (cell["platform"], cell["profile"]) + require(key not in keys and positive(cell["job_id"]) and cell["job_id"] not in jobs, + "duplicate_compose_cell_or_job") + require(positive(cell["artifact_id"]) and cell["artifact_id"] not in artifacts + and matching(cell["artifact_digest"], DIGEST), "compose_artifact") + keys.add(key) + jobs.add(cell["job_id"]) + artifacts.add(cell["artifact_id"]) + require(keys == {(platform, profile) for platform in PLATFORMS for profile in PROFILES}, + "compose_cartesian_set") + return value + + +def archive_receipt(data: bytes) -> dict[str, Any]: + """Read one canonical regular JSON member, without extracting anything.""" + require(type(data) is bytes and len(data) <= ARCHIVE_LIMIT, "artifact_archive_size") + try: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + members = archive.infolist() + require(len(members) == 1 and not archive.comment, "artifact_inventory") + entry = members[0] + mode = stat.S_IFMT(entry.external_attr >> 16) + require(entry.filename == "receipt.json" and not entry.is_dir() + and not (entry.external_attr & 0x10) + and mode in (0, stat.S_IFREG), "artifact_member_type") + require(not (entry.flag_bits & 1) + and entry.compress_type in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED), + "artifact_member_encoding") + require(0 < entry.file_size <= RECEIPT_LIMIT + and entry.file_size <= 100 * max(entry.compress_size, 1), + "artifact_expansion") + require(not entry.comment and entry.header_offset == 0 and data[:4] == b"PK\x03\x04" + and len(data) >= 22 and data[-22:-18] == b"PK\x05\x06", + "artifact_archive_layout") + # No prefix, trailing carrier, extra disk or unindexed local member. + disk, directory_disk, disk_count, count, directory_size, directory_at, comment = ( + struct.unpack_from(" datetime: + require(type(value) is str and len(value) <= 40, "provider_timestamp") + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + raise Refusal("provider_timestamp") from None + require(result.tzinfo is not None, "provider_timestamp_timezone") + return result + + +class Provider(Protocol): + """Implementations perform bounded reads only; fake providers need no network.""" + + def api(self, path: str) -> Any: ... + def archive(self, artifact_id: int) -> bytes: ... + def workflow_blob(self, commit: str, path: str) -> str: ... + def publication_subjects(self, release: dict[str, Any]) -> dict[str, dict[str, Any]]: ... + + +def workflow_run(provider: Provider, selected: dict[str, Any], role: str, + release: dict[str, Any]) -> dict[str, Any]: + run = provider.api(f"repos/{REPOSITORY}/actions/runs/{selected['run_id']}") + require(type(run) is dict and literal(run.get("id"), selected["run_id"]), "provider_run_id") + for field in ("repository", "head_repository"): + repository = run.get(field) + require(type(repository) is dict and literal(repository.get("id"), REPOSITORY_ID) + and repository.get("full_name") == REPOSITORY, "provider_repository") + require(literal(run.get("run_attempt"), selected["run_attempt"]), "provider_current_attempt") + require(run.get("status") == "completed" and run.get("conclusion") == "success", + "provider_run_not_successful") + require(run.get("event") == "workflow_dispatch" + and run.get("head_sha") == release["source_commit"] + and run.get("head_branch") == release["tag"] + and run.get("path") == WORKFLOWS[role], "provider_run_source") + workflow = provider.api(f"repos/{REPOSITORY}/actions/workflows/{WORKFLOWS[role].split('/')[-1]}") + require(type(workflow) is dict and positive(workflow.get("id")) + and literal(run.get("workflow_id"), workflow["id"]) + and workflow.get("path") == WORKFLOWS[role], "provider_workflow_identity") + image = GHCR_IMAGE + "@" + release["index_digest"] + if role == "release": + title = f"Native {release['tag']} at {release['source_commit']}" + else: + prefix = "Native image parity " if role == "corpus" else "Published Compose " + title = prefix + image + require(run.get("display_title") == title, "provider_dispatch_input_binding") + timestamp(run.get("run_started_at")) + timestamp(run.get("updated_at")) + return run + + +def jobs(provider: Provider, run: dict[str, Any], required: dict[str, tuple[str, ...]]) -> dict[str, Any]: + page = provider.api(f"repos/{REPOSITORY}/actions/runs/{run['id']}/jobs?filter=all&per_page=100") + require(type(page) is dict and type(page.get("jobs")) is list + and type(page.get("total_count")) is int + and len(page["jobs"]) == page["total_count"] <= 100, "provider_jobs_incomplete") + selected, ids = {}, set() + for job in page["jobs"]: + require(type(job) is dict and positive(job.get("id")) + and job["id"] not in ids and literal(job.get("run_id"), run["id"]) + and positive(job.get("run_attempt")), "provider_job_identity") + ids.add(job["id"]) + require(job["run_attempt"] <= run["run_attempt"], "provider_later_job_attempt") + if job["run_attempt"] != run["run_attempt"]: + continue + name = job.get("name") + require(type(name) is str and name in required and name not in selected, + "provider_job_set") + require(job.get("head_sha") == run["head_sha"] and job.get("status") == "completed" + and job.get("conclusion") == "success", "provider_job_not_successful") + steps = job.get("steps") + require(type(steps) is list, "provider_steps_missing") + for critical in required[name]: + matches = [step for step in steps if type(step) is dict and step.get("name") == critical] + require(len(matches) == 1 and matches[0].get("status") == "completed" + and matches[0].get("conclusion") == "success", "provider_required_step") + timestamp(job.get("completed_at")) + selected[name] = job + require(set(selected) == set(required), "provider_required_jobs_missing") + return selected + + +def artifact_metadata(provider: Provider, artifact_id: int, digest: str, name: str, + run: dict[str, Any], job: dict[str, Any]) -> dict[str, Any]: + metadata = provider.api(f"repos/{REPOSITORY}/actions/artifacts/{artifact_id}") + require(type(metadata) is dict and literal(metadata.get("id"), artifact_id) + and metadata.get("name") == name and metadata.get("expired") is False + and metadata.get("digest") == digest, "provider_artifact_identity") + require(positive(metadata.get("size_in_bytes")) and metadata["size_in_bytes"] <= ARCHIVE_LIMIT, + "provider_artifact_size") + owner = metadata.get("workflow_run") + require(type(owner) is dict and literal(owner.get("id"), run["id"]) + and literal(owner.get("repository_id"), REPOSITORY_ID) + and literal(owner.get("head_repository_id"), REPOSITORY_ID) + and owner.get("head_sha") == run["head_sha"], "provider_artifact_owner") + require(timestamp(run["run_started_at"]) <= timestamp(metadata.get("created_at")) + <= timestamp(job["completed_at"]), "provider_artifact_attempt_window") + return metadata + + +def receipt(provider: Provider, artifact_id: int, digest: str, name: str, + run: dict[str, Any], job: dict[str, Any]) -> dict[str, Any]: + artifact_metadata(provider, artifact_id, digest, name, run, job) + archive = provider.archive(artifact_id) + require(len(archive) <= ARCHIVE_LIMIT + and "sha256:" + hashlib.sha256(archive).hexdigest() == digest, + "provider_artifact_digest") + return archive_receipt(archive) + + +RECEIPT_KEYS = { + "schema", "kind", "eligible", "repository", "repository_id", "run_id", "run_attempt", + "job_id", "runner_commit", "workflow_path", "workflow_blob", "requested_image", + "index_digest", "platform", "profile", "child_manifest_digest", "image_config_digest", + "actual_image_config_digest", "container_image_architecture", "container_image_os", + "image_source_commit", "image_release_tag", "observations", "cleanup", +} +CORPUS_OBSERVATIONS = {"reference_records", "native_record", "compare", "service_calls", + "schedule_catalog", "namespace_delete_payloads"} +COMPOSE_OBSERVATIONS = {"health_serving", "ready", "cluster_version_matches_release", + "worker_registered", "loopback_binding", "unset_token_refused", + "production_default_token_refused"} + + +def bind_receipt(value: dict[str, Any], role: str, selected: dict[str, Any], + run: dict[str, Any], job: dict[str, Any], release: dict[str, Any], + platform: str, profile: str | None, subject: dict[str, Any]) -> None: + value = closed(value, RECEIPT_KEYS, "receipt_shape") + require(value["schema"] == RECEIPT_SCHEMA and value["kind"] == role + and value["eligible"] is True, "receipt_not_eligible") + expected = { + "repository": REPOSITORY, "repository_id": REPOSITORY_ID, + "run_id": run["id"], "run_attempt": run["run_attempt"], "job_id": job["id"], + "runner_commit": release["source_commit"], "workflow_path": WORKFLOWS[role], + "workflow_blob": selected["workflow_blob"], + "requested_image": GHCR_IMAGE + "@" + release["index_digest"], + "index_digest": release["index_digest"], "platform": platform, "profile": profile, + "child_manifest_digest": subject["child_manifest_digest"], + "image_config_digest": subject["image_config_digest"], + "actual_image_config_digest": subject["image_config_digest"], + "container_image_architecture": platform.split("/")[1], "container_image_os": "linux", + "image_source_commit": release["source_commit"], "image_release_tag": release["tag"], + } + require(all(literal(value[key], expected_value) for key, expected_value in expected.items()), + "receipt_source_or_subject_binding") + cleanup = closed(value["cleanup"], + {"api_ok", "verified_absent", "container_count", "network_count", "volume_count"}, + "receipt_cleanup_shape") + require(cleanup["api_ok"] is True and cleanup["verified_absent"] is True + and all(literal(cleanup[key], 0) for key in ("container_count", "network_count", "volume_count")), + "receipt_cleanup_unqualified") + observations = closed(value["observations"], + CORPUS_OBSERVATIONS if role == "corpus" else COMPOSE_OBSERVATIONS, + "receipt_observation_shape") + if role == "corpus": + require(all(literal(result, 0) for result in observations.values()), "receipt_corpus_failed") + else: + require(all(result is True for key, result in observations.items() + if key != "production_default_token_refused") + and literal(observations["production_default_token_refused"], + True if profile == "production" else None), "receipt_compose_failed") + + +def verify(value: dict[str, Any], provider: Provider) -> dict[str, Any]: + """Verify once. Re-read mutable run/artifact authority before returning.""" + # Revalidate programmatic callers exactly as CLI manifest bytes. + value = manifest(canonical(value)) + release = value["release"] + publication = workflow_run(provider, release, "release", release) + publication_jobs = jobs(provider, publication, RELEASE_JOBS) + completed = max([timestamp(publication["updated_at"])] + + [timestamp(job["completed_at"]) for job in publication_jobs.values()]) + subjects = provider.publication_subjects(release) + require(type(subjects) is dict and set(subjects) == set(PLATFORMS), "publication_platform_set") + for subject in subjects.values(): + require(type(subject) is dict + and matching(subject.get("child_manifest_digest"), DIGEST) + and matching(subject.get("image_config_digest"), DIGEST) + and len({release["index_digest"], subject["child_manifest_digest"], + subject["image_config_digest"]}) == 3, "publication_subject_identity") + require(len({subject["child_manifest_digest"] for subject in subjects.values()}) == 2 + and len({subject["image_config_digest"] for subject in subjects.values()}) == 2, + "publication_duplicate_platform_subject") + snapshots, artifact_ids, artifact_checks = [("release", release)], [], [] + for role in ("corpus", "compose"): + selected = value[role] + require(provider.workflow_blob(release["source_commit"], WORKFLOWS[role]) == selected["workflow_blob"], + "qualifier_workflow_source") + run = workflow_run(provider, selected, role, release) + require(timestamp(run["run_started_at"]) >= completed, "qualification_precedes_publication") + required = ({CORPUS_JOB: QUALIFICATION_STEPS[role]} if role == "corpus" else { + platform + " " + profile + " published image": QUALIFICATION_STEPS[role] + for platform in PLATFORMS for profile in PROFILES}) + current_jobs = jobs(provider, run, required) + if role == "corpus": + job = current_jobs[CORPUS_JOB] + artifact_checks.append((selected["artifact_id"], selected["artifact_digest"], + "native-corpus-promotion-receipt", run, job)) + record = receipt(provider, *artifact_checks[-1]) + bind_receipt(record, role, selected, run, job, release, AMD64, None, + subjects[AMD64]) + artifact_ids.append(selected["artifact_id"]) + else: + for cell in selected["cells"]: + platform, profile = cell["platform"], cell["profile"] + job = current_jobs[platform + " " + profile + " published image"] + require(literal(cell["job_id"], job["id"]), "compose_selected_job") + name = "native-compose-promotion-receipt-" + platform.split("/")[1] + "-" + profile + artifact_checks.append((cell["artifact_id"], cell["artifact_digest"], name, run, job)) + record = receipt(provider, *artifact_checks[-1]) + bind_receipt(record, role, selected, run, job, release, platform, profile, subjects[platform]) + artifact_ids.append(cell["artifact_id"]) + snapshots.append((role, selected)) + for role, selected in snapshots: + workflow_run(provider, selected, role, release) + for selected_artifact in artifact_checks: + artifact_metadata(provider, *selected_artifact) + return {"schema": "durable-workflow.native-alias-verification.v1", + "release": release, "subjects": subjects, "qualified_artifact_ids": artifact_ids} + + +if __name__ == "__main__": + raise SystemExit("native_alias_evidence is a library; executable verification is not wired") diff --git a/scripts/ci/native_alias_promote.py b/scripts/ci/native_alias_promote.py new file mode 100644 index 000000000..a9a062015 --- /dev/null +++ b/scripts/ci/native_alias_promote.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Verify existing qualification; optionally enter the protected alias writer.""" +from __future__ import annotations + +import argparse +import os +import signal +from pathlib import Path +import subprocess +import sys + +from native_alias_evidence import (DOCKERHUB_IMAGE, GHCR_IMAGE, MANIFEST_LIMIT, + REPOSITORY, Refusal, canonical, manifest, require, verify) +from native_alias_transport import GitHubRegistryProvider + + +def dispatch(value: dict) -> None: + release = value["release"] + require(os.environ.get("GITHUB_REPOSITORY") == REPOSITORY + and os.environ.get("PROMOTION_TAG") == release["tag"] + and os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" + and os.environ.get("GITHUB_REF") == "refs/tags/" + release["tag"] + and os.environ.get("GITHUB_SHA") == release["source_commit"] + and os.environ.get("GITHUB_WORKFLOW_REF") == + f"{REPOSITORY}/.github/workflows/native-alias-promotion.yml@refs/tags/{release['tag']}", + "promotion_dispatch_identity") + + +def qualify(value: dict, provider, mode: str) -> dict: + if mode != "release-order": + dispatch(value) + proof = verify(value, provider) + if mode in ("verify", "promote"): + require(not provider.release_exists(value["release"]["tag"]), "preexisting_release_requires_decision") + return proof + + +def plan(value: dict, provider) -> None: + dispatch(value) + source = value["release"]["source_commit"] + comparison = provider.api(f"repos/{REPOSITORY}/compare/{source}...main") + require(type(comparison) is dict and comparison.get("status") in ("ahead", "identical") + and type(comparison.get("base_commit")) is dict + and comparison["base_commit"].get("sha") == source, "promotion_source_not_on_main") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("plan", "verify", "promote", "release-order")) + args = parser.parse_args() + raw = os.environ.get("PROMOTION_EVIDENCE", "").encode("utf-8") + require(len(raw) <= MANIFEST_LIMIT, "manifest_size") + value = manifest(raw) + provider = GitHubRegistryProvider() + if args.mode == "plan": + plan(value, provider) + return + proof = qualify(value, provider, args.mode) + if args.mode == "verify": + sys.stdout.buffer.write(canonical(proof)) + return + release = proof["release"] + if args.mode == "release-order": + require(os.environ.get("RELEASE_TAG") == release["tag"] + and os.environ.get("RELEASE_RUN_ID") == str(release["run_id"]) + and os.environ.get("RELEASE_RUN_ATTEMPT") == str(release["run_attempt"]), + "release_evidence_identity") + environment = os.environ.copy() + # No caller-supplied registry, tag inventory, executable or tag source can + # redirect the protected writer. Its only mutation is the existing helper. + environment.pop("RELEASE_IMAGE_KNOWN_TAGS", None) + environment.update(RELEASE_TAG=release["tag"], INDEX_DIGEST=release["index_digest"], + GHCR_IMAGE=GHCR_IMAGE, DOCKERHUB_IMAGE=DOCKERHUB_IMAGE, DOCKER="docker", + RELEASE_IMAGE_TAG_SOURCE_URL="https://github.com/" + REPOSITORY + ".git", + VERIFY_ONLY="true" if args.mode == "release-order" else "false") + helper = Path(__file__).with_name("native-release-rolling-tags.sh") + # Bounded mutation preserves the helper's content-free partial-state record. + # A timeout is an uncertain write, never a rollback or automatic retry. + process = subprocess.Popen(["bash", str(helper)], env=environment, stdin=subprocess.DEVNULL, + start_new_session=True) + try: + status = process.wait(timeout=180) + except subprocess.TimeoutExpired: + raise Refusal("alias_write_timeout_uncertain_no_rollback") from None + finally: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + require(status == 0, "alias_write_stopped_no_rollback") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError) as error: + reason = str(error) if isinstance(error, Refusal) else "local_input_or_transport" + print("Native promotion refused: " + reason, file=sys.stderr) + raise SystemExit(1) from None diff --git a/scripts/ci/native_alias_transport.py b/scripts/ci/native_alias_transport.py new file mode 100644 index 000000000..2519aaf90 --- /dev/null +++ b/scripts/ci/native_alias_transport.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Bounded read-only GitHub/registry transport for native promotion checks.""" +from __future__ import annotations + +import os +from pathlib import Path +import re +import selectors +import signal +import subprocess +import time +from typing import Any + +from native_alias_evidence import (ARCHIVE_LIMIT, DIGEST, DOCKERHUB_IMAGE, GHCR_IMAGE, + JSON_LIMIT, PLATFORMS, REPOSITORY, SHA, Refusal, + matching, require, strict_json) + + +GITHUB_HOST = "github.com" + + +class Commands: + """No shell, polling, automatic retries, unbounded output or provider logs.""" + + def __init__(self, seconds: float = 180): + self.deadline = time.monotonic() + seconds + + @staticmethod + def require_wait_authority() -> None: + require(callable(getattr(os, "waitid", None)) + and all(hasattr(os, name) for name in ( + "P_PID", "WEXITED", "WNOHANG", "WNOWAIT", "CLD_EXITED", "CLD_KILLED", "CLD_DUMPED")) + and signal.getsignal(signal.SIGCHLD) == signal.SIG_DFL, + "provider_child_authority_unavailable") + + @staticmethod + def child_state(pid: int): + # The synchronous caller owns this direct child. WNOWAIT retains the + # child's kernel PID reservation until group cleanup has finished. + Commands.require_wait_authority() + try: + state = os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + except OSError: + raise Refusal("provider_child_authority_unknown") from None + if state is None: + return None + require(state.si_pid == pid and state.si_code in (os.CLD_EXITED, os.CLD_KILLED, os.CLD_DUMPED), + "provider_child_authority_unknown") + return state + + @staticmethod + def finish_child(process) -> None: + try: + # Never signal after releasing the direct child's PID reservation. + Commands.child_state(process.pid) + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=1) + except (OSError, subprocess.SubprocessError): + raise Refusal("provider_child_cleanup_failed") from None + finally: + process.stdout.close() + process.stderr.close() + + def run(self, argv: list[str], limit: int = JSON_LIMIT, + env: dict[str, str] | None = None, accepted: tuple[int, ...] = (0,)) -> bytes: + remaining = min(30, self.deadline - time.monotonic()) + require(remaining > 0, "read_deadline") + self.require_wait_authority() + environment = os.environ.copy() + environment.pop("GH_DEBUG", None) + environment.update(GH_HOST=GITHUB_HOST, GH_PROMPT_DISABLED="1", GH_PAGER="cat") + if env: + environment.update(env) + process = None + try: + process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=environment, start_new_session=True) + output, errors = bytearray(), bytearray() + until = time.monotonic() + remaining + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ, output) + selector.register(process.stderr, selectors.EVENT_READ, errors) + while selector.get_map(): + require(time.monotonic() < until, "read_timeout") + for key, _ in selector.select(min(0.1, max(0, until - time.monotonic()))): + block = os.read(key.fd, 8192) + if not block: + selector.unregister(key.fileobj) + continue + key.data.extend(block) + require(len(output) <= limit and len(errors) <= 4096, "read_output_bound") + require(time.monotonic() < until, "read_timeout") + state = self.child_state(process.pid) + while state is None: + require(time.monotonic() < until, "read_timeout") + time.sleep(min(0.01, max(0, until - time.monotonic()))) + state = self.child_state(process.pid) + code = state.si_status if state.si_code == os.CLD_EXITED else -state.si_status + require(time.monotonic() < until, "read_timeout") + require(code in accepted, "provider_read_failed") + return bytes(output) + except (OSError, subprocess.SubprocessError): + raise Refusal("provider_command_failed") from None + finally: + if process is not None: + self.finish_child(process) + + +class GitHubRegistryProvider: + def __init__(self, commands: Commands | None = None): + self.commands = commands if commands is not None else Commands() + + def api(self, path: str) -> Any: + require(type(path) is str and path.startswith(f"repos/{REPOSITORY}/") + and re.fullmatch(r"[A-Za-z0-9_./?=&-]+", path) is not None + and ".." not in path, "provider_api_path") + return strict_json(self.commands.run( + ["gh", "api", "--hostname", GITHUB_HOST, "--method", "GET", path]), JSON_LIMIT) + + def archive(self, artifact_id: int) -> bytes: + require(type(artifact_id) is int and artifact_id > 0, "artifact_id") + return self.commands.run(["gh", "api", "--hostname", GITHUB_HOST, "--method", "GET", + f"repos/{REPOSITORY}/actions/artifacts/{artifact_id}/zip"], + ARCHIVE_LIMIT) + + def release_exists(self, tag: str) -> bool: + # gh's exit=1 alone cannot distinguish a 404 from failed authentication, + # transport or rate limits. Only the fixed API's parsed HTTP 404 counts. + from native_alias_evidence import FINAL_TAG + require(matching(tag, FINAL_TAG), "final_release_required") + response = self.commands.run(["gh", "api", "--hostname", GITHUB_HOST, "--method", "GET", + "--include", f"repos/{REPOSITORY}/releases/tags/{tag}"], + accepted=(0, 1)) + headers, separator, body = response.partition(b"\r\n\r\n") + if not separator: + headers, separator, body = response.partition(b"\n\n") + require(bool(separator), "release_http_response") + status = re.fullmatch(rb"HTTP/[0-9.]+ (200|404)(?: [^\r\n]*)?", headers.splitlines()[0]) + require(status is not None and b"content-type: application/json" in headers.lower(), "release_http_status") + value = strict_json(body, JSON_LIMIT) + if status[1] == b"404": + require(type(value) is dict and value.get("message") == "Not Found", "release_absence_unknown") + return False + require(type(value) is dict and type(value.get("id")) is int and value["id"] > 0 + and value.get("tag_name") == tag, "release_existing_identity") + return True + + def workflow_blob(self, commit: str, path: str) -> str: + require(matching(commit, SHA) and re.fullmatch(r"\.github/workflows/[a-z-]+\.yml", path), + "workflow_source_path") + value = self.api(f"repos/{REPOSITORY}/contents/{path}?ref={commit}") + require(type(value) is dict and value.get("type") == "file" and value.get("path") == path + and matching(value.get("sha"), SHA), "workflow_source_blob") + return value["sha"] + + def registry_json(self, image: str, digest: str, image_config: bool = False) -> Any: + if image not in (GHCR_IMAGE, DOCKERHUB_IMAGE): + raise Refusal("registry_subject") + if type(digest) is not str or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None: + raise Refusal("registry_subject") + arguments = ["docker", "buildx", "imagetools", "inspect"] + arguments += ["--format", "{{json .Image}}"] if image_config else ["--raw"] + return strict_json(self.commands.run(arguments + [image + "@" + digest]), JSON_LIMIT) + + def index_subjects(self, image: str, index_digest: str, source: str, + tag: str) -> dict[str, dict[str, str]]: + index = self.registry_json(image, index_digest) + require(type(index) is dict and type(index.get("manifests")) is list + and len(index["manifests"]) == 2, "publication_index_members") + subjects = {} + for member in index["manifests"]: + require(type(member) is dict and type(member.get("platform")) is dict, + "publication_member_platform") + platform = member["platform"] + key = str(platform.get("os")) + "/" + str(platform.get("architecture")) + require(key in PLATFORMS and key not in subjects + and matching(member.get("digest"), DIGEST), "publication_member_set") + child = self.registry_json(image, member["digest"]) + require(type(child) is dict and type(child.get("config")) is dict + and matching(child["config"].get("digest"), DIGEST), "publication_child_config") + config = self.registry_json(image, member["digest"], image_config=True) + require(type(config) is dict and config.get("os") == "linux" + and config.get("architecture") == platform["architecture"] + and type(config.get("config")) is dict, "publication_config_platform") + labels = config["config"].get("Labels") + require(type(labels) is dict and labels.get("org.opencontainers.image.revision") == source + and labels.get("dev.durable-workflow.release.tag") == tag, + "publication_config_source") + subjects[key] = {"child_manifest_digest": member["digest"], + "image_config_digest": child["config"]["digest"]} + require(set(subjects) == set(PLATFORMS), "publication_platform_set") + return subjects + + def publication_subjects(self, release: dict[str, Any]) -> dict[str, dict[str, Any]]: + helper = Path(__file__).with_name("native-release-verify-publication.sh") + identity = strict_json(self.commands.run(["bash", str(helper)], env={ + "GITHUB_REPOSITORY": REPOSITORY, "GHCR_IMAGE": GHCR_IMAGE, + "DOCKERHUB_IMAGE": DOCKERHUB_IMAGE, "RELEASE_TAG": release["tag"], + "RELEASE_RUN_ID": str(release["run_id"]), + "RELEASE_RUN_ATTEMPT": str(release["run_attempt"]), "GH": "gh", "DOCKER": "docker", + }), JSON_LIMIT) + require(identity == release, "publication_identity_changed") + ghcr = self.index_subjects(GHCR_IMAGE, release["index_digest"], + release["source_commit"], release["tag"]) + hub = self.index_subjects(DOCKERHUB_IMAGE, release["index_digest"], + release["source_commit"], release["tag"]) + require(ghcr == hub, "publication_registry_members_disagree") + return ghcr diff --git a/scripts/ci/native_qualification.py b/scripts/ci/native_qualification.py new file mode 100644 index 000000000..2d661dee5 --- /dev/null +++ b/scripts/ci/native_qualification.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Sanitized image observations, exact owned cleanup and qualification receipts. + +Only the existing qualification jobs call these phases. A PR/default/local +image produces a diagnostic receipt; it cannot be annotated into eligibility. +""" +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import sys +from typing import Any + +from native_alias_evidence import (COMPOSE_OBSERVATIONS, CORPUS_JOB, CORPUS_OBSERVATIONS, + DIGEST, FINAL_TAG, GHCR_IMAGE, JSON_LIMIT, PLATFORMS, + RECEIPT_SCHEMA, REPOSITORY, REPOSITORY_ID, SHA, WORKFLOWS, + Refusal, canonical, closed, literal, matching, positive, + require, strict_json) +from native_alias_transport import Commands, GitHubRegistryProvider + +OWNER = "dev.durable-workflow.qualification.scope" +COMPOSE_OWNER = "com.docker.compose.project" +LABEL_FILTER = "label=" +OBJECT_ID = r"[0-9a-f]{64}" +VOLUME_NAME = r"[A-Za-z0-9_.-]+" +CLEANUP_KEYS = {"api_ok", "verified_absent", "container_count", "network_count", "volume_count"} +ROOT = Path.cwd().resolve() +STATE_PATHS = ( + "qualification-state/image.json", "qualification-state/observations.json", + "qualification-state/cleanup.json", "qualification-state/body-cleanup.json", + "parity-evidence/native-image-corpus.json", "promotion-receipt/receipt.json", +) + + +def state_path(path: Path) -> Path: + # Select a fixed workspace path, never use a caller-controlled path at a file sink. + for relative in STATE_PATHS: + target = ROOT / relative + if path in (Path(relative), target): + require(not target.is_symlink() and not target.parent.is_symlink(), "qualification_path_symlink") + return target + raise Refusal("qualification_path_not_allowed") + + +def read(path: Path) -> Any: + with state_path(path).open("rb") as stream: + return strict_json(stream.read(JSON_LIMIT + 1), JSON_LIMIT) + + +def write(path: Path, value: Any) -> None: + target = state_path(path) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("wb") as stream: + stream.write(canonical(value)) + + +def eligible_context(role: str, image: str) -> bool: + tag = os.environ.get("GITHUB_REF_NAME", "") + return (os.environ.get("GITHUB_REPOSITORY") == REPOSITORY + and os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" + and os.environ.get("GITHUB_REF") == "refs/tags/" + tag + and matching(tag, FINAL_TAG) and matching(os.environ.get("GITHUB_SHA"), SHA) + and os.environ.get("GITHUB_WORKFLOW_REF") == + f"{REPOSITORY}/{WORKFLOWS[role]}@refs/tags/{tag}" + and re.fullmatch(re.escape(GHCR_IMAGE) + r"@sha256:[0-9a-f]{64}", image) is not None) + + +def inspect(commands: Commands, resource: str, identity: str) -> dict[str, Any]: + if resource not in ("container", "image", "network", "volume"): + raise Refusal("docker_resource_kind") + # Leading CLI options, whitespace, separators and paths are never identities. + if not re.fullmatch(r"(?:sha256:[0-9a-f]{64}|[A-Za-z0-9][A-Za-z0-9_.-]{0,180})", identity): + raise Refusal("docker_resource_identity") + value = strict_json(commands.run(["docker", resource, "inspect", identity]), JSON_LIMIT) + require(type(value) is list and len(value) == 1 and type(value[0]) is dict, + "docker_inspect_shape") + return value[0] + + +def observe_image(provider: GitHubRegistryProvider, role: str, image: str, + container: str, platform: str) -> dict[str, Any]: + if not eligible_context(role, image): + return {"eligible": False, "reason": "nonpromotion_context"} + require(platform in PLATFORMS, "qualification_platform") + source, tag = os.environ["GITHUB_SHA"], os.environ["GITHUB_REF_NAME"] + actual_source = provider.commands.run(["git", "rev-parse", "HEAD"], 128).decode().strip() + require(actual_source == source, "qualification_checkout") + index = image.split("@", 1)[1] + subject = provider.index_subjects(GHCR_IMAGE, index, source, tag)[platform] + instance = inspect(provider.commands, "container", container) + require(instance.get("Image") == subject["image_config_digest"], "actual_container_image") + config = inspect(provider.commands, "image", subject["image_config_digest"]) + require(config.get("Id") == subject["image_config_digest"] + and config.get("Architecture") == platform.split("/")[1] + and config.get("Os") == "linux" and type(config.get("Config")) is dict, + "actual_container_platform") + labels = config["Config"].get("Labels") + require(type(labels) is dict and labels.get("org.opencontainers.image.revision") == source + and labels.get("dev.durable-workflow.release.tag") == tag, "actual_container_source") + return {"eligible": True, "requested_image": image, "index_digest": index, + "platform": platform, **subject, "actual_image_config_digest": instance["Image"], + "container_image_architecture": config["Architecture"], "container_image_os": config["Os"], + "image_source_commit": source, "image_release_tag": tag} + + +def lines(commands: Commands, args: list[str], expression: str) -> set[str]: + value = commands.run(["docker", *args]).decode("utf-8", errors="strict").splitlines() + require(len(value) == len(set(value)) and len(value) <= 256 + and all(re.fullmatch(expression, line) for line in value), "cleanup_inventory") + return set(value) + + +def owned_networks(commands: Commands, role: str, scope: str) -> tuple[set[str], set[str]]: + selector = LABEL_FILTER + COMPOSE_OWNER + "=" + scope if role == "compose" else "name=^" + scope + "$" + networks = lines(commands, ["network", "ls", "-q", "--no-trunc", "--filter", selector], OBJECT_ID) + attached = set() + for identity in networks: + data = inspect(commands, "network", identity) + labels = data.get("Labels") + owner = COMPOSE_OWNER if role == "compose" else OWNER + require(data.get("Id") == identity and type(labels) is dict and labels.get(owner) == scope + and type(data.get("Containers")) is dict, "cleanup_network_ownership") + if role == "corpus": + require(data.get("Name") == scope, "cleanup_network_name") + require(all(re.fullmatch(OBJECT_ID, item) for item in data["Containers"]), "cleanup_network_container") + attached.update(data["Containers"]) + return networks, attached + + +def owned_mounts(commands: Commands, mounts: list, role: str, scope: str) -> set[str]: + volumes = set() + for mount in mounts: + if type(mount) is not dict or mount.get("Type") != "volume": + continue + name = mount.get("Name") + require(type(name) is str and re.fullmatch(VOLUME_NAME, name), "cleanup_volume_identity") + metadata = inspect(commands, "volume", name) + labels = metadata.get("Labels") + anonymous = (re.fullmatch(OBJECT_ID, name) is not None + and labels in (None, {}, {"com.docker.volume.anonymous": ""})) + compose = role == "compose" and type(labels) is dict and labels.get(COMPOSE_OWNER) == scope + # The exact Engine anonymous marker is not an owner grant: the volume + # must also be mounted by the already inspected, positively owned container. + require(metadata.get("Name") == name and (anonymous or compose), "cleanup_volume_owner") + volumes.add(name) + return volumes + + +def owned_inventory(commands: Commands, role: str, scope: str) -> tuple[set[str], set[str], set[str]]: + networks, containers = owned_networks(commands, role, scope) + owner = COMPOSE_OWNER if role == "compose" else OWNER + containers |= lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", + LABEL_FILTER + owner + "=" + scope], OBJECT_ID) + volumes = set() + if role == "corpus": + containers |= lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", + "name=^/" + scope + "-"], OBJECT_ID) + else: + volumes = lines(commands, ["volume", "ls", "-q", "--filter", LABEL_FILTER + owner + "=" + scope], VOLUME_NAME) + for name in volumes: + data = inspect(commands, "volume", name) + require(data.get("Name") == name and type(data.get("Labels")) is dict + and data["Labels"].get(owner) == scope, "cleanup_volume_owner") + # Discovering a prefix or attached resource never grants ownership. Validate + # the complete candidate set, including mounts, before the first removal. + for identity in containers: + data = inspect(commands, "container", identity) + require(data.get("Id") == identity and type(data.get("Config")) is dict + and type(data.get("Mounts")) is list, "cleanup_container_identity") + labels = data["Config"].get("Labels") + require(type(labels) is dict and labels.get(owner) == scope, "cleanup_container_owner") + volumes |= owned_mounts(commands, data["Mounts"], role, scope) + require(len(containers) <= 64 and len(networks) <= 8 and len(volumes) <= 64, "cleanup_resource_bound") + return containers, networks, volumes + + +def cleanup(commands: Commands, role: str, scope: str) -> dict[str, Any]: + require(role in ("corpus", "compose") and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,150}", scope), + "cleanup_scope") + containers, networks, volumes = owned_inventory(commands, role, scope) + if containers: + commands.run(["docker", "container", "rm", "-f", "-v", *sorted(containers)]) + if networks: + commands.run(["docker", "network", "rm", *sorted(networks)]) + # rm -v can already remove anonymous volumes. A successful list distinguishes + # absence from unavailable Docker; never reinterpret an inspect failure as 404. + remaining = lines(commands, ["volume", "ls", "-q"], VOLUME_NAME) & volumes + if remaining: + commands.run(["docker", "volume", "rm", *sorted(remaining)]) + after_containers, after_networks, after_volumes = owned_inventory(commands, role, scope) + after_volumes |= lines(commands, ["volume", "ls", "-q"], VOLUME_NAME) & volumes + require(not after_containers and not after_networks and not after_volumes, "cleanup_not_absent") + return {"api_ok": True, "verified_absent": True, "container_count": 0, + "network_count": 0, "volume_count": 0} + + +def qualification_observations(role: str, profile: str | None, value: Any) -> dict: + keys = CORPUS_OBSERVATIONS if role == "corpus" else COMPOSE_OBSERVATIONS + observations = closed(value, keys, "qualification_observation_shape") + if role == "corpus": + require(all(literal(item, 0) for item in observations.values()), "qualification_corpus_failed") + else: + require(profile in ("local", "production") + and all(item is True for key, item in observations.items() + if key != "production_default_token_refused") + and literal(observations["production_default_token_refused"], profile == "production" or None), + "qualification_compose_failed") + return observations + + +def finalize(provider: GitHubRegistryProvider, role: str, image: str, platform: str, + profile: str | None, identity: Any, observations: Any, cleanup_result: Any, + body_outcome: str, cleanup_outcome: str) -> dict[str, Any]: + if not eligible_context(role, image): + return {"schema": "durable-workflow.native-qualification-diagnostic.v1", + "eligible": False, "reason": "nonpromotion_context"} + require(body_outcome == cleanup_outcome == "success", "qualification_step_failed") + require(type(identity) is dict and identity.get("eligible") is True + and identity.get("requested_image") == image and identity.get("platform") == platform, + "qualification_identity_missing") + cleanup_result = closed(cleanup_result, CLEANUP_KEYS, "qualification_cleanup_shape") + require(cleanup_result["api_ok"] is True and cleanup_result["verified_absent"] is True + and all(literal(cleanup_result[key], 0) for key in CLEANUP_KEYS - {"api_ok", "verified_absent"}), + "qualification_cleanup_failed") + observations = qualification_observations(role, profile, observations) + source = os.environ["GITHUB_SHA"] + run_id, attempt = int(os.environ["GITHUB_RUN_ID"]), int(os.environ["GITHUB_RUN_ATTEMPT"]) + require(positive(run_id) and positive(attempt), "qualification_run") + run = provider.api(f"repos/{REPOSITORY}/actions/runs/{run_id}") + require(type(run) is dict and literal(run.get("id"), run_id) and literal(run.get("run_attempt"), attempt) + and run.get("head_sha") == source and run.get("path") == WORKFLOWS[role] + and run.get("event") == "workflow_dispatch" and run.get("head_branch") == os.environ["GITHUB_REF_NAME"], + "qualification_provider_run") + for field in ("repository", "head_repository"): + owner = run.get(field) + require(type(owner) is dict and literal(owner.get("id"), REPOSITORY_ID) + and owner.get("full_name") == REPOSITORY, "qualification_provider_owner") + name = CORPUS_JOB if role == "corpus" else platform + " " + str(profile) + " published image" + page = provider.api(f"repos/{REPOSITORY}/actions/runs/{run_id}/jobs?filter=all&per_page=100") + require(type(page) is dict and type(page.get("jobs")) is list + and type(page.get("total_count")) is int + and len(page["jobs"]) == page["total_count"] <= 100, "qualification_job_inventory") + selected = [job for job in page["jobs"] if type(job) is dict + and literal(job.get("run_attempt"), attempt) and job.get("name") == name] + require(len(selected) == 1 and positive(selected[0].get("id")) + and literal(selected[0].get("run_id"), run_id) and selected[0].get("head_sha") == source + and selected[0].get("status") == "in_progress", "qualification_job_owner") + workflow_blob = provider.workflow_blob(source, WORKFLOWS[role]) + actual_blob = provider.commands.run(["git", "rev-parse", source + ":" + WORKFLOWS[role]], 128).decode().strip() + require(actual_blob == workflow_blob, "qualification_workflow_blob") + # Only explicitly whitelisted image fields cross into the artifact. + image_keys = {"requested_image", "index_digest", "platform", "child_manifest_digest", "image_config_digest", + "actual_image_config_digest", "container_image_architecture", "container_image_os", + "image_source_commit", "image_release_tag"} + closed(identity, image_keys | {"eligible"}, "qualification_identity_shape") + return {"schema": RECEIPT_SCHEMA, "kind": role, "eligible": True, + "repository": REPOSITORY, "repository_id": REPOSITORY_ID, + "run_id": run_id, "run_attempt": attempt, "job_id": selected[0]["id"], + "runner_commit": source, "workflow_path": WORKFLOWS[role], "workflow_blob": workflow_blob, + "profile": profile, **{key: identity[key] for key in image_keys}, + "observations": observations, "cleanup": cleanup_result} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("phase", choices=("observe-image", "cleanup", "finalize")) + parser.add_argument("--kind", choices=("corpus", "compose"), required=True) + parser.add_argument("--image", default="") + parser.add_argument("--container", default="") + parser.add_argument("--scope", default="") + parser.add_argument("--platform", default="linux/amd64") + parser.add_argument("--profile", choices=("local", "production")) + parser.add_argument("--identity", type=Path) + parser.add_argument("--observations", type=Path) + parser.add_argument("--cleanup", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + provider = GitHubRegistryProvider() + if args.phase == "observe-image": + value = observe_image(provider, args.kind, args.image, args.container, args.platform) + elif args.phase == "cleanup": + value = cleanup(provider.commands, args.kind, args.scope) + elif not eligible_context(args.kind, args.image): + value = {"schema": "durable-workflow.native-qualification-diagnostic.v1", + "eligible": False, "reason": "nonpromotion_context"} + else: + require(args.identity is not None and args.observations is not None and args.cleanup is not None, + "qualification_inputs_missing") + observations = read(args.observations) + if args.kind == "corpus": + observations = observations.get("status") if type(observations) is dict else None + value = finalize(provider, args.kind, args.image, args.platform, args.profile, read(args.identity), + observations, read(args.cleanup), os.environ.get("QUALIFICATION_BODY_OUTCOME", ""), + os.environ.get("QUALIFICATION_CLEANUP_OUTCOME", "")) + write(args.output, value) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError) as error: + # Never print Docker/API bodies, HTTP registration payloads or credentials. + reason = str(error) if isinstance(error, Refusal) else "local_input_or_transport" + if re.fullmatch(r"[a-z_]{1,80}", reason) is None: + reason = "local_input_or_transport" + print("native qualification refused: " + reason + "; no positive receipt finalized", file=sys.stderr) + raise SystemExit(1) from None diff --git a/scripts/ci/select-published-compose-image.sh b/scripts/ci/select-published-compose-image.sh index 89b358e8b..74c8f55b3 100755 --- a/scripts/ci/select-published-compose-image.sh +++ b/scripts/ci/select-published-compose-image.sh @@ -17,6 +17,8 @@ curl_bin="${CURL:-curl}" : "${GITHUB_ENV:?}" if [ -n "${INPUT_SERVER_IMAGE:-}" ]; then + [[ "$INPUT_SERVER_IMAGE" =~ ^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,255}$ ]] \ + || fail "manual image reference must be one bounded single-line reference." printf 'DW_SERVER_IMAGE=%s\n' "$INPUT_SERVER_IMAGE" >> "$GITHUB_ENV" exit 0 fi diff --git a/scripts/conformance/server-parity/artifact-summary.mjs b/scripts/conformance/server-parity/artifact-summary.mjs index 424392a1b..1903a0e73 100644 --- a/scripts/conformance/server-parity/artifact-summary.mjs +++ b/scripts/conformance/server-parity/artifact-summary.mjs @@ -18,12 +18,13 @@ function priority(path, failed) { if (failed && (/\.stderr$/.test(name) || /(?:stderr|error|failure|bootstrap|install|build).*(?:\.log|\.txt)$/.test(name) || /^(?:php|rust|native)(?:[-.].*)?\.log$/.test(name) || /server\.log$/.test(name))) return 2; if (/^(identity|build|database-state|database-memory-limit)\.json$/.test(name)) return 3; + if (/^execution-(listed-tests|shard(?:-b)?)\.txt$/.test(name)) return 4; if (/(?:-status|-integrity|-version|-revision|-controls|-contract)\.txt$/.test(name) || /\.status$/.test(name) || /^(SHA256SUMS|source-hashes\.sha256)$/.test(name)) return 4; return null; } -const TEST_SUMMARY = /^(rust|mysql|postgres|comparator|codec-load|artifact-summary)(-execution)?-tests\.txt$/; +const TEST_SUMMARY = /^(rust|mysql|postgres|comparator|codec-load|artifact-summary)(-execution)?-tests(-b)?\.txt$/; // A node:test (TAP) report's failing tests: each `not ok` line with its // indented YAML diagnostic block, ending at the block's `...` line. A large diff --git a/scripts/conformance/server-parity/native-image-corpus.sh b/scripts/conformance/server-parity/native-image-corpus.sh index 4a1a70b8e..539eb36ef 100755 --- a/scripts/conformance/server-parity/native-image-corpus.sh +++ b/scripts/conformance/server-parity/native-image-corpus.sh @@ -31,10 +31,12 @@ image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; " # The frozen PHP reference loses SQLite writes under maintenance contention. # service-calls (#796), schedule-catalog (#1021) and namespace-delete-payloads -# (#1018) record separately with PHP's loop stopped; native maintenance remains enabled. +# (#1018), plus post-prune payload cleanup, record separately with PHP's loop +# stopped; native maintenance remains enabled. selection=(--exclude-fixture tests/Fixtures/ServerParity/service-calls.json --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json - --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json) + --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.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") @@ -44,33 +46,36 @@ 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 -- \ +bash scripts/ci/run-composer.sh --rm --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress -docker network create "$RESOURCE_SCOPE" >/dev/null +docker network create --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" "$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 \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" -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 +mkdir -p qualification-state +python3 scripts/ci/native_qualification.py observe-image --kind corpus --image "$native" \ + --container "$RESOURCE_SCOPE-rust" --platform linux/amd64 --output qualification-state/image.json # 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 \ + docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --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 \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" -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" \ + if docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --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 @@ -79,7 +84,7 @@ for target in server rust; do done test "$ready" = 1 done -docker run -d --name "$RESOURCE_SCOPE-php-maintenance" --network "$RESOURCE_SCOPE" \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" -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 \ @@ -95,11 +100,11 @@ http_flags=(--network "$RESOURCE_SCOPE" -v "$PWD:/source:ro" -v "$PWD/parity-evi -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 \ + docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --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 \ + docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --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 \ @@ -111,7 +116,7 @@ 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" \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --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=$? service_calls_status=0 @@ -124,15 +129,20 @@ namespace_delete_payloads_status=0 bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 namespace-delete-payloads \ || namespace_delete_payloads_status=$? +retention_payloads_status=0 +bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 retention-external-payloads \ + || retention_payloads_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 serviceCalls "$service_calls_status" \ --argjson scheduleCatalog "$schedule_catalog_status" \ --argjson namespaceDeletePayloads "$namespace_delete_payloads_status" \ + --argjson retentionPayloads "$retention_payloads_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, service_calls:$serviceCalls, schedule_catalog:$scheduleCatalog, namespace_delete_payloads:$namespaceDeletePayloads}}' \ + status:{reference_records:$reference, native_record:$nativeStatus, compare:$compare, service_calls:$serviceCalls, schedule_catalog:$scheduleCatalog, namespace_delete_payloads:$namespaceDeletePayloads, retention_payloads:$retentionPayloads}}' \ > parity-evidence/native-image-corpus.json cat parity-evidence/native-image-corpus.json test "$status" -eq 0 @@ -141,3 +151,4 @@ test "$compare_status" -eq 0 test "$service_calls_status" -eq 0 test "$schedule_catalog_status" -eq 0 test "$namespace_delete_payloads_status" -eq 0 +test "$retention_payloads_status" -eq 0 diff --git a/scripts/conformance/server-parity/update-validation-sqlite.sh b/scripts/conformance/server-parity/update-validation-sqlite.sh index b1e1ae7ae..0856dc564 100644 --- a/scripts/conformance/server-parity/update-validation-sqlite.sh +++ b/scripts/conformance/server-parity/update-validation-sqlite.sh @@ -7,6 +7,7 @@ # service-calls (#796) or schedule-catalog, whose backfill can lose an # occurrence to PHP's concurrent SQLite maintenance writer (#1021). # namespace-delete-payloads can leave a post-commit deletion pending (#1018). +# retention-external-payloads has the same contention after a prune commits. # These fixtures keep their exact assertions in the same PHP-only window. # # The published PHP record runs with PHP's maintenance loop stopped. On SQLite @@ -45,7 +46,7 @@ image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; " prefix=parity-v1 http_record() { local url="$1" target="$2" - docker run --rm --user=1000:1000 --entrypoint node --network "$RESOURCE_SCOPE" \ + docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint node --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 "$image" scripts/conformance/server-parity.mjs record \ --mode http --url "$url" --target "$target" --output "/evidence/$name-$target.json" --fixture "$fixture" \ @@ -83,14 +84,14 @@ if ((php_status != 0)); then run_without_maintenance "parity-evidence/$name-maintenance-schedule-retry.txt" -- php_record ((php_status == 0)) || exit "$php_status" fi -docker run --rm --user=1000:1000 --entrypoint node \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --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/$name-embedded.json" --fixture "$fixture" \ --prefix "$prefix" http_record "$native_url" rust -docker run --rm --user=1000:1000 --entrypoint node \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --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/$name-php.json" "/evidence/$name-rust.json" "/evidence/$name-embedded.json" \ diff --git a/scripts/smoke-published-compose.sh b/scripts/smoke-published-compose.sh index 44ed0f105..c06ec7f55 100755 --- a/scripts/smoke-published-compose.sh +++ b/scripts/smoke-published-compose.sh @@ -18,6 +18,7 @@ export DW_SERVER_PLATFORM="$SERVER_PLATFORM" unset DOCKER_DEFAULT_PLATFORM BASE_URL="http://server:8080" +published="" export SERVER_PORT @@ -58,27 +59,26 @@ compose() { docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" } +OBSERVATION_DIR="${DW_QUALIFICATION_DIR:-$ROOT_DIR/qualification-state}" +mkdir -p "$OBSERVATION_DIR" +HTTP_DIR=$(mktemp -d) +chmod 0700 "$HTTP_DIR" + curl_json_with_retry() { local output="$1" shift for attempt in $(seq 1 60); do if [ "$ENDPOINT_MODE" = "container" ]; then - if compose exec -T server curl -fsS "$@" >"$output"; then - cat "$output" - echo + if compose exec -T server curl -fsS --connect-timeout 2 --max-time 5 --max-filesize 1048576 "$@" >"$output"; then return 0 fi - elif curl -fsS "$@" >"$output"; then - cat "$output" - echo + elif curl -fsS --connect-timeout 2 --max-time 5 --max-filesize 1048576 "$@" >"$output"; then return 0 fi if [ "$attempt" -eq 60 ]; then - echo "Request failed after ${attempt} attempts: curl $*" >&2 - compose ps >&2 || true - compose logs server >&2 || true + echo "Published Compose request did not succeed within its bound" >&2 return 1 fi @@ -87,7 +87,13 @@ curl_json_with_retry() { } cleanup() { - compose down -v --remove-orphans >/dev/null 2>&1 || true + local body_status=$? cleanup_status=0 + trap - EXIT + python3 "$ROOT_DIR/scripts/ci/native_qualification.py" cleanup --kind compose --scope "$PROJECT" \ + --output "$OBSERVATION_DIR/body-cleanup.json" || cleanup_status=$? + rm -rf "$HTTP_DIR" + if [[ "$body_status" -ne 0 ]]; then exit "$body_status"; fi + exit "$cleanup_status" } trap cleanup EXIT @@ -115,6 +121,10 @@ fi compose pull compose up -d --wait +container=$(compose ps -q server) +python3 "$ROOT_DIR/scripts/ci/native_qualification.py" observe-image --kind compose \ + --image "${DW_SERVER_IMAGE:-}" --container "$container" --platform "$SERVER_PLATFORM" \ + --output "$OBSERVATION_DIR/image.json" if [ "$ENDPOINT_MODE" = "host" ]; then published="$(compose port server 8080)" @@ -133,13 +143,13 @@ fi echo " resolved endpoint: $BASE_URL" -curl_json_with_retry /tmp/dw-server-compose-health.json "${BASE_URL}/api/health" -curl_json_with_retry /tmp/dw-server-compose-ready.json "${BASE_URL}/api/ready" -curl_json_with_retry /tmp/dw-server-compose-cluster.json \ +curl_json_with_retry "$HTTP_DIR"/health.json "${BASE_URL}/api/health" +curl_json_with_retry "$HTTP_DIR"/ready.json "${BASE_URL}/api/ready" +curl_json_with_retry "$HTTP_DIR"/cluster.json \ -H "Authorization: Bearer ${AUTH_TOKEN}" \ "${BASE_URL}/api/cluster/info" -curl_json_with_retry /tmp/dw-server-compose-worker.json \ +curl_json_with_retry "$HTTP_DIR"/worker.json \ -X POST "${BASE_URL}/api/worker/register" \ -H "Authorization: Bearer ${WORKER_AUTH_TOKEN}" \ -H "Content-Type: application/json" \ @@ -147,29 +157,50 @@ curl_json_with_retry /tmp/dw-server-compose-worker.json \ -H "X-Durable-Workflow-Protocol-Version: 1.0" \ -d "{\"worker_id\":\"${WORKER_ID}\",\"task_queue\":\"${TASK_QUEUE}\",\"runtime\":\"python\"}" -DW_SMOKE_WORKER_ID="$WORKER_ID" DW_SMOKE_TASK_QUEUE="$TASK_QUEUE" python3 - <<'PY' +DW_SMOKE_HTTP_DIR="$HTTP_DIR" DW_SMOKE_WORKER_ID="$WORKER_ID" DW_SMOKE_NATIVE="$NATIVE_PUBLISHED" \ +DW_SMOKE_ENDPOINT_MODE="$ENDPOINT_MODE" DW_SMOKE_BIND_ADDRESS="${published%%:*}" \ +DW_SMOKE_OBSERVATION_DIR="$OBSERVATION_DIR" python3 - <<'OBSERVATIONS' import json import os from pathlib import Path -health = json.loads(Path("/tmp/dw-server-compose-health.json").read_text()) -ready = json.loads(Path("/tmp/dw-server-compose-ready.json").read_text()) -cluster = json.loads(Path("/tmp/dw-server-compose-cluster.json").read_text()) -worker = json.loads(Path("/tmp/dw-server-compose-worker.json").read_text()) - -assert health.get("status") == "serving", health -assert ready.get("status") == "ready", ready -assert cluster.get("version"), cluster -assert worker.get("worker_id") == os.environ["DW_SMOKE_WORKER_ID"], worker -assert worker.get("registered") is True, worker -PY +root = Path(os.environ["DW_SMOKE_HTTP_DIR"]) +try: + health, ready, cluster, worker = [json.loads((root / name).read_bytes()) for name in + ("health.json", "ready.json", "cluster.json", "worker.json")] + identity = json.loads((Path(os.environ["DW_SMOKE_OBSERVATION_DIR"]) / "image.json").read_bytes()) + expected_version = identity.get("image_release_tag", "").removeprefix("native-") + results = { + "health_serving": health.get("status") == "serving", + "ready": ready.get("status") == "ready", + "cluster_version_matches_release": cluster.get("version") == expected_version if identity.get("eligible") else bool(cluster.get("version")), + "worker_registered": worker.get("worker_id") == os.environ["DW_SMOKE_WORKER_ID"] and worker.get("registered") is True, + "loopback_binding": os.environ["DW_SMOKE_ENDPOINT_MODE"] == "host" and os.environ["DW_SMOKE_BIND_ADDRESS"] == "127.0.0.1", + "unset_token_refused": os.environ["DW_SMOKE_NATIVE"] == "true", + "production_default_token_refused": None, + } + if not all(results[key] for key in ("health_serving", "ready", "cluster_version_matches_release", "worker_registered")): + raise ValueError("smoke assertions") + (Path(os.environ["DW_SMOKE_OBSERVATION_DIR"]) / "observations.json").write_text(json.dumps(results) + "\n") +except (ValueError, OSError, AttributeError): + raise SystemExit("Published Compose response assertions failed") from None +OBSERVATIONS if [ "$PROFILE" = "production" ]; then - status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer dev-token" "${BASE_URL}/api/cluster/info" || true)" + status="$(curl --connect-timeout 2 --max-time 5 -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer dev-token" "${BASE_URL}/api/cluster/info")" if [ "$status" != "401" ]; then echo "production profile accepted dev-token (HTTP $status)" >&2 exit 1 fi + python3 - "$OBSERVATION_DIR/observations.json" <<'PRODUCTION' +import json +import sys +from pathlib import Path +path = Path(sys.argv[1]) +value = json.loads(path.read_bytes()) +value["production_default_token_refused"] = True +path.write_text(json.dumps(value) + "\n") +PRODUCTION fi echo "Published Compose smoke passed" diff --git a/tests/Unit/NativeDebugArtifactTest.mjs b/tests/Unit/NativeDebugArtifactTest.mjs index e081cba4c..3f134ab71 100644 --- a/tests/Unit/NativeDebugArtifactTest.mjs +++ b/tests/Unit/NativeDebugArtifactTest.mjs @@ -279,7 +279,7 @@ function databaseCommand(id) { return line.trim().slice(1, -1); } -function runDatabaseCommand(t, id, part, binary = true) { +function runDatabaseCommand(t, id, part, binary = true, listExit = 0) { const root = mkdtempSync(join(tmpdir(), 'native-debug-cargo-')); t.after(() => rmSync(root, {recursive: true, force: true})); mkdirSync(join(root, 'bin')); @@ -292,13 +292,20 @@ function runDatabaseCommand(t, id, part, binary = true) { const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.CARGO_CALL_LOG, JSON.stringify(args) + '\\n'); -if(args.includes('--list')) console.log('case_a: test\\ncase_b: test'); +if(args.includes('--list')) { + console.log('case_a: test\\ncase_b: test'); + process.exit(Number(process.env.CARGO_LIST_EXIT || 0)); +} `, {mode: 0o755}); const script = databaseCommand(id).replaceAll('/evidence/', `${root}/evidence/`).replaceAll('/target/', `${root}/target/`); const run = spawnSync('bash', ['-c', script], {encoding: 'utf8', env: {...process.env, - PATH: `${root}/bin:${process.env.PATH}`, CARGO_CALL_LOG: log, DW_PARITY_PART: part, DW_EXECUTION_SHARDS: '2'}}); + PATH: `${root}/bin:${process.env.PATH}`, CARGO_CALL_LOG: log, CARGO_LIST_EXIT: String(listExit), + DW_PARITY_PART: part, DW_EXECUTION_SHARDS: '2'}}); const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); - return {...run, calls}; + const listed = join(root, 'evidence/execution-listed-tests.txt'); + const selected = join(root, `evidence/execution-shard${part === 'native-tests-b' ? '-b' : ''}.txt`); + return {...run, calls, listed: existsSync(listed) ? readFileSync(listed, 'utf8') : null, + selected: existsSync(selected) ? readFileSync(selected, 'utf8') : null}; } for (const part of ['native-tests', 'native-tests-b']) { @@ -314,6 +321,14 @@ for (const part of ['native-tests', 'native-tests-b']) { ['test', ...flags, '--test', 'execution', '--', '--exact', '--nocapture', '--test-threads=2', part === 'native-tests' ? 'case_a' : 'case_b'], ['build', ...flags]); assert.deepEqual(run.calls, expected); + assert.equal(run.listed, 'case_a\ncase_b\n'); + assert.equal(run.selected, part === 'native-tests' ? 'case_a\n' : 'case_b\n'); + }); + test(`MySQL ${part} refuses a failing test-list command even with partial output`, t => { + const run = runDatabaseCommand(t, 'mysql-execution', part, true, 7); + assert.notEqual(run.status, 0); + assert.equal(run.calls.some(args => args.includes('--exact')), false); + assert.equal(run.calls.some(args => args[0] === 'build'), false); }); } diff --git a/tests/Unit/NativeImageCorpusTest.mjs b/tests/Unit/NativeImageCorpusTest.mjs index 932ac30c3..6cc62e1bd 100644 --- a/tests/Unit/NativeImageCorpusTest.mjs +++ b/tests/Unit/NativeImageCorpusTest.mjs @@ -71,7 +71,7 @@ esac`, test('native image qualification records every selected fixture once across main and isolated records', () => { const result = run(); assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.helpers, ['service-calls', 'schedule-catalog', 'namespace-delete-payloads']); + assert.deepEqual(result.helpers, ['service-calls', 'schedule-catalog', 'namespace-delete-payloads', 'retention-external-payloads']); const commands = result.calls.filter(args => args.includes('scripts/conformance/server-parity.mjs')); assert.equal(commands.length, 4, 'three main records and their comparison'); let first; @@ -88,7 +88,7 @@ test('native image qualification records every selected fixture once across main first = all; } assert.deepEqual(result.receipt.status, {reference_records: 0, native_record: 0, compare: 0, - service_calls: 0, schedule_catalog: 0, namespace_delete_payloads: 0}); + service_calls: 0, schedule_catalog: 0, namespace_delete_payloads: 0, retention_payloads: 0}); assert.equal(result.receipt.runner_revision, revision); }); @@ -96,10 +96,11 @@ for (const [stage, field] of [ ['php', 'reference_records'], ['embedded', 'reference_records'], ['rust', 'native_record'], ['compare', 'compare'], ['service-calls', 'service_calls'], ['schedule-catalog', 'schedule_catalog'], ['namespace-delete-payloads', 'namespace_delete_payloads'], + ['retention-external-payloads', 'retention_payloads'], ]) test(`failure in ${stage} is retained in the receipt and fails image qualification`, () => { const result = run(stage); assert.notEqual(result.status, 0, result.stderr); assert.equal(result.receipt.status[field], 17); - assert.deepEqual(result.helpers, ['service-calls', 'schedule-catalog', 'namespace-delete-payloads'], + assert.deepEqual(result.helpers, ['service-calls', 'schedule-catalog', 'namespace-delete-payloads', 'retention-external-payloads'], 'one failure does not hide the separate checks'); }); diff --git a/tests/Unit/ServerParityArtifactSummaryTest.mjs b/tests/Unit/ServerParityArtifactSummaryTest.mjs index dce60536a..e9012bb05 100644 --- a/tests/Unit/ServerParityArtifactSummaryTest.mjs +++ b/tests/Unit/ServerParityArtifactSummaryTest.mjs @@ -40,6 +40,26 @@ test('successful summaries preserve exact reports and omit journals, DBs, payloa assert.throws(() => summarizeEvidence(source, output), /fresh empty directory/); }); +test('execution inventories retain exact bytes with explicit truncation and omit unrelated lists', t => { + const {source, output} = fixture(t); + const inventories = {'execution-listed-tests.txt': 'case_a\ncase_b\n', + 'execution-shard.txt': 'case_a\n', 'execution-shard-b.txt': 'case_b\n', + 'mysql-execution-tests-b.txt': 'test result: ok. 1 passed; 0 failed;\n'}; + for (const [name, content] of Object.entries(inventories)) writeFileSync(join(source, name), content); + writeFileSync(join(source, 'execution-unrelated.txt'), 'not a selected-test inventory'); + const summary = summarizeEvidence(source, output, {outcome: 'success'}); + for (const [name, content] of Object.entries(inventories)) { + assert.equal(readFileSync(join(output, name), 'utf8'), content); + assert.equal(summary.retained.find(entry => entry.path === name).tail_only, false); + } + assert.equal(existsSync(join(output, 'execution-unrelated.txt')), false); + const other = fixture(t); + writeFileSync(join(other.source, 'execution-listed-tests.txt'), 'x'.repeat(64 * 1024 + 1)); + const truncated = summarizeEvidence(other.source, other.output, {outcome: 'success'}).retained[0]; + assert.equal(truncated.retained_bytes, 64 * 1024); + assert.equal(truncated.tail_only, true); +}); + test('failed summaries keep bounded diagnostic tails without truncating JSON into a false report', t => { const {source, output} = fixture(t); const diagnostic = `${'x'.repeat(128 * 1024)}\nHTTP 500 while installing pinned dependency\n`; diff --git a/tests/Unit/ServerParityFixtureSelectionTest.mjs b/tests/Unit/ServerParityFixtureSelectionTest.mjs index 95d363010..658bce28f 100644 --- a/tests/Unit/ServerParityFixtureSelectionTest.mjs +++ b/tests/Unit/ServerParityFixtureSelectionTest.mjs @@ -57,7 +57,7 @@ function mainCorpusCommands(job) { return job.split(/(?=docker run )/).filter(command => /server-parity\.mjs (record|compare)/.test(command)); } -test('the SQLite job keeps update-validation in its main corpus and isolates the three contention-sensitive fixtures', () => { +test('the SQLite job keeps update-validation in its main corpus and isolates the contention-sensitive fixtures', () => { const sqlite = jobs()['execution-and-codec']; const commands = mainCorpusCommands(sqlite); assert.equal(commands.length, 4, 'php, embedded and rust records plus their comparison'); @@ -66,6 +66,7 @@ test('the SQLite job keeps update-validation in its main corpus and isolates the '--exclude-fixture tests/Fixtures/ServerParity/service-calls.json', '--exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json', '--exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json', + '--exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json', ]); } assert.ok(corpus.includes('update-validation.json')); @@ -76,6 +77,8 @@ test('the SQLite job keeps update-validation in its main corpus and isolates the assert.match(sqlite, /test "\$schedule_catalog_status" -eq 0/); assert.match(sqlite, /namespace_delete_payloads_status=0\n\s+bash scripts\/conformance\/server-parity\/update-validation-sqlite\.sh http:\/\/parity-server:8080 http:\/\/parity-rust:8080 namespace-delete-payloads \|\| namespace_delete_payloads_status=\$\?/); assert.match(sqlite, /test "\$namespace_delete_payloads_status" -eq 0/); + assert.match(sqlite, /retention_payloads_status=0\n\s+bash scripts\/conformance\/server-parity\/update-validation-sqlite\.sh http:\/\/parity-server:8080 http:\/\/parity-rust:8080 retention-external-payloads \|\| retention_payloads_status=\$\?/); + assert.match(sqlite, /test "\$retention_payloads_status" -eq 0/); assert.doesNotMatch(sqlite, /update-validation-sqlite\.sh http:\/\/parity-server:8080 http:\/\/parity-rust:8080 \|\|/); }); diff --git a/tests/Unit/ServerParityPartsTest.mjs b/tests/Unit/ServerParityPartsTest.mjs index e9d3a7781..4685aaa79 100644 --- a/tests/Unit/ServerParityPartsTest.mjs +++ b/tests/Unit/ServerParityPartsTest.mjs @@ -141,10 +141,10 @@ test('the database bootstraps run the native suites only in the native-tests par assert.match(mysql.text, /-e DW_PARITY_PART -e DW_EXECUTION_SHARDS \\\n/); // native-tests runs the database suite and the first half of the execution // tests; native-tests-b, where a label has it, runs the second half. - assert.match(mysql.text, /'case "\$DW_PARITY_PART" in native-tests\) shard=1 suffix= ;; native-tests-b\) shard=2 suffix=-b ;; \*\) shard= ;; esac; if \[ "\$DW_PARITY_PART" = native-tests \]; then cargo test [^']*runtime::mysql::[^']* > \/evidence\/mysql-tests\.txt \|\| exit 1; fi; if \[ -n "\$shard" \]; then [^']*cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 \$\(cat \/evidence\/execution-shard\$suffix\.txt\) > \/evidence\/mysql-execution-tests\$suffix\.txt \|\| exit 1; fi; case "\$DW_PARITY_PART" in native-tests\|native-tests-b\) cargo build --locked --offline ;; \*\) test -x \/target\/debug\/durable-workflow-server ;; esac'/); + assert.match(mysql.text, /'case "\$DW_PARITY_PART" in native-tests\) shard=1 suffix= ;; native-tests-b\) shard=2 suffix=-b ;; \*\) shard= ;; esac; set -o pipefail; if \[ "\$DW_PARITY_PART" = native-tests \]; then cargo test [^']*runtime::mysql::[^']* > \/evidence\/mysql-tests\.txt \|\| exit 1; fi; if \[ -n "\$shard" \]; then [^']*cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 \$\(cat \/evidence\/execution-shard\$suffix\.txt\) > \/evidence\/mysql-execution-tests\$suffix\.txt \|\| exit 1; fi; case "\$DW_PARITY_PART" in native-tests\|native-tests-b\) cargo build --locked --offline ;; \*\) test -x \/target\/debug\/durable-workflow-server ;; esac'/); }); -test('the MySQL 8 labels split their execution tests into two parts, every test in exactly one', () => { +test('all MySQL and MariaDB labels split execution tests into two parts, every test in exactly one', () => { const block = job('mysql-execution'); assert.match(block, /^ {6}DW_EXECUTION_SHARDS: \$\{\{ matrix\.execution_shards \|\| 1 \}\}$/m); // Matrix values come from the label's include entry; the added @@ -152,7 +152,7 @@ test('the MySQL 8 labels split their execution tests into two parts, every test const include = block.slice(block.indexOf('\n include:\n'), block.indexOf('\n env:\n')); const entries = include.split(/\n {10}- /).slice(1).map(text => Object.fromEntries( [...('label: ' + text.replace(/^label: /, '')).matchAll(/^\s*([a-z_]+): (\S+)$/gm)].map(match => match.slice(1)))); - const sharded = ['mysql-8.0', 'existing-php-mysql-matrix']; + const sharded = ['mysql-8.0', 'existing-php-mysql-matrix', 'mariadb-10.11']; for (const name of sharded) { const pair = entries.filter(entry => entry.label === name); assert.equal(pair.length, 2, JSON.stringify(entries)); @@ -164,7 +164,7 @@ test('the MySQL 8 labels split their execution tests into two parts, every test assert.ok(entries.filter(entry => !sharded.includes(entry.label)).every(entry => !entry.execution_shards && !entry.part)); // The selection the bootstrap runs: list, sort bytewise, deal round-robin. const bootstrap = steps(block).find(step => step.name.startsWith('Bootstrap published ')).text; - const selection = /cargo test --locked --offline --test execution -- --list --format terse \| sed -n "s\/: test\\\$\/\/p" \| LC_ALL=C sort \| awk -v count="\$DW_EXECUTION_SHARDS" -v shard="\$shard" "([^"]+)" > \/evidence\/execution-shard\$suffix\.txt && test -s \/evidence\/execution-shard\$suffix\.txt && /.exec(bootstrap); + const selection = /cargo test --locked --offline --test execution -- --list --format terse \| sed -n "s\/: test\\\$\/\/p" \| LC_ALL=C sort \| tee \/evidence\/execution-listed-tests\.txt \| awk -v count="\$DW_EXECUTION_SHARDS" -v shard="\$shard" "([^"]+)" > \/evidence\/execution-shard\$suffix\.txt && test -s \/evidence\/execution-shard\$suffix\.txt && /.exec(bootstrap); assert.ok(selection, 'the execution tests are selected by the listed round-robin'); const names = Array.from({length: 489}, (_, index) => `suite::test_${String(index).padStart(3, '0')}`); const deal = (shard, count) => { diff --git a/tests/Unit/ServerParityRetentionPayloadsContractTest.mjs b/tests/Unit/ServerParityRetentionPayloadsContractTest.mjs index c7cb1219f..7f163eac7 100644 --- a/tests/Unit/ServerParityRetentionPayloadsContractTest.mjs +++ b/tests/Unit/ServerParityRetentionPayloadsContractTest.mjs @@ -33,6 +33,7 @@ test('the embedded retention payloads are explicitly inapplicable', () => { for (const [label, mutate] of [ ['a skipped run that holds a payload', state => { state.steps['sole pruned'].result = {outcome: 'skipped', reason: 'external_payload_cleanup_unavailable'}; }], ['an uncounted deletion', state => { state.steps['sole pruned'].result.external_payloads_deleted = 0; }], + ['a post-commit deletion still pending', state => { state.steps['sole pruned'].cleanup.pending = 1; }], ['a payload left after its run', state => { state.steps['sole fetched'] = {status: 200, bytes_match: true}; }], ['a held payload deleted', state => { state.steps['shared fetched while held'] = {status: 404, reason: 'external_payload_not_found'}; }], ['a peer-held payload counted', state => { state.steps['peer-held pruned'].result.external_payloads_deleted = 1; }], diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index ac4b1ff3c..2fafdb836 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -908,7 +908,7 @@ }, { "path": "scripts/conformance/server-parity/native-image-corpus.sh", - "line": "bash scripts/ci/run-composer.sh --rm -v \"$PWD:/source\" -w /source -- \\", + "line": "bash scripts/ci/run-composer.sh --rm --label \"dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE\" -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": [ diff --git a/tests/Unit/Support/docker_workflow_isolation_policy_test.py b/tests/Unit/Support/docker_workflow_isolation_policy_test.py index 612b43b3a..1b93968dc 100644 --- a/tests/Unit/Support/docker_workflow_isolation_policy_test.py +++ b/tests/Unit/Support/docker_workflow_isolation_policy_test.py @@ -12,6 +12,72 @@ class DockerWorkflowIsolationPolicyTest(unittest.TestCase): + def qualification_cleanup_sources(self) -> dict[str, str]: + return {relative: (ROOT / relative).read_text() for relative in ( + "scripts/ci/native_qualification.py", "scripts/ci/native_alias_evidence.py", + "scripts/ci/native_alias_transport.py")} + + def qualification_workflow(self, invocation: str, always: bool = True) -> str: + condition = "if: always()" if always else "if: success()" + return """ + jobs: + corpus: + env: + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + steps: + - run: docker network create "$RESOURCE_SCOPE" + - run: docker run -d --name "$RESOURCE_SCOPE-rust" image + - CONDITION + run: INVOCATION + """.replace("CONDITION", condition).replace("INVOCATION", invocation) + + def test_accepts_exact_reviewed_strict_qualification_cleanup(self) -> None: + invocation = ('python3 scripts/ci/native_qualification.py cleanup --kind corpus ' + '--scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json') + result = self.run_policy(self.qualification_workflow(invocation), self.qualification_cleanup_sources()) + self.assertEqual(0, result.returncode, result.stderr) + + def test_strict_qualification_cleanup_refuses_helper_and_import_byte_changes(self) -> None: + invocation = ('python3 scripts/ci/native_qualification.py cleanup --kind corpus ' + '--scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json') + for changed in self.qualification_cleanup_sources(): + sources = self.qualification_cleanup_sources() + sources[changed] += "\n# changed source is not the reviewed closure\n" + result = self.run_policy(self.qualification_workflow(invocation), sources) + self.assertNotEqual(0, result.returncode, changed) + self.assertIn("strict qualification cleanup requires reviewed bytes", result.stderr) + + def test_strict_qualification_cleanup_refuses_open_argv_scope_or_non_always(self) -> None: + invocation = ('python3 scripts/ci/native_qualification.py cleanup --kind corpus ' + '--scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json') + for invalid in (invocation + " --extra value", invocation.replace("corpus", "unknown"), + invocation.replace('"$RESOURCE_SCOPE"', "shared-name"), + invocation.replace("qualification-state/cleanup.json", "another.json"), + invocation.replace("cleanup --kind", "observe-image --kind")): + result = self.run_policy(self.qualification_workflow(invalid), self.qualification_cleanup_sources()) + self.assertNotEqual(0, result.returncode, invalid) + result = self.run_policy(self.qualification_workflow(invocation, always=False), self.qualification_cleanup_sources()) + self.assertNotEqual(0, result.returncode) + conditional = self.qualification_workflow(invocation).replace("if: always()", "if: always() && false") + result = self.run_policy(conditional, self.qualification_cleanup_sources()) + self.assertNotEqual(0, result.returncode) + self.assertIn("unconditional always()", result.stderr) + + def test_accepts_closed_strict_compose_cleanup_with_job_scoped_project(self) -> None: + workflow = """ + jobs: + smoke: + env: + PROJECT: compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + SERVER_PORT: "0" + steps: + - run: docker compose -p "$PROJECT" up -d + - if: always() + run: python3 scripts/ci/native_qualification.py cleanup --kind compose --scope "$PROJECT" --output qualification-state/cleanup.json + """ + result = self.run_policy(workflow, self.qualification_cleanup_sources()) + self.assertEqual(0, result.returncode, result.stderr) + def run_policy( self, workflow: str, scripts: dict[str, str] | None = None ) -> subprocess.CompletedProcess[str]: diff --git a/tests/Unit/Support/native_alias_evidence_test.py b/tests/Unit/Support/native_alias_evidence_test.py new file mode 100644 index 000000000..7e6d6f0ff --- /dev/null +++ b/tests/Unit/Support/native_alias_evidence_test.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Pure provider/receipt countercontrols. No network, Docker or credentials.""" +import copy +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import stat +import struct +import subprocess +import sys +import unittest +import zipfile + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location("native_alias_evidence", ROOT / "scripts/ci/native_alias_evidence.py") +E = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(E) +SOURCE = "1" * 40 +INDEX = "sha256:" + "a" * 64 + + +def packed(value, name="receipt.json", mode=stat.S_IFREG | 0o644, extra=None): + target = io.BytesIO() + with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as archive: + entry = zipfile.ZipInfo(name, date_time=(2026, 11, 5, 0, 0, 0)) + entry.external_attr = mode << 16 + entry.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(entry, value if type(value) is bytes else + (json.dumps(value, sort_keys=True, ensure_ascii=False, + separators=(",", ":")) + "\n").encode()) + if extra is not None: + archive.writestr(extra, b"extra") + return target.getvalue() + + +class FakeProvider: + def __init__(self): + self.runs, self.workflows, self.job_sets, self.metadata, self.archives = {}, {}, {}, {}, {} + self.run_reads, self.artifact_reads = {}, {} + self.on_final_run = self.on_final_artifact = None + self.writes = [] + self.subjects = { + "linux/amd64": {"child_manifest_digest": "sha256:" + "b" * 64, + "image_config_digest": "sha256:" + "c" * 64}, + "linux/arm64": {"child_manifest_digest": "sha256:" + "d" * 64, + "image_config_digest": "sha256:" + "e" * 64}, + } + self.selected = { + "schema": "durable-workflow.native-alias-promotion-evidence.v1", + "release": {"tag": "3.2.0", "source_commit": SOURCE, "index_digest": INDEX, + "run_id": 301, "run_attempt": 1}, + "corpus": {"run_id": 302, "run_attempt": 1, "runner_commit": SOURCE, + "workflow_blob": "2" * 40, "artifact_id": 501, "artifact_digest": None}, + "compose": {"run_id": 303, "run_attempt": 1, "runner_commit": SOURCE, + "workflow_blob": "3" * 40, "cells": []}, + } + owner = {"id": E.REPOSITORY_ID, "full_name": E.REPOSITORY} + for n, role in enumerate(("release", "corpus", "compose"), 1): + selection = self.selected[role] + workflow_id = 10 + n + self.workflows[E.WORKFLOWS[role].split("/")[-1]] = { + "id": workflow_id, "path": E.WORKFLOWS[role]} + title = ("Native 3.2.0 at " + SOURCE if role == "release" else + ("Native image parity " if role == "corpus" else "Published Compose ") + + E.GHCR_IMAGE + "@" + INDEX) + self.runs[selection["run_id"]] = { + "id": selection["run_id"], "repository": owner, "head_repository": owner, + "run_attempt": 1, "status": "completed", "conclusion": "success", + "event": "workflow_dispatch", "head_sha": SOURCE, "head_branch": "3.2.0", + "path": E.WORKFLOWS[role], "workflow_id": workflow_id, "display_title": title, + "run_started_at": "2026-11-05T00:00:00Z" if role == "release" else "2026-11-05T00:02:00Z", + "updated_at": "2026-11-05T00:01:00Z" if role == "release" else "2026-11-05T00:04:00Z", + } + if role == "release": + names = E.RELEASE_JOBS + elif role == "corpus": + names = {E.CORPUS_JOB: E.QUALIFICATION_STEPS[role]} + else: + names = {platform + " " + profile + " published image": E.QUALIFICATION_STEPS[role] + for platform in E.PLATFORMS for profile in E.PROFILES} + self.job_sets[selection["run_id"]] = [ + {"id": n * 1000 + i, "run_id": selection["run_id"], "run_attempt": 1, + "head_sha": SOURCE, "name": name, "status": "completed", "conclusion": "success", + "completed_at": "2026-11-05T00:00:59Z" if role == "release" else "2026-11-05T00:03:59Z", + "steps": [{"name": step, "status": "completed", "conclusion": "success"} + for step in critical]} + for i, (name, critical) in enumerate(names.items(), 1)] + corpus = self.make_receipt("corpus", "linux/amd64", None, 2001) + self.set_artifact(501, corpus, "native-corpus-promotion-receipt", 302) + self.selected["corpus"]["artifact_digest"] = self.metadata[501]["digest"] + for i, (platform, profile) in enumerate( + ((p, r) for p in E.PLATFORMS for r in E.PROFILES), 1): + artifact_id = 501 + i + record = self.make_receipt("compose", platform, profile, 3000 + i) + self.set_artifact(artifact_id, record, + "native-compose-promotion-receipt-" + platform.split("/")[1] + "-" + profile, 303) + self.selected["compose"]["cells"].append({ + "platform": platform, "profile": profile, "job_id": 3000 + i, + "artifact_id": artifact_id, "artifact_digest": self.metadata[artifact_id]["digest"]}) + + def make_receipt(self, role, platform, profile, job_id): + subject = self.subjects[platform] + observations = ({k: 0 for k in E.CORPUS_OBSERVATIONS} if role == "corpus" else + {k: True for k in E.COMPOSE_OBSERVATIONS}) + if role == "compose" and profile == "local": + observations["production_default_token_refused"] = None + return { + "schema": "durable-workflow.native-qualification-receipt.v1", "kind": role, + "eligible": True, "repository": E.REPOSITORY, "repository_id": E.REPOSITORY_ID, + "run_id": 302 if role == "corpus" else 303, "run_attempt": 1, "job_id": job_id, + "runner_commit": SOURCE, "workflow_path": E.WORKFLOWS[role], + "workflow_blob": "2" * 40 if role == "corpus" else "3" * 40, + "requested_image": E.GHCR_IMAGE + "@" + INDEX, "index_digest": INDEX, + "platform": platform, "profile": profile, + "child_manifest_digest": subject["child_manifest_digest"], + "image_config_digest": subject["image_config_digest"], + "actual_image_config_digest": subject["image_config_digest"], + "container_image_architecture": platform.split("/")[1], "container_image_os": "linux", + "image_source_commit": SOURCE, "image_release_tag": "3.2.0", + "observations": observations, + "cleanup": {"api_ok": True, "verified_absent": True, + "container_count": 0, "network_count": 0, "volume_count": 0}, + } + + def set_artifact(self, artifact_id, record, name=None, run=None, data=None): + if artifact_id in self.metadata: + name = self.metadata[artifact_id]["name"] if name is None else name + run = self.metadata[artifact_id]["workflow_run"]["id"] if run is None else run + data = packed(record) if data is None else data + self.archives[artifact_id] = data + self.metadata[artifact_id] = { + "id": artifact_id, "name": name, "expired": False, + "size_in_bytes": len(data), "digest": "sha256:" + hashlib.sha256(data).hexdigest(), + "workflow_run": {"id": run, "repository_id": E.REPOSITORY_ID, + "head_repository_id": E.REPOSITORY_ID, "head_sha": SOURCE}, + "created_at": "2026-11-05T00:03:00Z", + } + if artifact_id == 501: + self.selected["corpus"]["artifact_digest"] = self.metadata[artifact_id]["digest"] + else: + for cell in self.selected["compose"]["cells"]: + if cell["artifact_id"] == artifact_id: + cell["artifact_digest"] = self.metadata[artifact_id]["digest"] + + def mutate_receipt(self, artifact_id, change): + value = E.archive_receipt(self.archives[artifact_id]) + change(value) + self.set_artifact(artifact_id, value) + + def api(self, path): + prefix = "repos/" + E.REPOSITORY + "/actions/" + if not path.startswith(prefix): + raise AssertionError("unexpected provider path") + suffix = path[len(prefix):] + if suffix.startswith("workflows/"): + return copy.deepcopy(self.workflows[suffix.split("/")[-1]]) + if suffix.startswith("artifacts/"): + identity = int(suffix.split("/")[-1]);self.artifact_reads[identity] = self.artifact_reads.get(identity, 0) + 1 + value = copy.deepcopy(self.metadata[identity]) + if self.on_final_artifact and self.artifact_reads[identity] > 1: + self.on_final_artifact(value) + return value + identity = int(suffix.split("/")[1]) + if "/jobs?" in suffix: + return {"total_count": len(self.job_sets[identity]), "jobs": copy.deepcopy(self.job_sets[identity])} + self.run_reads[identity] = self.run_reads.get(identity, 0) + 1 + value = copy.deepcopy(self.runs[identity]) + if self.on_final_run and self.run_reads[identity] > 1: + self.on_final_run(value) + return value + + def archive(self, artifact_id): + return self.archives[artifact_id] + + def workflow_blob(self, commit, path): + if commit != SOURCE: + raise E.Refusal("unapproved_source") + return "2" * 40 if path.endswith("native-image-parity.yml") else "3" * 40 + + def publication_subjects(self, release): + if release["index_digest"] != INDEX: + raise E.Refusal("unattested_index") + return copy.deepcopy(self.subjects) + + def promote(self): + proof = E.verify(self.selected, self) + self.writes.append(proof["release"]["index_digest"]) + + +class EvidenceTests(unittest.TestCase): + def refused(self, change): + provider = FakeProvider();change(provider) + with self.assertRaises(E.Refusal):provider.promote() + self.assertEqual([], provider.writes) + + def test_unwired_library_entrypoint_cannot_succeed_as_a_guard(self): + result=subprocess.run([sys.executable,str(ROOT / "scripts/ci/native_alias_evidence.py")], + capture_output=True,timeout=2,check=False) + self.assertNotEqual(0,result.returncode) + self.assertIn(b"executable verification is not wired",result.stderr) + + def test_complete_exact_tuple_reaches_write_ledger(self): + provider = FakeProvider();provider.promote();self.assertEqual([INDEX], provider.writes) + + def test_run_authority_refusals_leave_zero_writes(self): + mutations = [ + lambda p: p.runs[302].update(event="pull_request"), + lambda p: p.runs[302].update(head_sha="f" * 40), + lambda p: p.runs[302].update(head_branch="main"), + lambda p: p.runs[302].update(path=E.WORKFLOWS["compose"]), + lambda p: p.runs[302].update(workflow_id=999), + lambda p: p.runs[302].update(run_attempt=2), + lambda p: p.runs[302].update(conclusion="failure"), + lambda p: p.runs[302].update(status="in_progress"), + lambda p: p.runs[302].update(display_title="Native image parity ghcr.io/durable-workflow/server:3.2.0"), + lambda p: p.runs[302].update(run_started_at="2026-11-05T00:00:01Z"), + lambda p: p.runs[302].update(head_repository={"id":999,"full_name":"fork/server"}), + ] + for i, change in enumerate(mutations): + with self.subTest(i=i):self.refused(change) + + def test_required_job_and_step_refusals(self): + def missing_record_step(p): + p.job_sets[302][0]["steps"] = [s for s in p.job_sets[302][0]["steps"] + if s["name"] != "Record and compare the corpus against the native image"] + for change in [missing_record_step, + lambda p: p.job_sets[302][0].update(run_attempt=2), + lambda p: p.job_sets[302][0].update(conclusion="skipped"), + lambda p: p.job_sets[302][0]["steps"][0].update(conclusion="skipped"), + lambda p: p.job_sets[303].pop(), + lambda p: p.job_sets[303].append(copy.deepcopy(p.job_sets[303][0]))]: + with self.subTest(change=change):self.refused(change) + + def test_manifest_refuses_coercion_unknown_members_and_cell_substitution(self): + for change in [lambda p:p.selected["release"].update(run_id=True), + lambda p:p.selected["release"].update(tag="3.2.0-rc.1"), + lambda p:p.selected.update(repository="other/repo"), + lambda p:p.selected["compose"]["cells"].pop(), + lambda p:p.selected["compose"]["cells"].__setitem__(1,copy.deepcopy(p.selected["compose"]["cells"][0])), + lambda p:p.selected["corpus"].update(runner_commit="f" * 40), + lambda p:p.selected["corpus"].update(workflow_blob="f" * 40)]: + with self.subTest(change=change):self.refused(change) + + def test_artifact_ownership_digest_expiry_and_attempt_window(self): + for change in [lambda p:p.metadata[501].update(expired=True), + lambda p:p.metadata[501].update(name="another-artifact"), + lambda p:p.metadata[501]["workflow_run"].update(id=303), + lambda p:p.metadata[501].update(created_at="2026-11-05T00:00:01Z"), + lambda p:p.archives.__setitem__(501,p.archives[501]+b"altered")]: + with self.subTest(change=change):self.refused(change) + + def test_receipt_negative_controls_observe_zero_writes(self): + changes = [lambda v:v.update(eligible=False), + lambda v:v.update(schema="durable-workflow.native-qualification-diagnostic.v1"), + lambda v:v.update(run_attempt=2),lambda v:v.update(job_id=999), + lambda v:v.update(runner_commit="f"*40),lambda v:v.update(requested_image=E.GHCR_IMAGE+":3.2.0"), + lambda v:v.update(child_manifest_digest=INDEX),lambda v:v.update(actual_image_config_digest=INDEX), + lambda v:v.update(platform="linux/arm64"),lambda v:v.update(extra="refuse"), + lambda v:v["cleanup"].update(api_ok=False),lambda v:v["cleanup"].update(container_count=1), + lambda v:v["cleanup"].update(volume_count=False),lambda v:v["observations"].update(compare=False)] + for i, change in enumerate(changes): + with self.subTest(i=i):self.refused(lambda p:p.mutate_receipt(501,change)) + + def test_production_default_token_refusal_is_required(self): + self.refused(lambda p:p.mutate_receipt(503,lambda v:v["observations"].update(production_default_token_refused=False))) + + def test_current_attempt_and_artifact_are_rechecked(self): + self.refused(lambda p:setattr(p,"on_final_run",lambda run:run.update(run_attempt=2))) + self.refused(lambda p:setattr(p,"on_final_artifact",lambda artifact:artifact.update(expired=True))) + self.refused(lambda p:setattr(p,"on_final_artifact",lambda artifact:artifact.update(digest="sha256:"+"f"*64))) + self.refused(lambda p:setattr(p,"on_final_artifact",lambda artifact:artifact["workflow_run"].update(id=999))) + + def test_hostile_archive_members_and_canonical_json(self): + record=FakeProvider().make_receipt("corpus","linux/amd64",None,2001) + invalid = [packed(record,"../receipt.json"),packed(record,mode=stat.S_IFLNK|0o777), + packed(record,extra="extra.json"),packed(b'{"x":1,"x":2}\n'), + packed(b'{"x":1.0}\n'),packed(b'{"x":"\\ud800"}\n'),packed(b' {"x":1}\n'), + packed(b'{"padding":"'+b'a'*40000+b'"}\n'),b'prefix'+packed(record),packed(record)+b'trailing'] + for i, data in enumerate(invalid): + with self.subTest(i=i),self.assertRaises(E.Refusal):E.archive_receipt(data) + + def test_streaming_zip_descriptor_is_supported_without_extraction(self): + class NonSeekable(io.BytesIO): + def seek(self, *_):raise io.UnsupportedOperation("stream") + output=NonSeekable() + with zipfile.ZipFile(output,"w",compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("receipt.json",b'{"ok":true}\n') + self.assertEqual({"ok":True},E.archive_receipt(output.getvalue())) + + def test_subject_type_and_cross_platform_identity_refusals(self): + self.refused(lambda p:p.subjects["linux/amd64"].update(child_manifest_digest=[])) + self.refused(lambda p:p.subjects.__setitem__("linux/arm64",copy.deepcopy(p.subjects["linux/amd64"]))) + + def test_parser_rejects_duplicate_fields_nonfinite_and_noninteger_ids(self): + for raw in [b'{"x":1,"x":2}',b'{"x":NaN}',b'{"x":1e0}',b'{"x":Infinity}']: + with self.subTest(raw=raw),self.assertRaises(E.Refusal):E.strict_json(raw,8192) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py new file mode 100644 index 000000000..000373f7c --- /dev/null +++ b/tests/Unit/Support/native_qualification_test.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +"""Transport/producer/cleanup countercontrols; no real Docker or API calls.""" +import copy +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import textwrap +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts/ci")) +import native_alias_evidence as E +import native_alias_promote as P +import native_alias_transport as T +import native_qualification as Q +from native_alias_evidence_test import FakeProvider + +ROOT = Path(__file__).resolve().parents[3] + +SOURCE = "1" * 40 +INDEX, CHILD, CONFIG = ["sha256:" + letter * 64 for letter in "abc"] +CID, NID, VOLUME = [letter * 64 for letter in "def"] +SCOPE = "published-compose-301-1-smoke-amd64-local" +CONTEXT = { + "PROMOTION_TAG": "3.2.0", + "GITHUB_REPOSITORY": E.REPOSITORY, "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REF": "refs/tags/3.2.0", "GITHUB_REF_NAME": "3.2.0", "GITHUB_SHA": SOURCE, + "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/" + E.WORKFLOWS["compose"] + "@refs/tags/3.2.0", + "GITHUB_RUN_ID": "303", "GITHUB_RUN_ATTEMPT": "1", +} + + +def fake_command_lifecycle(module, exit_code=0, authority_error=False, cleanup_error=None): + """No process or signal: an early reap makes the fake PID foreign.""" + events = [] + + class Pipe: + def __init__(self, descriptor): + self.descriptor = descriptor + + def close(self): + events.append("close-pipe") + + class Process: + pid = 43210 + reaped = False + stdout, stderr = Pipe(10), Pipe(11) + + def wait(self, timeout=None): + events.append("reap") + if cleanup_error == "reap": + raise module.subprocess.TimeoutExpired("synthetic-private-marker", timeout) + self.reaped = True + return exit_code if exit_code is not None else -9 + + process = Process() + + class Selector: + def __init__(self): + self.keys = {} + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def register(self, stream, event, data): + self.keys[stream.descriptor] = SimpleNamespace(fd=stream.descriptor, fileobj=stream, data=data) + + def unregister(self, stream): + del self.keys[stream.descriptor] + + def get_map(self): + return self.keys + + def select(self, timeout): + return [(value, 1) for value in list(self.keys.values())] + + def observe(kind, pid, flags): + assert kind == os.P_PID + assert pid == process.pid + assert flags & os.WNOWAIT + assert flags & os.WNOHANG + events.append("observe") + if authority_error or process.reaped: + raise ChildProcessError("synthetic unowned child") + return None if exit_code is None else SimpleNamespace(si_pid=pid, si_code=os.CLD_EXITED, si_status=exit_code) + + def signal_group(pid, sig): + assert pid == process.pid + assert sig == module.signal.SIGKILL + events.append("foreign-signal" if process.reaped else "owned-signal") + if cleanup_error == "signal": + raise PermissionError("synthetic-private-marker") + + clock = [0.0] + + def now(): + clock[0] += 0.1 + return clock[0] + + error, result = None, None + with patch.object(module.subprocess, "Popen", return_value=process), \ + patch.object(module.selectors, "DefaultSelector", Selector), \ + patch.object(module.os, "read", return_value=b""), \ + patch.object(module.os, "waitid", side_effect=observe), \ + patch.object(module.os, "killpg", side_effect=signal_group), \ + patch.object(module.signal, "getsignal", return_value=module.signal.SIG_DFL), \ + patch.object(module.time, "monotonic", side_effect=now), \ + patch.object(module.time, "sleep"): + commands = module.Commands(1) + try: + result = commands.run(["synthetic-read-only-cli"]) + except E.Refusal as caught: + error = caught + return result, error, events + + +class CommandChildAuthorityTest(unittest.TestCase): + def test_success_signals_only_before_the_direct_child_is_reaped(self): + result, error, events = fake_command_lifecycle(T) + self.assertIsNone(error) + self.assertEqual(result, b"") + self.assertNotIn("foreign-signal", events) + self.assertIn("owned-signal", events) + self.assertLess(events.index("owned-signal"), events.index("reap")) + + def test_failure_and_timeout_also_clean_up_before_reaping(self): + for code, reason in ((1, "provider_read_failed"), (None, "read_timeout")): + with self.subTest(code=code): + _, error, events = fake_command_lifecycle(T, exit_code=code) + self.assertIsInstance(error, E.Refusal) + self.assertEqual(str(error), reason) + self.assertNotIn("foreign-signal", events) + self.assertIn("owned-signal", events) + self.assertLess(events.index("owned-signal"), events.index("reap")) + + def test_unknown_child_authority_never_signals_or_reaps_a_numeric_pid(self): + _, error, events = fake_command_lifecycle(T, authority_error=True) + self.assertIsInstance(error, E.Refusal) + self.assertEqual(str(error), "provider_child_authority_unknown") + self.assertNotIn("foreign-signal", events) + self.assertNotIn("owned-signal", events) + self.assertNotIn("reap", events) + self.assertEqual(events.count("close-pipe"), 2) + + def test_missing_nonreaping_primitive_refuses_before_process_creation(self): + commands = T.Commands() + with patch.object(T.os, "waitid", None), patch.object(T.subprocess, "Popen") as spawn: + with self.assertRaisesRegex(E.Refusal, "provider_child_authority_unavailable"): + commands.run(["synthetic-read-only-cli"]) + spawn.assert_not_called() + + def test_an_automatic_or_custom_reaper_refuses_before_process_creation(self): + for handler in (T.signal.SIG_IGN, lambda *args: None): + commands = T.Commands() + with self.subTest(handler=handler), \ + patch.object(T.signal, "getsignal", return_value=handler), \ + patch.object(T.subprocess, "Popen") as spawn: + with self.assertRaisesRegex(E.Refusal, "provider_child_authority_unavailable"): + commands.run(["synthetic-read-only-cli"]) + spawn.assert_not_called() + + def test_signal_or_bounded_reap_failure_cannot_return_a_successful_read(self): + for fault in ("signal", "reap"): + with self.subTest(fault=fault): + result, error, events = fake_command_lifecycle(T, cleanup_error=fault) + self.assertIsNone(result) + self.assertIsInstance(error, E.Refusal) + self.assertEqual(str(error), "provider_child_cleanup_failed") + self.assertNotIn("foreign-signal", events) + self.assertEqual(events.count("close-pipe"), 2) + + +class ResourceCommands: + def __init__(self): + self.containers, self.networks, self.volumes = {CID}, {NID}, {VOLUME} + self.removed, self.reads = [], [] + self.owner = SCOPE + self.corpus_owner = SCOPE + self.container_name = "/" + SCOPE + "-server" + self.volume_owner = None + self.network_owner = SCOPE + self.fail_list = self.leak = False + + def run(self, argv, limit=E.JSON_LIMIT, env=None): + self.reads.append(argv) + resource, operation = argv[1:3] + if operation == "ls": + if self.fail_list: + raise E.Refusal("fake_api_unavailable") + if resource == "volume" and "--filter" in argv and ( + type(self.volume_owner) is not dict or self.volume_owner.get(Q.COMPOSE_OWNER) != SCOPE): + return b"" + return ("\n".join(sorted(getattr(self, resource + "s"))) + + ("\n" if getattr(self, resource + "s") else "")).encode() + if operation == "inspect": + if resource == "container": + labels = {"com.docker.compose.project": self.owner} + if self.corpus_owner is not None: + labels[Q.OWNER] = self.corpus_owner + value = {"Id": CID, "Name": self.container_name, "Image": CONFIG, + "Config": {"Labels": labels}, + "NetworkSettings": {"Networks": {SCOPE: {}}}, + "Mounts": [{"Type": "volume", "Name": VOLUME}]} + elif resource == "volume": + value = {"Name": VOLUME, "Labels": self.volume_owner} + elif resource == "network": + value = {"Id": NID, "Name": SCOPE, "Containers": {CID: {}}, + "Labels": {Q.OWNER: self.network_owner, Q.COMPOSE_OWNER: self.network_owner}} + else: + value = {"Id": CONFIG, "Architecture": "amd64", "Os": "linux", + "Config": {"Labels": {"org.opencontainers.image.revision": SOURCE, + "dev.durable-workflow.release.tag": "3.2.0"}}} + return json.dumps([value]).encode() + if operation == "rm": + identities = {item for item in argv[3:] if not item.startswith("-")} + self.removed.append((resource, identities)) + if not self.leak: + getattr(self, resource + "s").difference_update(identities) + return b"" + if argv[:3] == ["git", "rev-parse", "HEAD"]: + return SOURCE.encode() + if argv[:2] == ["git", "rev-parse"]: + return ("3" * 40).encode() + raise AssertionError(argv) + + +class ProducerProvider: + def __init__(self): + self.commands = ResourceCommands() + self.subject = {"child_manifest_digest": CHILD, "image_config_digest": CONFIG} + owner = {"id": E.REPOSITORY_ID, "full_name": E.REPOSITORY} + self.run = {"id": 303, "run_attempt": 1, "head_sha": SOURCE, + "path": E.WORKFLOWS["compose"], "event": "workflow_dispatch", + "head_branch": "3.2.0", "repository": owner, "head_repository": owner} + self.job = {"id": 3001, "run_id": 303, "run_attempt": 1, "head_sha": SOURCE, + "status": "in_progress", "name": "linux/amd64 local published image"} + + def index_subjects(self, *args): + return {"linux/amd64": self.subject} + + def workflow_blob(self, *args): + return "3" * 40 + + def api(self, path): + return {"jobs": [self.job], "total_count": 1} if "/jobs?" in path else self.run + + +class ProducerTests(unittest.TestCase): + def test_cleanup_removes_only_observed_owned_objects_and_checks_absence(self): + commands = ResourceCommands() + result = Q.cleanup(commands, "compose", SCOPE) + self.assertEqual(result, {"api_ok": True, "verified_absent": True, + "container_count": 0, "network_count": 0, "volume_count": 0}) + self.assertEqual(commands.removed, [("container", {CID}), ("network", {NID}), ("volume", {VOLUME})]) + self.assertGreaterEqual(sum(args[2] == "ls" for args in commands.reads), 8) + + def test_engine_anonymous_marker_requires_owned_mount_and_no_foreign_labels(self): + for role in ("compose", "corpus"): + commands = ResourceCommands() + commands.volume_owner = {"com.docker.volume.anonymous": ""} + self.assertIs(Q.cleanup(commands, role, SCOPE)["verified_absent"], True) + for labels in ({"com.docker.volume.anonymous": "", "owner": "foreign"}, + {"com.docker.volume.anonymous": "not-the-engine-marker"}): + commands = ResourceCommands() + commands.volume_owner = labels + with self.subTest(role=role, labels=labels), self.assertRaisesRegex(E.Refusal, "cleanup_volume_owner"): + Q.cleanup(commands, role, SCOPE) + self.assertEqual(commands.removed, []) + + def test_foreign_network_never_authorizes_any_removal(self): + for role in ("compose", "corpus"): + commands = ResourceCommands() + commands.network_owner = "foreign" + with self.subTest(role=role), self.assertRaisesRegex(E.Refusal, "cleanup_network_ownership"): + Q.cleanup(commands, role, SCOPE) + self.assertEqual(commands.removed, []) + + def test_cli_resource_and_file_paths_are_closed_before_any_sink(self): + commands = ResourceCommands() + for identity in ("--format={{.Config.Env}}", "../private", "server\n--format=env", "x/y"): + with self.subTest(identity=identity), self.assertRaisesRegex(E.Refusal, "docker_resource_identity"): + Q.inspect(commands, "container", identity) + self.assertEqual(commands.reads, []) + for path in ("../private", "/tmp/private", "qualification-state/../private", "qualification-state/other.json"): + candidate = Path(path) + with self.subTest(path=path), self.assertRaisesRegex(E.Refusal, "qualification_path_not_allowed"): + Q.state_path(candidate) + with tempfile.TemporaryDirectory() as directory, patch.object(Q, "ROOT", Path(directory)): + target = Path(directory) / "qualification-state" + target.symlink_to(Path(directory), target_is_directory=True) + with self.assertRaisesRegex(E.Refusal, "qualification_path_symlink"): + Q.state_path(Path("qualification-state/image.json")) + + def test_cleanup_unavailable_is_unknown_and_foreign_resources_are_not_removed(self): + for field, value in (("fail_list", True), ("owner", "another-project"), + ("volume_owner", {"com.docker.compose.project": "foreign"})): + commands = ResourceCommands() + setattr(commands, field, value) + with self.subTest(field=field), self.assertRaises(E.Refusal): + Q.cleanup(commands, "compose", SCOPE) + self.assertEqual(commands.removed, []) + + def test_cleanup_remaining_resource_is_failure(self): + for role in ("corpus", "compose"): + commands = ResourceCommands() + commands.leak = True + with self.subTest(role=role), self.assertRaisesRegex(E.Refusal, "cleanup_not_absent"): + Q.cleanup(commands, role, SCOPE) + + def test_corpus_unavailable_inventory_is_unknown_with_zero_removals(self): + commands = ResourceCommands() + commands.fail_list = True + with self.assertRaisesRegex(E.Refusal, "fake_api_unavailable"): + Q.cleanup(commands, "corpus", SCOPE) + self.assertEqual(commands.removed, []) + + def test_corpus_prefix_attachment_and_missing_label_never_authorize_removal(self): + for name, owner in (("/foreign-owner", "foreign"), ("/" + SCOPE + "-rust", "foreign"), + ("/" + SCOPE + "-rust", None)): + commands = ResourceCommands() + commands.container_name, commands.corpus_owner = name, owner + with self.subTest(name=name, owner=owner), self.assertRaisesRegex(E.Refusal, "cleanup_container_owner"): + Q.cleanup(commands, "corpus", SCOPE) + self.assertEqual(commands.removed, []) + + def test_corpus_labelled_attached_or_standalone_helpers_are_owned(self): + for attached in (True, False): + commands = ResourceCommands() + commands.container_name = "/anonymous-helper" + if not attached: + commands.networks.clear() + result = Q.cleanup(commands, "corpus", SCOPE) + self.assertIs(result["verified_absent"], True) + self.assertIn(("container", {CID}), commands.removed) + + def test_diagnostic_context_does_not_query_provider_or_docker(self): + provider = ProducerProvider() + with patch.dict(os.environ, CONTEXT, clear=True): + for field, value in (("GITHUB_EVENT_NAME", "pull_request"), + ("GITHUB_SHA", "unknown"), ("GITHUB_REF", "refs/heads/main")): + with patch.dict(os.environ, {field: value}): + result = Q.observe_image(provider, "compose", E.GHCR_IMAGE + "@" + INDEX, CID, "linux/amd64") + self.assertIs(result["eligible"], False) + self.assertEqual(provider.commands.reads, []) + + def test_observed_image_and_finalized_receipt_bind_to_consumer(self): + provider = ProducerProvider() + with patch.dict(os.environ, CONTEXT, clear=True): + image = E.GHCR_IMAGE + "@" + INDEX + identity = Q.observe_image(provider, "compose", image, CID, "linux/amd64") + observations = {key: True for key in E.COMPOSE_OBSERVATIONS} + observations["production_default_token_refused"] = None + cleanup = Q.cleanup(ResourceCommands(), "compose", SCOPE) + record = Q.finalize(provider, "compose", image, "linux/amd64", "local", identity, + observations, cleanup, "success", "success") + E.bind_receipt(record, "compose", {"workflow_blob": "3" * 40}, provider.run, provider.job, + {"source_commit": SOURCE, "tag": "3.2.0", "index_digest": INDEX}, + "linux/amd64", "local", provider.subject) + self.assertEqual(set(record), E.RECEIPT_KEYS) + for field, replacement in (("api_ok", False), ("verified_absent", False), + ("container_count", False), ("volume_count", 1)): + invalid = {**cleanup, field: replacement} + with self.subTest(cleanup=field), self.assertRaises(E.Refusal): + Q.finalize(provider, "compose", image, "linux/amd64", "local", identity, + observations, invalid, "success", "success") + for body, clean in (("failure", "success"), ("success", "failure"), ("cancelled", "success")): + with self.subTest(body=body, clean=clean), self.assertRaises(E.Refusal): + Q.finalize(provider, "compose", image, "linux/amd64", "local", identity, + observations, cleanup, body, clean) + provider.run["run_attempt"] = 2 + with self.assertRaises(E.Refusal): + Q.finalize(provider, "compose", image, "linux/amd64", "local", identity, + observations, cleanup, "success", "success") + + def test_actual_config_id_cannot_be_substituted_with_index(self): + provider = ProducerProvider() + provider.subject["image_config_digest"] = INDEX + with patch.dict(os.environ, CONTEXT, clear=True), self.assertRaisesRegex(E.Refusal, "actual_container_image"): + Q.observe_image(provider, "compose", E.GHCR_IMAGE + "@" + INDEX, CID, "linux/amd64") + + +class ReadCommands: + def __init__(self, result): + self.result, self.calls = result, [] + + def run(self, argv, limit=E.JSON_LIMIT, env=None, accepted=(0,)): + self.calls.append((argv, limit, env, accepted)) + return self.result + + +class TransportTests(unittest.TestCase): + def test_unprivileged_plan_requires_exact_tag_and_accepted_main_source(self): + value = {"release": {"tag": "3.2.0", "source_commit": SOURCE}} + context = {**CONTEXT, "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/.github/workflows/native-alias-promotion.yml@refs/tags/3.2.0"} + from unittest.mock import Mock + provider = Mock() + with patch.dict(os.environ, context, clear=True): + for status in ("ahead", "identical"): + provider.api.return_value = {"status": status, "base_commit": {"sha": SOURCE}} + P.plan(value, provider) + for comparison in ({"status": "behind", "base_commit": {"sha": SOURCE}}, + {"status": "diverged", "base_commit": {"sha": SOURCE}}, + {"status": "ahead", "base_commit": {"sha": "f" * 40}}, {}): + provider.api.return_value = comparison + with self.subTest(comparison=comparison), self.assertRaisesRegex(E.Refusal, "promotion_source_not_on_main"): + P.plan(value, provider) + provider.reset_mock() + with patch.dict(os.environ, {"PROMOTION_TAG": "3.2.1"}), self.assertRaisesRegex(E.Refusal, "promotion_dispatch_identity"): + P.plan(value, provider) + provider.api.assert_not_called() + + def test_api_only_targets_fixed_owning_repository_and_host(self): + commands = ReadCommands(b"{}") + provider = T.GitHubRegistryProvider(commands) + provider.api("repos/" + E.REPOSITORY + "/actions/runs/301") + self.assertEqual(commands.calls[0][0][:6], ["gh", "api", "--hostname", "github.com", "--method", "GET"]) + for path in ("https://foreign.invalid/", "repos/fork/server/actions/runs/301", + "repos/" + E.REPOSITORY + "/../secrets", "repos/" + E.REPOSITORY + "/runs\nanything"): + with self.subTest(path=path), self.assertRaises(E.Refusal): + provider.api(path) + self.assertEqual(len(commands.calls), 1) + + def test_registry_subjects_bind_platform_child_config_and_source(self): + class Registry(T.GitHubRegistryProvider): + def __init__(self): + self.members = { + INDEX: {"manifests": [ + {"platform": {"os": "linux", "architecture": "amd64"}, "digest": CHILD}, + {"platform": {"os": "linux", "architecture": "arm64"}, "digest": "sha256:" + "d" * 64}]}, + CHILD: {"config": {"digest": CONFIG}}, + "sha256:" + "d" * 64: {"config": {"digest": "sha256:" + "e" * 64}}, + } + labels = {"org.opencontainers.image.revision": SOURCE, "dev.durable-workflow.release.tag": "3.2.0"} + self.configs = {CHILD: {"os": "linux", "architecture": "amd64", "config": {"Labels": dict(labels)}}, + "sha256:" + "d" * 64: {"os": "linux", "architecture": "arm64", "config": {"Labels": dict(labels)}}} + + def registry_json(self, image, digest, image_config=False): + return (self.configs if image_config else self.members)[digest] + + registry = Registry() + self.assertEqual(registry.index_subjects(E.GHCR_IMAGE, INDEX, SOURCE, "3.2.0")["linux/amd64"], + {"child_manifest_digest": CHILD, "image_config_digest": CONFIG}) + for fault in ("platform", "source", "release", "config", "extra_member"): + registry = Registry() + if fault == "platform": registry.configs[CHILD]["architecture"] = "arm64" + elif fault == "source": registry.configs[CHILD]["config"]["Labels"]["org.opencontainers.image.revision"] = "f" * 40 + elif fault == "release": registry.configs[CHILD]["config"]["Labels"]["dev.durable-workflow.release.tag"] = "3.1.0" + elif fault == "config": registry.members[CHILD]["config"]["digest"] = "not-a-config" + else: registry.members[INDEX]["manifests"].append(copy.deepcopy(registry.members[INDEX]["manifests"][0])) + with self.subTest(fault=fault), self.assertRaises(E.Refusal): + registry.index_subjects(E.GHCR_IMAGE, INDEX, SOURCE, "3.2.0") + + def test_registry_cli_rejects_option_or_argument_injection_before_any_command(self): + commands = ReadCommands(b"{}") + provider = T.GitHubRegistryProvider(commands) + for image, digest in (("--format=env", INDEX), ("foreign.invalid/server", INDEX), + (E.GHCR_IMAGE, "--format=env"), (E.GHCR_IMAGE, INDEX + "\n--format=env"), + (E.GHCR_IMAGE, "sha256:" + "a" * 63)): + with self.subTest(image=image, digest=digest), self.assertRaisesRegex(E.Refusal, "registry_subject"): + provider.registry_json(image, digest) + self.assertEqual(commands.calls, []) + + def test_release_absence_requires_authoritative_http_404(self): + for status, body, expected in (("404 Not Found", {"message": "Not Found"}, False), + ("200 OK", {"id": 42, "tag_name": "3.2.0"}, True)): + commands = ReadCommands(("HTTP/2.0 " + status + "\r\nContent-Type: application/json\r\n\r\n" + json.dumps(body)).encode()) + self.assertIs(T.GitHubRegistryProvider(commands).release_exists("3.2.0"), expected) + for response in (b"unauthorized", b"HTTP/2.0 503 Error\r\n\r\n{}", + b"HTTP/2.0 404 Not Found\r\nContent-Type: application/json\r\n\r\n{}"): + provider = T.GitHubRegistryProvider(ReadCommands(response)) + with self.subTest(response=response), self.assertRaises(E.Refusal): + provider.release_exists("3.2.0") + + def test_commands_bound_output_time_and_sanitize_errors(self): + for code, limit, seconds in (("print('x'*10000)", 10, 1), + ("import time; time.sleep(1)", 100, .03), + ("import sys; sys.stderr.write('private-marker'); sys.exit(1)", 100, 1)): + commands = T.Commands(seconds) + with self.subTest(code=code), self.assertRaises(E.Refusal) as caught: + commands.run([sys.executable, "-c", code], limit) + self.assertNotIn("private-marker", str(caught.exception)) + self.assertEqual(T.Commands().run([sys.executable, "-c", "print('bounded')"]), b"bounded\n") + + def test_promotion_dispatch_cannot_be_a_main_or_foreign_workflow(self): + value = {"release": {"tag": "3.2.0", "source_commit": SOURCE}} + context = {**CONTEXT, "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/.github/workflows/native-alias-promotion.yml@refs/tags/3.2.0"} + with patch.dict(os.environ, context, clear=True): + P.dispatch(value) + for field, replacement in (("GITHUB_REF", "refs/heads/main"), ("GITHUB_SHA", "2" * 40), + ("GITHUB_WORKFLOW_REF", "foreign/workflow")): + with patch.dict(os.environ, {field: replacement}), self.assertRaises(E.Refusal): + P.dispatch(value) + + def test_protected_entry_refusals_leave_zero_write_ledger(self): + context = {**CONTEXT, "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/.github/workflows/native-alias-promotion.yml@refs/tags/3.2.0"} + for fault in ("legacy_release", "attempt", "workflow_blob", "archive"): + provider = FakeProvider() + provider.release_exists = lambda tag: fault == "legacy_release" + if fault == "attempt": + provider.runs[302]["run_attempt"] = 2 + elif fault == "workflow_blob": + provider.selected["corpus"]["workflow_blob"] = "f" * 40 + elif fault == "archive": + provider.archives[501] += b"extra" + with patch.dict(os.environ, context, clear=True), self.subTest(fault=fault): + with self.assertRaises(E.Refusal): + P.qualify(provider.selected, provider, "promote") + + +class WorkflowContracts(unittest.TestCase): + def test_only_separate_protected_job_may_move_native_aliases(self): + publisher = (ROOT / ".github/workflows/native-release.yml").read_text() + promotion = (ROOT / ".github/workflows/native-alias-promotion.yml").read_text() + self.assertNotIn("\n aliases:", publisher) + self.assertNotIn("native-release-rolling-tags.sh", publisher) + readonly, protected = promotion.split("\n promote:\n", 1) + self.assertNotIn(": write", readonly) + self.assertNotIn("secrets.DOCKERHUB", readonly) + self.assertIn("environment: dockerhub-native", protected) + self.assertIn("group: durable-workflow-server-rolling-aliases", protected) + self.assertIn("packages: write", protected) + self.assertNotIn("contents: write", promotion) + self.assertIn("native_alias_promote.py verify", readonly) + self.assertIn("native_alias_promote.py promote", protected) + for name, steps in E.RELEASE_JOBS.items(): + self.assertIn("name: " + name, publisher) + for step in steps: + self.assertIn("name: " + step, publisher) + + def test_receipt_authority_follows_body_and_cleanup_in_existing_jobs(self): + for role in ("corpus", "compose"): + source = (ROOT / E.WORKFLOWS[role]).read_text() + steps = E.QUALIFICATION_STEPS[role] + positions = [source.index("name: " + step) for step in steps] + self.assertEqual(positions, sorted(positions)) + self.assertIn("QUALIFICATION_BODY_OUTCOME: ${{ steps.body.outcome }}", source) + self.assertIn("QUALIFICATION_CLEANUP_OUTCOME: ${{ steps.cleanup.outcome }}", source) + self.assertIn("promotion-receipt/receipt.json", source) + self.assertIn("if-no-files-found: error", source) + + def test_workflow_strict_cleanup_failure_has_no_fallback_removal(self): + # Execute the actual workflow block AND helper against an inert Docker + # provider that presents a foreign exact-name/attached collision. Both + # helper broadening and a raw workflow fallback would produce removals. + with tempfile.TemporaryDirectory() as directory: + scratch = Path(directory) + scripts = scratch / "scripts/ci" + scripts.mkdir(parents=True) + for name in ("native_qualification.py", "native_alias_evidence.py", "native_alias_transport.py"): + shutil.copyfile(ROOT / "scripts/ci" / name, scripts / name) + docker = scratch / "docker" + docker.write_text("#!" + sys.executable + "\n" + textwrap.dedent(''' + import json, os, sys + from pathlib import Path + args = sys.argv[1:] + with Path(os.environ["FAKE_LEDGER"]).open("a") as log: + log.write(json.dumps(args) + "\\n") + cid, nid, scope = "d" * 64, "e" * 64, os.environ["RESOURCE_SCOPE"] + resource, operation = args[:2] + if operation == "ls": + print(cid if resource == "container" else nid if resource == "network" else "") + elif operation == "inspect": + value = ({"Id": nid, "Name": scope, "Labels": {"dev.durable-workflow.qualification.scope": scope}, + "Containers": {cid: {}}} if resource == "network" else + {"Id": cid, "Name": "/" + scope + "-rust", "Mounts": [], + "Config": {"Labels": {"owner": "foreign", "com.docker.compose.project": "foreign"}}, + "NetworkSettings": {"Networks": {scope: {}}}}) + print(json.dumps([value])) + elif operation == "rm" or resource == "rm": + pass + else: + raise SystemExit(71) + ''')) + docker.chmod(0o755) + for role in ("corpus", "compose"): + source = (ROOT / E.WORKFLOWS[role]).read_text() + block = source.split(" - name: Verify owned qualification cleanup\n", 1)[1] + block = block.split(" - name:", 1)[0] + body = textwrap.dedent(block.split(" run: |\n", 1)[1]) + ledger = scratch / (role + "-ledger") + result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", body], + env={"PATH": directory + ":" + os.environ["PATH"], + "RESOURCE_SCOPE": SCOPE, "DW_PUBLISHED_COMPOSE_PROJECT": SCOPE, + "FAKE_LEDGER": str(ledger)}, cwd=scratch, + capture_output=True, timeout=5, check=False) + self.assertNotEqual(result.returncode, 0, role) + calls = [json.loads(line) for line in ledger.read_text().splitlines()] + self.assertTrue(calls) + self.assertFalse(any("rm" in call for call in calls), calls) + self.assertFalse((scratch / "qualification-state/cleanup.json").exists()) + self.assertNotIn("docker ", body) + self.assertNotIn("||", body) + + +if __name__ == "__main__": + unittest.main()