diff --git a/.github/actions/ci-archive-cache/action.yml b/.github/actions/ci-archive-cache/action.yml new file mode 100644 index 000000000..80c3a5879 --- /dev/null +++ b/.github/actions/ci-archive-cache/action.yml @@ -0,0 +1,33 @@ +name: Cache a pinned CI archive +description: Restore an archive by tool version and content digest +inputs: + tool: + description: Tool identifier + required: true + version: + description: Pinned tool version + required: true + sha256: + description: Pinned archive digest + required: true +runs: + using: composite + steps: + - id: archive + shell: bash + env: + TOOL: ${{ inputs.tool }} + VERSION: ${{ inputs.version }} + SHA256: ${{ inputs.sha256 }} + run: | + set -euo pipefail + [[ "$TOOL" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] + [[ "$VERSION" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] + [[ "$SHA256" =~ ^[0-9a-f]{64}$ ]] + directory="$RUNNER_TEMP/coreav-ci-archives" + echo "path=$directory/$TOOL-$VERSION-$SHA256.archive" >> "$GITHUB_OUTPUT" + echo "COREAV_CI_ARCHIVE_DIR=$directory" >> "$GITHUB_ENV" + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ${{ steps.archive.outputs.path }} + key: ci-tool-archive-${{ inputs.tool }}-${{ inputs.version }}-${{ inputs.sha256 }} diff --git a/.github/actions/ci-check-tool/action.yml b/.github/actions/ci-check-tool/action.yml new file mode 100644 index 000000000..ddbf41d48 --- /dev/null +++ b/.github/actions/ci-check-tool/action.yml @@ -0,0 +1,34 @@ +name: Install a pinned CI check tool +description: Restore and verify the pinned archive before installation +inputs: + tool: + description: actionlint or cargo-deny + required: true +runs: + using: composite + steps: + - id: pin + shell: bash + env: + TOOL: ${{ inputs.tool }} + run: | + set -euo pipefail + source "$GITHUB_ACTION_PATH/../../../scripts/ci-tools.env" + case "$TOOL" in + actionlint) version=$ACTIONLINT_VERSION; sha256=$ACTIONLINT_SHA256 ;; + cargo-deny) version=$CARGO_DENY_VERSION; sha256=$CARGO_DENY_SHA256 ;; + *) echo 'reason=ci_check_tool_unknown' >&2; exit 2 ;; + esac + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "sha256=$sha256" >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/ci-archive-cache + with: + tool: ${{ inputs.tool }} + version: ${{ steps.pin.outputs.version }} + sha256: ${{ steps.pin.outputs.sha256 }} + - shell: bash + env: + TOOL: ${{ inputs.tool }} + run: | + bash "$GITHUB_ACTION_PATH/../../../scripts/install-ci-check-tool.sh" "$TOOL" "$RUNNER_TEMP/coreav-ci-tools" + echo "$RUNNER_TEMP/coreav-ci-tools" >> "$GITHUB_PATH" diff --git a/.github/workflows/deny.yml b/.github/workflows/deny.yml index 528dbb0ef..224ff4b86 100644 --- a/.github/workflows/deny.yml +++ b/.github/workflows/deny.yml @@ -84,6 +84,9 @@ jobs: python3 -I scripts/oracle_pin.py --check python3 -I scripts/workflow_check_selftest.py python3 -I -m unittest discover -s scripts/tests -p 'test_oracle_install.py' -v + for suite in test_ci_downloads.py test_ci_reference_downloads.py test_maturin_downloads.py; do + python3 -I -m unittest discover -s scripts/tests -p "$suite" -v + done python3 -m unittest discover -s scripts/encoders/macos -p 'test_source_freeze.py' -v python3 -I -m unittest discover -s .github/actions/changed-paths/tests -p 'test_*.py' python3 -I scripts/pr_text_check_selftest.py @@ -100,24 +103,20 @@ jobs: # Its shellcheck and pyflakes passes are off: they judge scripts, not # expressions. The fixture it writes is the mistake this step exists # for, which has to fail. + - uses: ./.github/actions/ci-check-tool + with: + tool: actionlint - name: Check the expressions of the workflows - env: - VERSION: 1.7.12 - SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 run: | set -euo pipefail - curl -sSfL -o "$RUNNER_TEMP/actionlint.tar.gz" \ - "https://github.com/rhysd/actionlint/releases/download/v$VERSION/actionlint_${VERSION}_linux_amd64.tar.gz" - echo "$SHA256 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c - - tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint - "$RUNNER_TEMP/actionlint" -shellcheck= -pyflakes= + actionlint -shellcheck= -pyflakes= # GitHub expands an expression anywhere in this script before it # runs, so the fixture's opening is put together here. open='$''{{' printf '%s\n' 'on: push' 'jobs:' ' probe:' ' runs-on: ubuntu-24.04' ' env:' \ " DIR: $open runner.temp }}/out" ' steps:' ' - run: echo hi' \ > "$RUNNER_TEMP/runner-in-job-env.yml" - if found=$("$RUNNER_TEMP/actionlint" -shellcheck= -pyflakes= "$RUNNER_TEMP/runner-in-job-env.yml" 2>&1); then + if found=$(actionlint -shellcheck= -pyflakes= "$RUNNER_TEMP/runner-in-job-env.yml" 2>&1); then echo 'actionlint accepted a runner expression in a job env' >&2 exit 1 fi @@ -192,25 +191,21 @@ jobs: set -euo pipefail python3 -m pip install --quiet --require-hashes -r bench/opus-speed/requirements.txt python3 -I -m unittest discover -s bench/opus-speed/tests -p 'test_*.py' - - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 - if: steps.result.outputs.reuse != 'true' # The fuzz projects are outside the workspace; check their own license # graphs. Projects are found as scripts/fuzz_plan.py finds them # (crates/*/fuzz), so a new one is checked without editing this file; - # finding none fails. cargo-deny is the version the action above runs. + # finding none fails. - name: Install cargo-deny if: steps.result.outputs.reuse != 'true' || github.event_name == 'merge_group' - env: - VERSION: 0.20.2 - SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f + uses: ./.github/actions/ci-check-tool + with: + tool: cargo-deny + - name: Check the workspace dependency policy + if: steps.result.outputs.reuse != 'true' run: | set -euo pipefail - name="cargo-deny-$VERSION-x86_64-unknown-linux-musl" - curl -sSfL -o "$RUNNER_TEMP/deny.tar.gz" \ - "https://github.com/EmbarkStudios/cargo-deny/releases/download/$VERSION/$name.tar.gz" - echo "$SHA256 $RUNNER_TEMP/deny.tar.gz" | sha256sum -c - - tar -xzf "$RUNNER_TEMP/deny.tar.gz" -C "$RUNNER_TEMP" "$name/cargo-deny" - echo "$RUNNER_TEMP/$name" >> "$GITHUB_PATH" + rustup show active-toolchain || rustup toolchain install + CARGO_TERM_COLOR=always cargo-deny --log-level warn --manifest-path ./Cargo.toml --all-features check - name: Refresh advisory checks on a reused merge group id: fresh-advisories if: github.event_name == 'merge_group' && steps.result.outputs.reuse == 'true' diff --git a/.github/workflows/h264-encoder.yml b/.github/workflows/h264-encoder.yml index f39350d5d..50b6d7039 100644 --- a/.github/workflows/h264-encoder.yml +++ b/.github/workflows/h264-encoder.yml @@ -22,6 +22,10 @@ on: - scripts/tests/test_h264_crf.py - scripts/tests/test_h264_p.py - scripts/install-jm-decoder.sh + - scripts/install-ci-jm-decoder.sh + - scripts/fetch-ci-archive.sh + - scripts/tests/test_ci_reference_downloads.py + - .github/actions/ci-archive-cache/action.yml - scripts/jm-decoder.env - scripts/oracle-ffmpeg.env - scripts/oracle_pin.py @@ -50,6 +54,10 @@ on: - scripts/tests/test_h264_crf.py - scripts/tests/test_h264_p.py - scripts/install-jm-decoder.sh + - scripts/install-ci-jm-decoder.sh + - scripts/fetch-ci-archive.sh + - scripts/tests/test_ci_reference_downloads.py + - .github/actions/ci-archive-cache/action.yml - scripts/jm-decoder.env - scripts/oracle-ffmpeg.env - scripts/oracle_pin.py @@ -173,6 +181,7 @@ jobs: components: clippy - name: Unit tests and opt-in driver run: | + python3 -I -m unittest discover -s scripts/tests -p 'test_ci_reference_downloads.py' -v H264_VUI_FIXTURE_DIR="$RUNNER_TEMP/vui" H264_P_FIXTURE_DIR="$RUNNER_TEMP/p-forced" cargo test --locked --release -p coreav-h264-encoder --features full cargo clippy --locked -p coreav-h264-encoder --features full --all-targets -- -D warnings cargo build --locked --release -p coreav-h264-encoder --features full --example g1 @@ -194,10 +203,27 @@ jobs: python3 -I -m unittest discover -s scripts/tests -p 'test_h264_p.py' python3 -I -m unittest discover -s scripts/tests -p 'test_h264_crf.py' - uses: ./.github/actions/oracle-cache + - name: Read the decoder archive pins + id: jm + run: | + . scripts/jm-decoder.env + echo "version=$JM_VERSION" >> "$GITHUB_OUTPUT" + echo "sha256=$JM_SHA256" >> "$GITHUB_OUTPUT" + echo "ca-sha256=$JM_CA_SHA256" >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/ci-archive-cache + with: + tool: jm + version: ${{ steps.jm.outputs.version }} + sha256: ${{ steps.jm.outputs.sha256 }} + - uses: ./.github/actions/ci-archive-cache + with: + tool: jm-ca + version: ${{ steps.jm.outputs.version }} + sha256: ${{ steps.jm.outputs.ca-sha256 }} - name: Install black-box decoder oracles run: | scripts/install-ci-oracle-ffmpeg.sh "$RUNNER_TEMP/g1-oracles" - bash scripts/install-jm-decoder.sh "$RUNNER_TEMP/g1-oracles" + bash scripts/install-ci-jm-decoder.sh "$RUNNER_TEMP/g1-oracles" python3 -I scripts/oracle_pin.py --verify-bin "$RUNNER_TEMP/g1-oracles" cat "$RUNNER_TEMP/g1-oracles/jm-build.txt" - name: Generate existing non-judged fixtures diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index a20ee0b34..2cc3718b3 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -102,6 +102,10 @@ jobs: .github/actions/oracle-cache/action.yml scripts/binaryen.env scripts/install-binaryen.sh + scripts/install-ci-binaryen.sh + scripts/fetch-ci-archive.sh + scripts/tests/test_ci_reference_downloads.py + .github/actions/ci-archive-cache/action.yml .github/workflows/npm.yml .github/actions/changed-paths/** .github/actions/merge-group-result/** @@ -140,10 +144,21 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '24' + - name: Read the optimizer archive pin + id: binaryen + run: | + . scripts/binaryen.env + echo "version=$BINARYEN_VERSION" >> "$GITHUB_OUTPUT" + echo "sha256=$BINARYEN_SHA256_X86_64_LINUX" >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/ci-archive-cache + with: + tool: binaryen + version: ${{ steps.binaryen.outputs.version }} + sha256: ${{ steps.binaryen.outputs.sha256 }} - name: Install binaryen run: | set -euo pipefail - opt="$(scripts/install-binaryen.sh "$RUNNER_TEMP/binaryen")" + opt="$(bash scripts/install-ci-binaryen.sh "$RUNNER_TEMP/binaryen")" echo "WASM_OPT=$opt" >> "$GITHUB_ENV" - name: Lint the command for WASI run: cargo clippy --locked -p coreav-cli --no-default-features --features cli --target wasm32-wasip1 -- -D warnings diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 7ae3574ba..0aaf24721 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -77,6 +77,11 @@ jobs: scripts/oracle-ffmpeg.env scripts/install-ci-oracle-ffmpeg.sh .github/actions/oracle-cache/action.yml + scripts/maturin.env + scripts/install-ci-maturin.sh + scripts/fetch-ci-archive.sh + scripts/tests/test_maturin_downloads.py + .github/actions/ci-archive-cache/** .github/workflows/python.yml .github/actions/changed-paths/** .github/actions/merge-group-result/** @@ -129,12 +134,17 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - - name: Build the wheel (manylinux) - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + - id: maturin-pin + run: scripts/install-ci-maturin.sh metadata linux-x86_64 >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/ci-archive-cache with: - target: x86_64 - manylinux: auto - args: --profile dist --locked --out dist --manifest-path crates/coreav-py/Cargo.toml + tool: ${{ steps.maturin-pin.outputs.tool }} + version: ${{ steps.maturin-pin.outputs.version }} + sha256: ${{ steps.maturin-pin.outputs.sha256 }} + - name: Build the wheel (manylinux) + run: scripts/install-ci-maturin.sh build linux-x86_64 --profile dist --locked --out dist --manifest-path crates/coreav-py/Cargo.toml + - name: Keep CI build glue out of the wheel + run: scripts/install-ci-maturin.sh verify-artifacts linux-x86_64 - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: toolchain: stable @@ -189,14 +199,17 @@ jobs: persist-credentials: false # Cross-compiled in the manylinux image, so no arm64 runner is needed; # the test runs under QEMU. - - name: Build the wheel (manylinux, cross-compiled) - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + - id: maturin-pin + run: scripts/install-ci-maturin.sh metadata linux-arm64 >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/ci-archive-cache with: - target: aarch64 - # manylinux_2_28: the manylinux2014 cross compiler does not define - # __ARM_ARCH, which ring's assembly needs (docs/design/python.md). - manylinux: 2_28 - args: --profile dist --locked --out dist --manifest-path crates/coreav-py/Cargo.toml + tool: ${{ steps.maturin-pin.outputs.tool }} + version: ${{ steps.maturin-pin.outputs.version }} + sha256: ${{ steps.maturin-pin.outputs.sha256 }} + - name: Build the wheel (manylinux, cross-compiled) + run: scripts/install-ci-maturin.sh build linux-arm64 --profile dist --locked --out dist --manifest-path crates/coreav-py/Cargo.toml + - name: Keep CI build glue out of the wheel + run: scripts/install-ci-maturin.sh verify-artifacts linux-arm64 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: fixtures @@ -236,11 +249,17 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.12' - - name: Build the wheel - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + - id: maturin-pin + run: scripts/install-ci-maturin.sh metadata macos-arm64 >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/ci-archive-cache with: - target: aarch64-apple-darwin - args: --profile dist --locked --out dist --manifest-path crates/coreav-py/Cargo.toml + tool: ${{ steps.maturin-pin.outputs.tool }} + version: ${{ steps.maturin-pin.outputs.version }} + sha256: ${{ steps.maturin-pin.outputs.sha256 }} + - name: Build the wheel + run: scripts/install-ci-maturin.sh build macos-arm64 --profile dist --locked --out dist --manifest-path crates/coreav-py/Cargo.toml + - name: Keep CI build glue out of the wheel + run: scripts/install-ci-maturin.sh verify-artifacts macos-arm64 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: fixtures diff --git a/.github/workflows/videotoolbox.yml b/.github/workflows/videotoolbox.yml index 1846fc2a5..51636dd9a 100644 --- a/.github/workflows/videotoolbox.yml +++ b/.github/workflows/videotoolbox.yml @@ -15,6 +15,11 @@ on: - Cargo.toml - deny.toml - scripts/encoders/macos/** + - scripts/ci-tools.env + - scripts/fetch-ci-archive.sh + - scripts/install-ci-check-tool.sh + - .github/actions/ci-archive-cache/action.yml + - .github/actions/ci-check-tool/action.yml - scripts/install-oracle-ffmpeg.sh - scripts/install-ci-oracle-ffmpeg.sh - .github/actions/oracle-cache/action.yml @@ -37,6 +42,11 @@ on: - Cargo.toml - deny.toml - scripts/encoders/macos/** + - scripts/ci-tools.env + - scripts/fetch-ci-archive.sh + - scripts/install-ci-check-tool.sh + - .github/actions/ci-archive-cache/action.yml + - .github/actions/ci-check-tool/action.yml - scripts/install-oracle-ffmpeg.sh - scripts/install-ci-oracle-ffmpeg.sh - .github/actions/oracle-cache/action.yml @@ -127,12 +137,11 @@ jobs: toolchain: stable - name: Build the independent Rust decoder and error calculator run: cargo build --locked --manifest-path crates/coreav-backend-apple/Cargo.toml --profile dist --example videotoolbox - - name: Check the standalone dependency licenses - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + - uses: ./.github/actions/ci-check-tool with: - manifest-path: crates/coreav-backend-apple/Cargo.toml - command: check licenses - rust-version: stable + tool: cargo-deny + - name: Check the standalone dependency licenses + run: CARGO_TERM_COLOR=always cargo-deny --log-level warn --manifest-path crates/coreav-backend-apple/Cargo.toml --all-features check licenses - name: Test Linux evidence replay and child cleanup run: python3 -m unittest discover -s scripts/encoders/macos -p 'test_*.py' -v - uses: ./.github/actions/oracle-cache diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 8b43f39e6..c5bbcb2fa 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -39,6 +39,10 @@ on: - scripts/oracle-audio-args.txt - scripts/binaryen.env - scripts/install-binaryen.sh + - scripts/install-ci-binaryen.sh + - scripts/fetch-ci-archive.sh + - scripts/tests/test_ci_reference_downloads.py + - .github/actions/ci-archive-cache/action.yml - scripts/rust-dist.env - scripts/notices/** - deny.toml @@ -80,6 +84,10 @@ on: - scripts/oracle-audio-args.txt - scripts/binaryen.env - scripts/install-binaryen.sh + - scripts/install-ci-binaryen.sh + - scripts/fetch-ci-archive.sh + - scripts/tests/test_ci_reference_downloads.py + - .github/actions/ci-archive-cache/action.yml - scripts/rust-dist.env - scripts/notices/** - deny.toml @@ -109,6 +117,7 @@ jobs: - run: npm test --prefix packages/wasm && npm run test:types --prefix packages/wasm - run: npm test --prefix packages/wasm-h264 && npm run test:types --prefix packages/wasm-h264 - run: python3 -m unittest discover -s scripts/tests -p test_wasm_thumbnail_fixtures.py + - run: python3 -I -m unittest discover -s scripts/tests -p 'test_ci_reference_downloads.py' -v - run: npm ci --ignore-scripts --no-audit --no-fund --prefix packages/wasm/examples - run: npm test --prefix packages/wasm/examples @@ -185,10 +194,21 @@ jobs: run: | npm ci --ignore-scripts --no-audit --no-fund --prefix packages/wasm npm ci --ignore-scripts --no-audit --no-fund --prefix packages/wasm/examples + - name: Read the optimizer archive pin + id: binaryen + run: | + . scripts/binaryen.env + echo "version=$BINARYEN_VERSION" >> "$GITHUB_OUTPUT" + echo "sha256=$BINARYEN_SHA256_X86_64_LINUX" >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/ci-archive-cache + with: + tool: binaryen + version: ${{ steps.binaryen.outputs.version }} + sha256: ${{ steps.binaryen.outputs.sha256 }} - name: Install the pinned optimizer run: | set -euo pipefail - opt="$(scripts/install-binaryen.sh "$RUNNER_TEMP/binaryen")" + opt="$(bash scripts/install-ci-binaryen.sh "$RUNNER_TEMP/binaryen")" echo "WASM_OPT=$opt" >> "$GITHUB_ENV" - name: Build the module run: npm run build --prefix packages/wasm diff --git a/docs/agents/ci.md b/docs/agents/ci.md index b54aebe1f..7657b4238 100644 --- a/docs/agents/ci.md +++ b/docs/agents/ci.md @@ -95,4 +95,6 @@ List development caches with `docker volume ls --filter name=coreav-dev-`. After | A change of an oracle platform's pin or installer starts every workflow that runs that installer | `scripts/workflow_check.py` (`deny.yml`) checks the platform-specific input lists, including the macOS verifier and its frozen Linux imports. `test_macos_installers_track_only_their_own_required_inputs` checks each macOS input and nested shell calls. A job-level filter (`changed-paths`) is review judgment | | Fixture cache identities and fixture-consuming workflow triggers include every shared qualification support input, not only the directly called script | The existing `test_oracle.SUPPORT` owner supplies both cache keys; `test_both_fixture_cache_keys_bind_every_owner_support_file` executes both key blocks, changes every support file and rejects missing inputs. `test_fixture_producers_compute_the_same_cache_key` executes the CI and WASM producers and requires identical keys. `test_all_fixture_support_changes_start_both_wasm_events` and `test_release_and_avi_triggers_cover_qualification_and_fixture_inputs` check the WASM, release and AVI filters against that owner and the generators they run. Workflow path filters cannot consume a Python owner at parse time, so checks prevent drift | | Current local actions provision the oracle from the shared verified archive even when neighboring binary caches hit | The shared archive action and CI installer own acquisition and verification; `test_local_actions_cannot_keep_the_frozen_installer` scans every local action, rejects the frozen installer and requires matching cache/install conditions without a binary-cache bypass (`deny.yml`). Workflow shell commands cannot be constrained by a Rust type | +| CI tool archive caches are advisory and never authorize execution without a digest check, including cache hits | `fetch-ci-archive.sh` owns staging, bounded retries, checksum verification and atomic cache publication; `ci-archive-cache` binds tool, version and digest without fallback keys. `test_ci_downloads.py` checks real transient HTTP recovery, corrupt and unavailable caches, refused downloads, fresh installation and workflow wiring. External network and file contents require runtime validation rather than a type | +| The CI-only maturin bootstrap retains its upstream MIT attribution and stays out of built wheels | `test_full_upstream_license_and_source_provenance_are_retained` binds the complete notice and source revision; every wheel job runs `verify-artifacts`, whose synthetic missing-wheel and copied-glue regressions fail closed. File placement or a Rust type cannot prove an archive's contents | | The pinned comparator archive comes from an Actions cache keyed by its registered digest on every branch, only digest-verified bytes enter that cache, and the generator's own digest check still judges what it is served | `test_comparator_archive_is_cached_by_its_registered_digest_on_every_branch` checks both cache steps' key and path against the registration and that neither save is limited to main; `test_fetch_keeps_only_a_digest_verified_archive_and_installs_the_stand_in_beside_it`, `test_stand_in_serves_only_the_generators_request_for_the_registered_archive` and `test_audio_conformance_builds_the_comparator_from_the_cached_archive` execute the shell blocks and the stand-in with a stand-in curl (`deny.yml`). A YAML type cannot govern what a cache step saves | diff --git a/docs/agents/codecs.md b/docs/agents/codecs.md index ce9dcf7a6..2031c23dd 100644 --- a/docs/agents/codecs.md +++ b/docs/agents/codecs.md @@ -32,7 +32,7 @@ H.264, AAC, Opus, VP9, AC-3, MP3 and AudioToolbox behavior; HEVC decoding and pl | G1 conformance preserves the oracle's native planar format and retains process failure diagnostics, including timeouts | One shared `capture` owns conformance and measurement process records, including the decoder's streamed binary output. `test_full_range_native_pixel_format_is_not_converted` rejects wrong range, format, or geometry; failure and timeout regressions retain partial output and status, re-raising the original timeout instead of accepting it | | P-picture conformance reports decoder warnings separately from missing pictures | `examples/p.rs::run` owns the final validation and uses separate failure branches for picture counts and decoder warnings, including dropped warnings. Types cannot classify a runtime decoder report | | Small G1 pictures retain the level's access-unit envelope instead of an unregistered fraction of their raw size | `a_small_textured_picture_uses_the_level_ceiling_not_a_raw_size_fraction` encodes a one-macroblock picture above 192 NAL bytes, while preserving a caller's explicit smaller bound | -| The JM downloader supplements a missing intermediate only from the exact pinned certificate and a chain to existing roots; it never disables TLS verification | The bundle consumes a single PEM reconstructed from validated DER, not the downloaded multi-certificate input. `test_jm_bundle_drops_a_trailing_unpinned_certificate` proves an appended synthetic CA stays untrusted; `test_jm_archive_fetch_requires_pinned_and_root_verified_intermediate` refuses hash or root-validation failures before archive fetch | +| The JM downloader supplements a missing intermediate only from the exact pinned certificate and a chain to existing roots; it never disables TLS verification | The bundle consumes a single PEM reconstructed from validated DER, not the downloaded multi-certificate input. `test_jm_bundle_drops_a_trailing_unpinned_certificate` proves an appended synthetic CA stays untrusted; `test_jm_archive_fetch_requires_pinned_and_root_verified_intermediate` refuses hash or root-validation failures before archive fetch. The separate CI installer caches canonical DER and the ZIP, verifies both on every use, revalidates the root chain and builds fresh; `test_ci_reference_downloads.py` covers those paths and requires the shared bounded transport retry policy for CA acquisition while checksum and root failures stay terminal. Registered installer and pin bytes remain unchanged | | G1 VUI colour combinations obey the specification, and cached RD summaries are replayed from complete raw metric output | `unspecified_primaries_cannot_signal_derived_colour_matrices` rejects forbidden derived matrices; `test_aggregate_mutation_and_missing_metric_detail_fail` requires 60 finite frame scores, their arithmetic mean, chroma scores, and replayable process records. The conformance harness rejects CoreAV oracle diagnostics even on a zero exit | | Native packed S24 conversion keeps its single-rounding contract; FFmpeg-exact sample-format tests cover S16, S32 and F32 rather than treating PCM24 packing as an S24 sample format | `float_to_s24_rounds_once_at_the_stored_width` derives native half-LSB and clipping bytes independently; `sample_conversion_is_exact_against_the_pinned_oracle` compares complete S16/S32/F32 output bytes with the verified oracle and requires it in CI. Its local `SCALAR_CONVERSION_REFERENCE` fixes the CPU profile because the arm64 SIMD path differs at half-LSB ties; native ties-to-even, CLI comparisons and measurement profiles stay unchanged. The documentary PCM24 difference adds no acceptance predicate | | AudioToolbox native fixtures append source PCM or begin drain only at `Flow::NeedInput`, pulling pending `Flow::More` output first | The `flow == Flow::NeedInput` append branch and pre-drain assertion in `native_four_cells_drain_and_decode_with_the_independent_rust_decoder` (`coreav-backend-apple/src/audiotoolbox/tests.rs`) enforce the fixture's ordering; it runs in `encoder-backends.yml` on hosted macOS | diff --git a/docs/agents/wasm.md b/docs/agents/wasm.md index 19dd55082..c15c2649b 100644 --- a/docs/agents/wasm.md +++ b/docs/agents/wasm.md @@ -4,6 +4,7 @@ The WASM module and its HTTP source. | Rule | Enforced by | |---|---| +| CI restores only the pinned Binaryen archive, verifies it on every cache hit, and extracts a fresh optimizer instead of reusing an unchecked binary | `install-ci-binaryen.sh` delegates archive verification to `fetch-ci-archive.sh`; `test_ci_reference_downloads.py` exercises platform pins, corrupt caches, failed downloads and fresh extraction, and checks WASM and npm workflow wiring. Registered installer and pin bytes remain unchanged | | WASM memory gates read only the registered bars, reject decoded duplicate registry keys and require complete isolate-bound readings with exact harness concurrency | `registry`, `gates` and `batch` own the parser, closed gate ledger and request lifecycle; their regressions run with the example unit suite in `wasm-contract`. The module job judges the installed artifact and emits no raw readings | | A workerd startup failure identifies its own process, not successful output accumulated from earlier workers | `WorkerdDiagnostics` owns each child's stdout, stderr and startup exit snapshot. `runtime.test.mjs` runs the pinned workerd with an invalid configuration after a successful prior log and checks isolated stderr, exit, launch, signal and deadline records. The workflow wiring regression requires installation of the example lockfile before this real-runtime test. Output and status arrive at runtime, so a type cannot establish their process identity; raw output remains runner-local | | Runtime diagnostics preserve split UTF-8 characters and scan for secret markers only after both output pipes close | The stream decoder owns character boundaries, and `WorkerdDiagnostics.assertOutputAbsent` owns the close-before-scan order. Split-byte and exit-before-late-output regressions in `runtime.test.mjs` check both streams and require a closed, non-echoing failure for a late marker. External stream completion and encoding cannot be established by a string type | diff --git a/scripts/ci-tools.env b/scripts/ci-tools.env new file mode 100644 index 000000000..a27ec473f --- /dev/null +++ b/scripts/ci-tools.env @@ -0,0 +1,4 @@ +CARGO_DENY_VERSION=0.20.2 +CARGO_DENY_SHA256=9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f +ACTIONLINT_VERSION=1.7.12 +ACTIONLINT_SHA256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 diff --git a/scripts/fetch-ci-archive.sh b/scripts/fetch-ci-archive.sh new file mode 100755 index 000000000..b9fe86518 --- /dev/null +++ b/scripts/fetch-ci-archive.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ $# -lt 4 ]]; then + echo 'usage: fetch-ci-archive.sh URL SHA256 OUTPUT CACHE [CURL_OPTIONS...]' >&2 + exit 2 +fi +URL=$1 +DIGEST=$2 +OUTPUT=$3 +CACHE=$4 +shift 4 +if [[ ! "$DIGEST" =~ ^[0-9a-f]{64}$ ]]; then + echo 'reason=ci_archive_invalid_digest' >&2 + exit 2 +fi +WORK=$(mktemp -d "${TMPDIR:-/tmp}/coreav-ci-archive.XXXXXX") +PART='' +trap 'rm -rf "$WORK"; if [[ -n "$PART" ]]; then rm -f "$PART" || true; fi' EXIT +ARCHIVE="$WORK/archive" +pinned() { + printf '%s %s\n' "$DIGEST" "$ARCHIVE" | shasum -a 256 --check >&2 +} +if [[ -f "$CACHE" ]]; then + if ! { cp "$CACHE" "$ARCHIVE" && pinned; }; then + echo 'reason=ci_archive_cache_rejected' >&2 + rm -f "$ARCHIVE" + fi +fi +if [[ ! -f "$ARCHIVE" ]]; then + if ! curl --fail --silent --show-error --location --retry 8 --retry-all-errors \ + --retry-connrefused --retry-max-time 360 --connect-timeout 20 --max-time 300 \ + --speed-limit 1024 --speed-time 30 "$@" "$URL" --output "$ARCHIVE"; then + echo 'reason=ci_archive_download_unavailable' >&2 + exit 1 + fi + if ! pinned; then + echo 'reason=ci_archive_digest_mismatch' >&2 + exit 1 + fi + if ! { mkdir -p "$(dirname "$CACHE")" && PART=$(mktemp "$CACHE.XXXXXX") && + cp "$ARCHIVE" "$PART" && mv -f "$PART" "$CACHE"; }; then + echo 'reason=ci_archive_cache_write_failed' >&2 + fi +fi +mkdir -p "$(dirname "$OUTPUT")" +cp "$ARCHIVE" "$OUTPUT" diff --git a/scripts/install-ci-binaryen.sh b/scripts/install-ci-binaryen.sh new file mode 100755 index 000000000..166b69b5a --- /dev/null +++ b/scripts/install-ci-binaryen.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +. "$here/binaryen.env" +if [ "$#" -ne 1 ]; then + echo "usage: install-ci-binaryen.sh DIRECTORY" >&2 + exit 2 +fi +case "$(uname -sm)" in + 'Linux x86_64') platform=x86_64-linux; digest="$BINARYEN_SHA256_X86_64_LINUX" ;; + 'Darwin arm64') platform=arm64-macos; digest="$BINARYEN_SHA256_ARM64_MACOS" ;; + *) echo "error: no pinned Binaryen archive for this platform" >&2; exit 1 ;; +esac +mkdir -p "$1" +destination="$(cd "$1" && pwd)" +temporary="$(mktemp -d "$destination/.binaryen.XXXXXX")" +trap 'rm -rf "$temporary"' EXIT +cache="${COREAV_CI_ARCHIVE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/coreav-ci-archives}" +name="binaryen-version_${BINARYEN_VERSION}-${platform}.tar.gz" +bash "$here/fetch-ci-archive.sh" \ + "https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/$name" \ + "$digest" "$temporary/$name" "$cache/binaryen-${BINARYEN_VERSION}-${digest}.archive" +mkdir "$temporary/extracted" +tar xzf "$temporary/$name" -C "$temporary/extracted" +opt="binaryen-version_${BINARYEN_VERSION}/bin/wasm-opt" +"$temporary/extracted/$opt" --version >&2 +rm -rf "$destination/binaryen-version_${BINARYEN_VERSION}" +mv "$temporary/extracted/binaryen-version_${BINARYEN_VERSION}" "$destination/" +printf '%s\n' "$destination/$opt" diff --git a/scripts/install-ci-check-tool.sh b/scripts/install-ci-check-tool.sh new file mode 100755 index 000000000..61de50827 --- /dev/null +++ b/scripts/install-ci-check-tool.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ $# -ne 2 ]]; then + echo 'usage: install-ci-check-tool.sh actionlint|cargo-deny DIRECTORY' >&2 + exit 2 +fi +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "$HERE/ci-tools.env" +TOOL=$1 +DEST=$2 +if [[ "$(uname -sm)" != 'Linux x86_64' ]]; then + echo 'reason=ci_check_tool_unsupported_platform' >&2 + exit 1 +fi +case "$TOOL" in + actionlint) + VERSION=$ACTIONLINT_VERSION + DIGEST=$ACTIONLINT_SHA256 + URL="https://github.com/rhysd/actionlint/releases/download/v$VERSION/actionlint_${VERSION}_linux_amd64.tar.gz" + MEMBER=actionlint + ;; + cargo-deny) + VERSION=$CARGO_DENY_VERSION + DIGEST=$CARGO_DENY_SHA256 + NAME="cargo-deny-$VERSION-x86_64-unknown-linux-musl" + URL="https://github.com/EmbarkStudios/cargo-deny/releases/download/$VERSION/$NAME.tar.gz" + MEMBER="$NAME/cargo-deny" + ;; + *) echo 'reason=ci_check_tool_unknown' >&2; exit 2 ;; +esac +WORK=$(mktemp -d "${TMPDIR:-/tmp}/coreav-ci-check-tool.XXXXXX") +trap 'rm -rf "$WORK"' EXIT +CACHE_DIR="${COREAV_CI_ARCHIVE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/coreav-ci-archives}" +bash "$HERE/fetch-ci-archive.sh" "$URL" "$DIGEST" "$WORK/archive" \ + "$CACHE_DIR/$TOOL-$VERSION-$DIGEST.archive" +tar -xzf "$WORK/archive" -C "$WORK" "$MEMBER" +mkdir -p "$DEST" +install -m 755 "$WORK/$MEMBER" "$DEST/$TOOL" +"$DEST/$TOOL" --version >&2 diff --git a/scripts/install-ci-jm-decoder.sh b/scripts/install-ci-jm-decoder.sh new file mode 100755 index 000000000..59e00b8b9 --- /dev/null +++ b/scripts/install-ci-jm-decoder.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail +DEST="${1:?usage: install-ci-jm-decoder.sh /absolute/bin/dir}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$here/jm-decoder.env" +mkdir -p "$DEST" +DEST=$(cd "$DEST" && pwd) +WORK=$(mktemp -d "${TMPDIR:-/tmp}/coreav-jm.XXXXXX") +CA_STAGE= +trap 'rm -rf "$WORK"; if [ -n "$CA_STAGE" ]; then rm -f "$CA_STAGE"; fi' EXIT +ROOTS="${SSL_CERT_FILE:-/etc/ssl/certs/ca-certificates.crt}" +CACHE="${COREAV_CI_ARCHIVE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/coreav-ci-archives}" +CA_CACHE="$CACHE/jm-ca-${JM_VERSION}-${JM_CA_SHA256}.archive" +if ! { cp "$CA_CACHE" "$WORK/intermediate.der" 2>/dev/null && \ + printf '%s %s\n' "$JM_CA_SHA256" "$WORK/intermediate.der" | shasum -a 256 -c - >/dev/null 2>&1; }; then + curl --fail --silent --show-error --location --retry 8 --retry-all-errors \ + --retry-connrefused --retry-max-time 360 --connect-timeout 20 --max-time 300 \ + --speed-limit 1024 --speed-time 30 "$JM_CA_URL" -o "$WORK/intermediate.pem" + openssl x509 -in "$WORK/intermediate.pem" -outform DER -out "$WORK/intermediate.der" + printf '%s %s\n' "$JM_CA_SHA256" "$WORK/intermediate.der" | shasum -a 256 -c - >&2 +fi +openssl x509 -inform DER -in "$WORK/intermediate.der" -out "$WORK/intermediate-pinned.pem" +openssl verify -CAfile "$ROOTS" "$WORK/intermediate-pinned.pem" > /dev/null +if mkdir -p "$CACHE" 2>/dev/null && CA_STAGE=$(mktemp "$CA_CACHE.XXXXXX" 2>/dev/null); then + if cp "$WORK/intermediate.der" "$CA_STAGE" && mv -f "$CA_STAGE" "$CA_CACHE"; then + CA_STAGE= + else + echo 'JM certificate cache publication unavailable; continuing with verified certificate' >&2 + fi +fi +cat "$ROOTS" "$WORK/intermediate-pinned.pem" > "$WORK/ca.pem" +bash "$here/fetch-ci-archive.sh" "$JM_URL" "$JM_SHA256" "$WORK/jm.zip" \ + "$CACHE/jm-${JM_VERSION}-${JM_SHA256}.archive" --cacert "$WORK/ca.pem" +unzip -q "$WORK/jm.zip" -d "$WORK/source" +BUILD=$(find "$WORK/source" -path '*/ldecod/Makefile' -print -quit) +test -n "$BUILD" +if ! make -C "${BUILD%/*}" -j2 CC='cc -fcommon' > "$WORK/build.log" 2>&1; then + echo 'JM black-box decoder build failed' >&2 + python3 - "$WORK/build.log" <<'PY' +import pathlib +import sys +text = pathlib.Path(sys.argv[1]).read_text(errors='replace') +categories = {'No rule to make target': 'missing-build-target', + 'multiple definition': 'legacy-common-symbols', + 'undefined reference': 'unresolved-symbol', + 'unrecognized command-line option': 'unsupported-compiler-option', + 'No such file or directory': 'missing-build-input', + ': error:': 'compilation-error'} +print('JM build diagnostic categories: ' + ', '.join(value for key, value in categories.items() if key in text)) +PY + exit 1 +fi +BINARY=$(find "$WORK/source" -type f \( -name ldecod.exe -o -name ldecod \) -print -quit) +test -n "$BINARY" && test -x "$BINARY" +install -m 755 "$BINARY" "$DEST/ldecod" +{ + printf 'JM_VERSION=%s\nJM_SHA256=%s\n' "$JM_VERSION" "$JM_SHA256" + printf "BUILD_COMMAND=make -C JM/ldecod -j2 CC='cc -fcommon'\n" + cc --version | head -1 + sha256sum "$DEST/ldecod" +} > "$DEST/jm-build.txt" diff --git a/scripts/install-ci-maturin.sh b/scripts/install-ci-maturin.sh new file mode 100755 index 000000000..2a3e37db2 --- /dev/null +++ b/scripts/install-ci-maturin.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# MIT License +# +# Copyright (c) 2021-present PyO3 Project and Contributors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Upstream: https://github.com/PyO3/maturin-action +# Revision: e83996d129638aa358a18fbd1dfb82f0b0fb5d3b +# Ported: image/target mappings, Rust/uv and Python setup, patchelf/cffi, +# target checks, environment prefixes and macOS SDK defaults. +# New: pinned wheel acquisition, verified extraction, mode control, +# verified-executable transport and regression coverage. +# CI-only build glue; not an input to shipped packages or runtime notices. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +source "$root/scripts/maturin.env" +mode=${1:?expected metadata, install, build or verify-artifacts} +platform=${2:?expected linux-x86_64, linux-arm64 or macos-arm64} +case "$platform" in + linux-x86_64|linux-arm64) + url=$MATURIN_LINUX_URL + sha256=$MATURIN_LINUX_SHA256 + ;; + macos-arm64) + url=$MATURIN_MACOS_URL + sha256=$MATURIN_MACOS_SHA256 + ;; + *) echo "unsupported maturin platform: $platform" >&2; exit 1 ;; +esac + +if [[ "$mode" == metadata ]]; then + printf 'tool=maturin-wheel\nversion=%s\nsha256=%s\n' "$MATURIN_VERSION" "$sha256" + exit 0 +fi +if [[ "$mode" == verify-artifacts ]]; then + python3 -I - <<'PY' +from pathlib import Path +from zipfile import ZipFile + +wheels = list(Path('dist').glob('*.whl')) +if not wheels: + raise SystemExit('reason=ci_maturin_missing_wheels') +for wheel in wheels: + with ZipFile(wheel) as archive: + if any(Path(name).name == 'install-ci-maturin.sh' for name in archive.namelist()): + raise SystemExit('reason=ci_build_glue_in_wheel') +PY + exit 0 +fi +if [[ "$mode" == build ]]; then + shift 2 + temporary=$(mktemp -d "${RUNNER_TEMP:?}/coreav-maturin-build.XXXXXX") + trap 'rm -rf "$temporary"' EXIT + "$root/scripts/install-ci-maturin.sh" install "$platform" "$temporary" + toolchain=$(python3 -I -c 'import sys, tomllib; print(tomllib.load(open(sys.argv[1], "rb"))["toolchain"]["channel"])' "$root/rust-toolchain.toml") + export CARGO_INCREMENTAL=${CARGO_INCREMENTAL-0} + export CARGO_TERM_COLOR=${CARGO_TERM_COLOR-always} + cd "${GITHUB_WORKSPACE:?}" + if [[ "$platform" == macos-arm64 ]]; then + rustup update --no-self-update "$toolchain" + rustup override set "$toolchain" + rustup component add llvm-tools-preview || true + rustup target add --toolchain "$toolchain" aarch64-apple-darwin + python3 -m pip install cffi || true + export DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} + export SDKROOT=${SDKROOT:-/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk} + "$temporary/maturin" build --target aarch64-apple-darwin "$@" + exit 0 + fi + case "$platform" in + linux-x86_64) + image=quay.io/pypa/manylinux2014_x86_64:latest + target=x86_64-unknown-linux-gnu + manylinux=auto + ;; + linux-arm64) + image=ghcr.io/rust-cross/manylinux_2_28-cross:aarch64 + target=aarch64-unknown-linux-gnu + manylinux=2_28 + ;; + esac + docker_env=(-e DEBIAN_FRONTEND=noninteractive -e ARCHFLAGS -e _PYTHON_SYSCONFIGDATA_NAME) + while IFS= read -r name; do + case "$name" in + CARGO_HOME) ;; + ACTIONS_*|CARGO_*|CC*|CFLAGS*|CMAKE_*|CPPFLAGS*|CXX*|CXXFLAGS*|GITHUB_*|JEMALLOC_*|LDFLAGS*|LD_*|MATURIN_*|PYO3_*|RUST*|SCCACHE_*|TARGET_*) + docker_env+=(-e "$name") ;; + esac + done < <(compgen -e) + docker run --rm --interactive --workdir "$GITHUB_WORKSPACE" "${docker_env[@]}" \ + -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE" \ + -v "$temporary:/opt/coreav-maturin:ro" \ + "$image" bash -s -- "$toolchain" "$target" "$manylinux" "$@" <<'SH' +set -euo pipefail +toolchain=$1 +target=$2 +manylinux=$3 +shift 3 +if command -v rustup >/dev/null; then + rm -rf "$HOME/.rustup/toolchains/" + rustup toolchain install stable +else + curl --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal +fi +export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" +rustup update --no-self-update "$toolchain" +rustup override set "$toolchain" +rustup component add llvm-tools-preview || true +export PATH="$PATH:/opt/python/cp37-cp37m/bin:/opt/python/cp38-cp38/bin:/opt/python/cp39-cp39/bin:/opt/python/cp310-cp310/bin:/opt/python/cp311-cp311/bin:/opt/python/cp312-cp312/bin" +command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh +command -v patchelf >/dev/null || uv tool install patchelf +python3 -m pip install --user cffi || true +if [[ ! -d $(rustc --print target-libdir --target "$target") ]]; then + rustup target add "$target" +fi +if [[ "$manylinux" != auto ]]; then + set -- --manylinux "$manylinux" "$@" +fi +/opt/coreav-maturin/maturin build --target "$target" "$@" +SH + sudo chown -R "$(id -u):$(id -g)" "$root/target" "$root/dist" || true + exit 0 +fi +if [[ "$mode" != install ]]; then + echo "expected metadata, install, build or verify-artifacts" >&2 + exit 1 +fi + +destination=${3:?expected installation directory} +cache_dir=${COREAV_CI_ARCHIVE_DIR:-${RUNNER_TEMP:?}/coreav-ci-archives} +mkdir -p "$destination" +temporary=$(mktemp -d "${RUNNER_TEMP:?}/coreav-maturin.XXXXXX") +trap 'rm -rf "$temporary"' EXIT +"$root/scripts/fetch-ci-archive.sh" "$url" "$sha256" "$temporary/maturin.whl" \ + "$cache_dir/maturin-wheel-$MATURIN_VERSION-$sha256.archive" +python3 -I - "$temporary/maturin.whl" "$temporary/maturin" "$MATURIN_VERSION" <<'PY' +import pathlib +import sys +import zipfile + +wheel, output, version = sys.argv[1:] +with zipfile.ZipFile(wheel) as archive: + pathlib.Path(output).write_bytes(archive.read(f"maturin-{version}.data/scripts/maturin")) +PY +chmod 755 "$temporary/maturin" +mv -f "$temporary/maturin" "$destination/maturin" diff --git a/scripts/maturin.env b/scripts/maturin.env new file mode 100644 index 000000000..5b7110de4 --- /dev/null +++ b/scripts/maturin.env @@ -0,0 +1,5 @@ +MATURIN_VERSION=1.16.0 +MATURIN_LINUX_URL=https://files.pythonhosted.org/packages/0a/dc/e77f490557cb5e045a75c3d18a7965425ac576235c970dfff4d903eb2c74/maturin-1.16.0-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.musllinux_1_1_x86_64.whl +MATURIN_LINUX_SHA256=837db2f4d93d4899c3ac393fb3cc9eebf35b6002336e5d574f4a4fb6c49fe283 +MATURIN_MACOS_URL=https://files.pythonhosted.org/packages/fb/d5/f49a90d8c489dc5f134654ec91d12a78a51599fdc8c2b5a1bc2391bca0ac/maturin-1.16.0-py3-none-macosx_10_12_universal2.macosx_10_12_x86_64.macosx_11_0_arm64.whl +MATURIN_MACOS_SHA256=131fcc17ddb3ccc48aa183d2172ebf994c322b858e1b9694fb8744d783df66f6 diff --git a/scripts/tests/test_ci_downloads.py b/scripts/tests/test_ci_downloads.py new file mode 100644 index 000000000..aebae80b3 --- /dev/null +++ b/scripts/tests/test_ci_downloads.py @@ -0,0 +1,236 @@ +import hashlib +import http.server +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import threading +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +def yaml(path): + return json.loads(subprocess.check_output( + ['ruby', '-ryaml', '-rjson', '-e', + 'puts JSON.generate(YAML.load_file(ARGV[0], aliases: true))', str(path)])) + + +class VerifiedDownload(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.body = b'verified synthetic archive\n' + self.digest = hashlib.sha256(self.body).hexdigest() + self.statuses = [] + self.requests = [] + owner = self + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + status = owner.statuses.pop(0) if owner.statuses else 200 + owner.requests.append(status) + self.send_response(status) + self.end_headers() + self.wfile.write(owner.body if status == 200 else b'unavailable') + + def log_message(self, *_args): + pass + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def stop(): + server.shutdown() + server.server_close() + thread.join() + + self.addCleanup(stop) + self.url = f'http://127.0.0.1:{server.server_port}/archive' + self.output = self.root / 'output' + self.cache = self.root / 'cache' / 'archive' + self.env = {'PATH': os.environ['PATH'], 'HOME': str(self.root), 'NO_PROXY': '127.0.0.1'} + + def fetch(self, digest=None): + return subprocess.run(['bash', str(ROOT / 'scripts/fetch-ci-archive.sh'), self.url, + digest or self.digest, str(self.output), str(self.cache)], + env=self.env, capture_output=True, text=True, timeout=30) + + def test_real_curl_recovers_from_http_503_and_504(self): + self.statuses = [503, 504, 200] + result = self.fetch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requests, [503, 504, 200]) + self.assertEqual(self.output.read_bytes(), self.body) + self.assertEqual(self.cache.read_bytes(), self.body) + + def test_cache_hit_rechecks_bytes_without_network(self): + self.cache.parent.mkdir() + self.cache.write_bytes(self.body) + self.output.write_bytes(b'unverified prior output') + self.statuses = [404] + result = self.fetch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.output.read_bytes(), self.body) + self.assertEqual(self.requests, []) + + def test_corrupt_cache_downloads_and_verifies_a_replacement(self): + self.cache.parent.mkdir() + self.cache.write_bytes(b'corrupt cache') + result = self.fetch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('ci_archive_cache_rejected', result.stderr) + self.assertEqual(self.requests, [200]) + self.assertEqual(self.output.read_bytes(), self.body) + self.assertEqual(self.cache.read_bytes(), self.body) + + def test_mismatched_download_is_neither_published_nor_installed(self): + result = self.fetch('0' * 64) + self.assertNotEqual(result.returncode, 0) + self.assertIn('reason=ci_archive_digest_mismatch', result.stderr) + self.assertFalse(self.output.exists()) + self.assertFalse(self.cache.exists()) + + def test_unwritable_cache_is_advisory(self): + self.cache.parent.write_text('not a directory') + result = self.fetch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.output.read_bytes(), self.body) + self.assertIn('ci_archive_cache_write_failed', result.stderr) + + def test_failed_cache_publication_cleans_the_partial_file(self): + tools = self.root / 'tools' + tools.mkdir() + (tools / 'mv').write_text('#!/bin/sh\nexit 1\n') + (tools / 'mv').chmod(0o755) + self.env['PATH'] = str(tools) + os.pathsep + self.env['PATH'] + result = self.fetch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.output.read_bytes(), self.body) + self.assertEqual(list(self.cache.parent.iterdir()), []) + + def test_failed_transfer_has_finite_retry_and_stall_limits(self): + tools = self.root / 'tools' + tools.mkdir() + calls = self.root / 'calls' + (tools / 'curl').write_text( + '#!/usr/bin/env python3\nimport json, sys\n' + f'open({str(calls)!r}, "w").write(json.dumps(sys.argv[1:]))\n' + 'sys.exit(22)\n') + (tools / 'curl').chmod(0o755) + self.env['PATH'] = str(tools) + os.pathsep + self.env['PATH'] + result = self.fetch() + self.assertNotEqual(result.returncode, 0) + self.assertIn('ci_archive_download_unavailable', result.stderr) + arguments = json.loads(calls.read_text()) + for flag, value in (('--retry', '8'), ('--retry-max-time', '360'), + ('--max-time', '300'), ('--connect-timeout', '20'), + ('--speed-limit', '1024'), ('--speed-time', '30')): + self.assertEqual(arguments[arguments.index(flag) + 1], value) + self.assertIn('--retry-all-errors', arguments) + self.assertIn('--retry-connrefused', arguments) + self.assertFalse(self.output.exists()) + + +class CheckToolInstallation(unittest.TestCase): + def test_both_tools_reinstall_from_verified_archives(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + scripts = root / 'scripts' + scripts.mkdir() + for name in ('fetch-ci-archive.sh', 'install-ci-check-tool.sh'): + shutil.copyfile(ROOT / 'scripts' / name, scripts / name) + tools = root / 'tools' + tools.mkdir() + (tools / 'uname').write_text('#!/bin/sh\necho "Linux x86_64"\n') + (tools / 'uname').chmod(0o755) + cache = root / 'cache' + cache.mkdir() + pins = {} + bodies = {} + for tool, prefix, member in (('actionlint', 'ACTIONLINT', 'actionlint'), + ('cargo-deny', 'CARGO_DENY', 'cargo-deny-1.2.3-x86_64-unknown-linux-musl/cargo-deny')): + program = b'#!/bin/sh\necho "1.2.3"\n' + data = io.BytesIO() + with tarfile.open(fileobj=data, mode='w:gz') as archive: + item = tarfile.TarInfo(member) + item.size, item.mode = len(program), 0o755 + archive.addfile(item, io.BytesIO(program)) + digest = hashlib.sha256(data.getvalue()).hexdigest() + pins[prefix + '_VERSION'] = '1.2.3' + pins[prefix + '_SHA256'] = digest + (cache / f'{tool}-1.2.3-{digest}.archive').write_bytes(data.getvalue()) + bodies[tool] = program + (scripts / 'ci-tools.env').write_text(''.join(f'{key}={value}\n' for key, value in pins.items())) + env = {'PATH': str(tools) + os.pathsep + os.environ['PATH'], + 'COREAV_CI_ARCHIVE_DIR': str(cache), 'HOME': str(root)} + for tool, expected in bodies.items(): + with self.subTest(tool=tool): + dest = root / 'bin' + dest.mkdir(exist_ok=True) + (dest / tool).write_bytes(b'unverified loose binary') + result = subprocess.run(['bash', str(scripts / 'install-ci-check-tool.sh'), tool, str(dest)], + env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((dest / tool).read_bytes(), expected) + + +class DownloadWorkflowWiring(unittest.TestCase): + def test_deny_names_every_new_download_regression_suite(self): + steps = yaml(ROOT / '.github/workflows/deny.yml')['jobs']['deny-checks']['steps'] + run = '\n'.join(step.get('run', '') for step in steps) + for name in ('test_ci_downloads.py', 'test_ci_reference_downloads.py', 'test_maturin_downloads.py'): + self.assertIn(name, run) + self.assertIn('python3 -I -m unittest discover -s scripts/tests -p "$suite" -v', run) + + def test_cache_identity_is_version_and_digest_without_fallback_keys(self): + action = ROOT / '.github/actions/ci-archive-cache' + read, cache = yaml(action / 'action.yml')['runs']['steps'] + self.assertEqual(cache['with']['key'], + 'ci-tool-archive-${{ inputs.tool }}-${{ inputs.version }}-${{ inputs.sha256 }}') + self.assertNotIn('restore-keys', cache['with']) + with tempfile.TemporaryDirectory() as directory: + output, env = Path(directory) / 'output', Path(directory) / 'env' + result = subprocess.run(['bash', '-e', '-c', read['run']], env=os.environ | { + 'TOOL': 'tool', 'VERSION': '1.2.3', 'SHA256': 'a' * 64, 'RUNNER_TEMP': directory, + 'GITHUB_OUTPUT': str(output), 'GITHUB_ENV': str(env)}, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(), f'path={directory}/coreav-ci-archives/tool-1.2.3-{"a" * 64}.archive\n') + self.assertEqual(env.read_text(), f'COREAV_CI_ARCHIVE_DIR={directory}/coreav-ci-archives\n') + + def test_check_tool_actions_never_skip_installation_on_cache_hits(self): + steps = yaml(ROOT / '.github/actions/ci-check-tool/action.yml')['runs']['steps'] + cache, = [i for i, step in enumerate(steps) if step.get('uses') == './.github/actions/ci-archive-cache'] + install, = [i for i, step in enumerate(steps) if 'install-ci-check-tool.sh' in step.get('run', '')] + self.assertLess(cache, install) + self.assertNotIn('if', steps[install]) + deny = yaml(ROOT / '.github/workflows/deny.yml')['jobs']['deny-checks']['steps'] + for tool in ('actionlint', 'cargo-deny'): + self.assertTrue(any(step.get('uses') == './.github/actions/ci-check-tool' and + step.get('with', {}).get('tool') == tool for step in deny)) + self.assertFalse(any(step.get('uses', '').startswith('EmbarkStudios/cargo-deny-action@') for step in deny)) + policy, = [step for step in deny if step.get('name') == 'Check the workspace dependency policy'] + self.assertIn('cargo-deny --log-level warn --manifest-path ./Cargo.toml --all-features check', policy['run']) + + def test_standalone_licenses_use_the_same_pinned_tool_and_trigger_inputs(self): + workflow = yaml(ROOT / '.github/workflows/videotoolbox.yml') + steps = workflow['jobs']['verify']['steps'] + self.assertTrue(any(step.get('uses') == './.github/actions/ci-check-tool' and + step.get('with', {}).get('tool') == 'cargo-deny' for step in steps)) + check, = [step for step in steps if step.get('name') == 'Check the standalone dependency licenses'] + self.assertIn('--manifest-path crates/coreav-backend-apple/Cargo.toml --all-features check licenses', check['run']) + triggers = workflow.get('on', workflow.get('true')) + for event in ('push', 'pull_request'): + for path in ('scripts/ci-tools.env', 'scripts/fetch-ci-archive.sh', 'scripts/install-ci-check-tool.sh', + '.github/actions/ci-archive-cache/action.yml', '.github/actions/ci-check-tool/action.yml'): + self.assertIn(path, triggers[event]['paths']) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_ci_reference_downloads.py b/scripts/tests/test_ci_reference_downloads.py new file mode 100644 index 000000000..20396ef4a --- /dev/null +++ b/scripts/tests/test_ci_reference_downloads.py @@ -0,0 +1,388 @@ +import hashlib +import io +import json +import os +import pathlib +import re +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +import textwrap +import unittest +import zipfile + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +class ReferenceDownloads(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.certificates = tempfile.TemporaryDirectory() + cls.certs = pathlib.Path(cls.certificates.name) + for name in ('root', 'extra'): + cls.openssl('req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', + '-subj', f'/CN=Synthetic-{name}', '-keyout', str(cls.certs / f'{name}.key'), + '-out', str(cls.certs / f'{name}.pem')) + cls.openssl('req', '-newkey', 'rsa:2048', '-nodes', '-subj', '/CN=Synthetic-intermediate', + '-keyout', str(cls.certs / 'intermediate.key'), '-out', str(cls.certs / 'intermediate.csr')) + (cls.certs / 'extensions').write_text('basicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n') + cls.openssl('x509', '-req', '-in', str(cls.certs / 'intermediate.csr'), + '-CA', str(cls.certs / 'root.pem'), '-CAkey', str(cls.certs / 'root.key'), + '-set_serial', '2', '-days', '1', '-extfile', str(cls.certs / 'extensions'), + '-out', str(cls.certs / 'intermediate.pem')) + cls.der = cls.openssl('x509', '-in', str(cls.certs / 'intermediate.pem'), '-outform', 'DER').stdout + + @classmethod + def tearDownClass(cls): + cls.certificates.cleanup() + + @staticmethod + def openssl(*args): + return subprocess.run(['openssl', *args], check=True, capture_output=True, timeout=30) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = pathlib.Path(self.temp.name) + self.scripts = self.root / 'scripts' + self.scripts.mkdir() + self.commands = self.root / 'commands' + self.commands.mkdir() + self.cache = self.root / 'cache' + self.cache.mkdir() + self.dest = self.root / 'installed' + self.ca_digest = hashlib.sha256(self.der).hexdigest() + self.ca_input = self.root / 'download.pem' + self.ca_input.write_bytes((self.certs / 'intermediate.pem').read_bytes() + (self.certs / 'extra.pem').read_bytes()) + self.env = dict(os.environ, PATH=f'{self.commands}:{os.environ["PATH"]}', + COREAV_CI_ARCHIVE_DIR=str(self.cache), SSL_CERT_FILE=str(self.certs / 'root.pem'), + CA_INPUT=str(self.ca_input), CALLS=str(self.root / 'calls'), + CAPTURE=str(self.root / 'bundle.pem'), PYTHON=sys.executable, + FAKE_PLATFORM='Linux x86_64') + for name in ('install-ci-binaryen.sh', 'install-ci-jm-decoder.sh'): + shutil.copyfile(ROOT / 'scripts' / name, self.scripts / name) + self.command('uname', '#!/bin/bash\nprintf "%s\\n" "$FAKE_PLATFORM"\n') + self.command('curl', '''#!/bin/bash +printf 'curl %s\n' "$*" >> "$CALLS" +if [ "${CA_CURL_EXIT:-0}" != 0 ]; then exit "$CA_CURL_EXIT"; fi +while [ "$1" != -o ]; do shift; done +cp "$CA_INPUT" "$2" +''') + self.command('make', '''#!/bin/bash +printf 'make %s\n' "$*" >> "$CALLS" +test "$1" = -C && test "$3" = -j2 && test "$4" = 'CC=cc -fcommon' || exit 61 +printf '#!/bin/bash\nexit 0\n' > "$2/ldecod" +chmod +x "$2/ldecod" +''') + self.command('cc', '#!/bin/bash\necho synthetic-compiler\n') + self.command('sha256sum', '#!/bin/bash\nexec shasum -a 256 "$@"\n') + (self.scripts / 'fetch-ci-archive.sh').write_text('''#!/bin/bash +exec "$PYTHON" - "$@" <<'PY' +import hashlib, json, os, pathlib, sys +url, digest, output, cache, *args = sys.argv[1:] +with open(os.environ['CALLS'], 'a') as log: + log.write('fetch ' + json.dumps([url, digest, output, cache, *args]) + '\\n') +if args: + assert args[0] == '--cacert' + pathlib.Path(os.environ['CAPTURE']).write_bytes(pathlib.Path(args[1]).read_bytes()) +if os.environ.get('FETCH_EXIT'): + sys.exit(int(os.environ['FETCH_EXIT'])) +cached = pathlib.Path(cache) +data = cached.read_bytes() if cached.is_file() else b'' +if hashlib.sha256(data).hexdigest() != digest: + data = pathlib.Path(os.environ['ARCHIVE_INPUT']).read_bytes() +if hashlib.sha256(data).hexdigest() != digest: + sys.exit(41) +pathlib.Path(output).write_bytes(data) +try: + cached.parent.mkdir(parents=True, exist_ok=True) + cached.write_bytes(data) +except OSError: + pass +PY +''') + + def command(self, name, text): + target = self.commands / name + target.write_text(text) + target.chmod(0o755) + + def calls(self): + path = self.root / 'calls' + return path.read_text() if path.exists() else '' + + def use_real_helper(self): + shutil.copyfile(ROOT / 'scripts/fetch-ci-archive.sh', self.scripts / 'fetch-ci-archive.sh') + self.command('curl', '''#!/bin/bash +printf 'curl %s\n' "$*" >> "$CALLS" +input="$CA_INPUT" +while [ $# -gt 0 ]; do + case "$1" in + *releases/download/*|*/jm.zip) input="$ARCHIVE_INPUT" ;; + -o|--output) output="$2"; shift ;; + --cacert) cp "$2" "$CAPTURE"; shift ;; + esac + shift +done +cp "$input" "$output" +''') + + def run_installer(self, name, expected=0): + result = subprocess.run(['bash', str(self.scripts / f'install-ci-{name}.sh'), str(self.dest)], + env=self.env, capture_output=True, text=True, timeout=20) + if expected == 0: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0, result.stderr) + return result + + def binaryen(self): + archive = self.root / 'binaryen.tar.gz' + with tarfile.open(archive, 'w:gz') as out: + data = b'#!/bin/bash\necho synthetic-wasm-opt\n' + entry = tarfile.TarInfo('binaryen-version_synthetic/bin/wasm-opt') + entry.size = len(data) + entry.mode = 0o755 + out.addfile(entry, io.BytesIO(data)) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + (self.scripts / 'binaryen.env').write_text(f'BINARYEN_VERSION=synthetic\nBINARYEN_SHA256_X86_64_LINUX={digest}\nBINARYEN_SHA256_ARM64_MACOS={digest}\n') + self.env['ARCHIVE_INPUT'] = str(archive) + return archive, self.cache / f'binaryen-synthetic-{digest}.archive' + + def jm(self): + archive = self.root / 'jm.zip' + with zipfile.ZipFile(archive, 'w') as out: + out.writestr('synthetic/ldecod/Makefile', '') + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + (self.scripts / 'jm-decoder.env').write_text(f'JM_VERSION=synthetic\nJM_URL=https://synthetic.invalid/jm.zip\nJM_SHA256={digest}\nJM_CA_URL=https://synthetic.invalid/intermediate.pem\nJM_CA_SHA256={self.ca_digest}\n') + self.env['ARCHIVE_INPUT'] = str(archive) + return archive, self.cache / f'jm-synthetic-{digest}.archive' + + def test_binaryen_delegates_both_platform_pins_and_extracts_fresh_on_hits(self): + archive, cache = self.binaryen() + for platform, suffix in [('Linux x86_64', 'x86_64-linux'), ('Darwin arm64', 'arm64-macos')]: + with self.subTest(platform=platform): + self.env['FAKE_PLATFORM'] = platform + result = self.run_installer('binaryen') + opt = pathlib.Path(result.stdout.strip()) + self.assertTrue(opt.is_file()) + self.assertIn(f'binaryen-version_synthetic-{suffix}.tar.gz', self.calls()) + stale = opt.parent / 'stale' + stale.write_text('not in the archive') + archive.unlink() + self.run_installer('binaryen') + self.assertFalse(stale.exists()) + archive.write_bytes(cache.read_bytes()) + + def test_binaryen_corrupt_cache_is_not_extracted_and_fetch_failure_propagates(self): + archive, cache = self.binaryen() + cache.write_bytes(b'corrupt cached archive') + self.run_installer('binaryen') + self.assertEqual(cache.read_bytes(), archive.read_bytes()) + cache.write_bytes(b'corrupt') + archive.write_bytes(b'wrong download') + self.run_installer('binaryen', expected=1) + self.env['FETCH_EXIT'] = '37' + self.assertEqual(self.run_installer('binaryen', expected=1).returncode, 37) + + def test_binaryen_unsupported_platform_refuses_before_fetch(self): + self.binaryen() + self.env['FAKE_PLATFORM'] = 'Linux aarch64' + self.run_installer('binaryen', expected=1) + self.assertEqual(self.calls(), '') + + def test_binaryen_real_helper_rechecks_hits_and_refuses_corrupt_downloads(self): + archive, cache = self.binaryen() + self.use_real_helper() + self.run_installer('binaryen') + archive.unlink() + self.run_installer('binaryen') + self.assertEqual(self.calls().count('curl '), 1) + cache.write_bytes(b'corrupt cache') + archive.write_bytes(b'wrong download') + result = self.run_installer('binaryen', expected=1) + self.assertIn('reason=ci_archive_digest_mismatch', result.stderr) + self.assertEqual(self.calls().count('curl '), 2) + + def test_jm_caches_der_and_rebuilds_fresh_on_both_cache_hits(self): + archive, cache = self.jm() + self.run_installer('jm-decoder') + ca_cache = self.cache / f'jm-ca-synthetic-{self.ca_digest}.archive' + self.assertEqual(ca_cache.read_bytes(), self.der) + self.assertEqual(cache.read_bytes(), archive.read_bytes()) + self.ca_input.unlink() + archive.unlink() + (self.dest / 'ldecod').write_text('stale binary') + self.run_installer('jm-decoder') + self.assertNotEqual((self.dest / 'ldecod').read_text(), 'stale binary') + calls = self.calls() + self.assertEqual(calls.count('curl '), 1) + self.assertEqual(calls.count('make '), 2) + self.assertIn('--cacert', calls) + self.assertNotIn('--insecure', calls) + self.assertIn("BUILD_COMMAND=make -C JM/ldecod -j2 CC='cc -fcommon'", (self.dest / 'jm-build.txt').read_text()) + + def test_jm_ca_transport_uses_the_shared_bounded_retry_policy(self): + self.jm() + self.run_installer('jm-decoder') + arguments = shlex.split(self.calls().splitlines()[0]) + expected = ['curl', '--fail', '--silent', '--show-error', '--location', '--retry', '8', + '--retry-all-errors', '--retry-connrefused', '--retry-max-time', '360', + '--connect-timeout', '20', '--max-time', '300', '--speed-limit', '1024', '--speed-time', '30'] + self.assertEqual(arguments[:len(expected)], expected) + + def test_jm_corrupt_der_cache_is_refetched_before_archive(self): + self.jm() + ca_cache = self.cache / f'jm-ca-synthetic-{self.ca_digest}.archive' + ca_cache.write_bytes(b'corrupt certificate') + self.run_installer('jm-decoder') + self.assertEqual(ca_cache.read_bytes(), self.der) + self.assertLess(self.calls().index('curl '), self.calls().index('fetch ')) + + def test_jm_real_helper_rechecks_zip_hits_and_refuses_corrupt_downloads(self): + archive, cache = self.jm() + self.use_real_helper() + self.run_installer('jm-decoder') + archive.unlink() + self.ca_input.unlink() + self.run_installer('jm-decoder') + self.assertEqual(self.calls().count('curl '), 2) + self.assertEqual(self.calls().count('make '), 2) + cache.write_bytes(b'corrupt ZIP cache') + archive.write_bytes(b'wrong ZIP download') + result = self.run_installer('jm-decoder', expected=1) + self.assertIn('reason=ci_archive_digest_mismatch', result.stderr) + self.assertEqual(self.calls().count('make '), 2) + self.assertIn('--cacert', self.calls()) + + def test_jm_wrong_certificate_pin_refuses_before_archive_or_publication(self): + self.jm() + self.ca_input.write_bytes((self.certs / 'extra.pem').read_bytes()) + self.run_installer('jm-decoder', expected=1) + self.assertNotIn('fetch ', self.calls()) + self.assertEqual(self.calls().count('curl '), 1) + self.assertEqual(list(self.cache.iterdir()), []) + + def test_jm_downloaded_certificate_root_failure_is_not_retried(self): + self.jm() + self.env['SSL_CERT_FILE'] = str(self.certs / 'extra.pem') + self.run_installer('jm-decoder', expected=1) + self.assertEqual(self.calls().count('curl '), 1) + self.assertNotIn('fetch ', self.calls()) + self.assertEqual(list(self.cache.iterdir()), []) + + def test_jm_cached_certificate_still_requires_existing_root_chain(self): + self.jm() + ca_cache = self.cache / f'jm-ca-synthetic-{self.ca_digest}.archive' + ca_cache.write_bytes(self.der) + self.env['SSL_CERT_FILE'] = str(self.certs / 'extra.pem') + self.run_installer('jm-decoder', expected=1) + self.assertNotIn('fetch ', self.calls()) + self.assertNotIn('curl ', self.calls()) + + def test_jm_bundle_never_trusts_trailing_unpinned_certificate(self): + self.jm() + for cached in (False, True): + with self.subTest(cached=cached): + self.run_installer('jm-decoder') + bundle = self.root / 'bundle.pem' + self.assertEqual(bundle.read_bytes().count(b'-----BEGIN CERTIFICATE-----'), 2) + self.openssl('verify', '-CAfile', str(bundle), str(self.certs / 'intermediate.pem')) + result = subprocess.run(['openssl', 'verify', '-CAfile', str(bundle), str(self.certs / 'extra.pem')], + capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0, 'trailing certificate became trusted') + self.ca_input.unlink(missing_ok=True) + + def test_jm_unavailable_advisory_cache_does_not_prevent_verified_install(self): + self.jm() + blocked = self.root / 'not-a-directory' + blocked.write_text('blocked') + self.env['COREAV_CI_ARCHIVE_DIR'] = str(blocked) + self.run_installer('jm-decoder') + self.assertTrue((self.dest / 'ldecod').is_file()) + + def test_jm_ca_or_archive_failure_never_builds(self): + self.jm() + self.env['CA_CURL_EXIT'] = '28' + self.assertEqual(self.run_installer('jm-decoder', expected=1).returncode, 28) + self.assertNotIn('fetch ', self.calls()) + del self.env['CA_CURL_EXIT'] + self.env['FETCH_EXIT'] = '39' + self.assertEqual(self.run_installer('jm-decoder', expected=1).returncode, 39) + self.assertNotIn('make ', self.calls()) + + def test_default_cli_cache_honors_xdg_without_changing_identity(self): + self.binaryen() + self.env.pop('COREAV_CI_ARCHIVE_DIR') + self.env['XDG_CACHE_HOME'] = str(self.root / 'xdg') + self.run_installer('binaryen') + self.assertIn(str(self.root / 'xdg/coreav-ci-archives/binaryen-synthetic-'), self.calls()) + + +class ReferenceWorkflow(unittest.TestCase): + def test_workflow_inputs_and_unconditional_verified_installers(self): + for workflow, job, installer, pin, tools in ( + ('wasm', 'wasm-module', 'binaryen', 'scripts/binaryen.env', ('binaryen',)), + ('h264-encoder', 'h264-encoder-conformance', 'jm-decoder', 'scripts/jm-decoder.env', ('jm', 'jm-ca')), + ): + with self.subTest(workflow=workflow): + text = (ROOT / f'.github/workflows/{workflow}.yml').read_text() + for event in ('push', 'pull_request'): + trigger = re.split(r'\n [a-z]', text.split(f' {event}:\n', 1)[1], maxsplit=1)[0] + for path in (f'scripts/install-ci-{installer}.sh', 'scripts/fetch-ci-archive.sh', + '.github/actions/ci-archive-cache/action.yml', 'scripts/tests/test_ci_reference_downloads.py', pin): + self.assertIn(f' - {path}\n', trigger + '\n') + body = re.split(r'\n [a-z]', text.split(f' {job}:\n', 1)[1], maxsplit=1)[0] + self.assertIn(f'bash scripts/install-ci-{installer}.sh', body) + self.assertNotIn(f'scripts/install-{installer}.sh', body) + for tool in tools: + self.assertIn(f'tool: {tool}\n', body) + self.assertEqual(body.count('uses: ./.github/actions/ci-archive-cache'), len(tools)) + install = re.search(rf' - name: Install (?:the pinned optimizer|black-box decoder oracles)\n((?: .*\n| .*\n)+)', body) + self.assertIsNotNone(install) + self.assertNotIn(' if:', install[1]) + self.assertLess(body.index('uses: ./.github/actions/ci-archive-cache'), body.index(f'bash scripts/install-ci-{installer}.sh')) + self.assertIn("-p 'test_ci_reference_downloads.py' -v", text) + + def test_npm_filters_and_installer_include_verified_archive_inputs(self): + text = (ROOT / '.github/workflows/npm.yml').read_text() + filters = text.split(' paths: |\n', 1)[1].split('\n\n', 1)[0] + for path in ('scripts/binaryen.env', 'scripts/install-binaryen.sh', 'scripts/install-ci-binaryen.sh', + 'scripts/fetch-ci-archive.sh', 'scripts/tests/test_ci_reference_downloads.py', + '.github/actions/ci-archive-cache/action.yml'): + self.assertIn(f' {path}\n', filters + '\n') + package = text.split('\n package:\n', 1)[1].split('\n smoke:', 1)[0] + self.assertIn('uses: ./.github/actions/ci-archive-cache\n', package) + self.assertIn('tool: binaryen\n', package) + self.assertIn('version: ${{ steps.binaryen.outputs.version }}\n', package) + self.assertIn('sha256: ${{ steps.binaryen.outputs.sha256 }}\n', package) + self.assertIn('opt="$(bash scripts/install-ci-binaryen.sh "$RUNNER_TEMP/binaryen")"', package) + self.assertNotIn('scripts/install-binaryen.sh', package) + install = package.split(' - name: Install binaryen\n', 1)[1].split(' - name:', 1)[0] + self.assertNotIn(' if:', install) + + def test_workflow_pin_outputs_are_read_from_the_existing_env_owner(self): + for workflow, step, env_file, expected in ( + ('wasm', 'binaryen', 'binaryen.env', {'version': 'synthetic', 'sha256': 'synthetic-linux-hash'}), + ('npm', 'binaryen', 'binaryen.env', {'version': 'synthetic', 'sha256': 'synthetic-linux-hash'}), + ('h264-encoder', 'jm', 'jm-decoder.env', {'version': 'synthetic', 'sha256': 'synthetic-zip-hash', 'ca-sha256': 'synthetic-ca-hash'}), + ): + text = (ROOT / f'.github/workflows/{workflow}.yml').read_text() + match = re.search(rf' id: {step}\n run: \|\n((?: .*\n)+)', text) + self.assertIsNotNone(match) + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + (root / 'scripts').mkdir() + (root / 'scripts' / env_file).write_text('BINARYEN_VERSION=synthetic\nBINARYEN_SHA256_X86_64_LINUX=synthetic-linux-hash\nJM_VERSION=synthetic\nJM_SHA256=synthetic-zip-hash\nJM_CA_SHA256=synthetic-ca-hash\n') + output = root / 'output' + subprocess.run(['bash', '-euo', 'pipefail', '-c', textwrap.dedent(match[1])], cwd=root, + env=dict(os.environ, GITHUB_OUTPUT=str(output)), check=True, capture_output=True, timeout=10) + self.assertEqual(dict(line.split('=', 1) for line in output.read_text().splitlines()), expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_maturin_downloads.py b/scripts/tests/test_maturin_downloads.py new file mode 100644 index 000000000..f0e34bec9 --- /dev/null +++ b/scripts/tests/test_maturin_downloads.py @@ -0,0 +1,316 @@ +"""Verified maturin wheels reach native and cross builds without another bootstrap.""" +import fnmatch +import hashlib +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + + +ROOT = Path(__file__).resolve().parents[2] +PLATFORMS = ('linux-x86_64', 'linux-arm64', 'macos-arm64') +BUILD_ARGS = ['--profile', 'dist', '--locked', '--out', 'dist', + '--manifest-path', 'crates/coreav-py/Cargo.toml'] + + +class MaturinDownloads(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + scripts = self.root / 'scripts' + scripts.mkdir() + shutil.copyfile(ROOT / 'scripts/install-ci-maturin.sh', scripts / 'install-ci-maturin.sh') + shutil.copyfile(ROOT / 'scripts/fetch-ci-archive.sh', scripts / 'fetch-ci-archive.sh') + (scripts / 'fetch-ci-archive.sh').chmod(0o755) + (scripts / 'install-ci-maturin.sh').chmod(0o755) + shutil.copyfile(ROOT / 'rust-toolchain.toml', self.root / 'rust-toolchain.toml') + self.tools = self.root / 'tools' + self.tools.mkdir() + self.log = self.root / 'events.jsonl' + self.wheel = self.root / 'fixture.whl' + self.binary = b'''#!/usr/bin/env python3 +import json, os, sys +with open(os.environ['FAKE_LOG'], 'a') as out: + out.write(json.dumps({'tool': 'maturin', 'args': sys.argv[1:], + 'env': dict(os.environ)}) + '\\n') +''' + with zipfile.ZipFile(self.wheel, 'w') as archive: + archive.writestr('maturin-1.16.0.data/scripts/maturin', self.binary) + self.sha256 = hashlib.sha256(self.wheel.read_bytes()).hexdigest() + (scripts / 'maturin.env').write_text( + 'MATURIN_VERSION=1.16.0\n' + ''.join( + f'MATURIN_{platform}_URL=https://files.pythonhosted.org/fixture.whl\n' + f'MATURIN_{platform}_SHA256={self.sha256}\n' + for platform in ('LINUX', 'MACOS'))) + mock = '''import json, os, pathlib, subprocess, sys +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +with open(os.environ['FAKE_LOG'], 'a') as out: + out.write(json.dumps({'tool': name, 'args': args}) + '\\n') +if name == 'python3': + if args[:2] != ['-m', 'pip']: + os.execv(os.environ['REAL_PYTHON'], [os.environ['REAL_PYTHON'], *args]) +elif name == 'curl': + if os.environ.get('FAIL_DOWNLOAD'): + sys.exit(22) + pathlib.Path(args[args.index('--output') + 1]).write_bytes( + pathlib.Path(os.environ['FAKE_DOWNLOAD']).read_bytes()) +elif name == 'rustc': + print(os.environ['FAKE_MISSING_LIBDIR']) +elif name == 'docker': + assert '--interactive' in args + script = sys.stdin.read() + mounts = [args[i + 1] for i, arg in enumerate(args) if arg == '-v'] + binary_mount = next(m for m in mounts if m.endswith(':/opt/coreav-maturin:ro')) + source = binary_mount.removesuffix(':/opt/coreav-maturin:ro') + assert pathlib.Path(source, 'maturin').is_file() + assert pathlib.Path(source, 'maturin').stat().st_mode & 0o111 + assert len(mounts) == 2 + assert args[args.index('--workdir') + 1] + ':' + args[args.index('--workdir') + 1] in mounts + assert not any('credentials' in m or 'ssh' in m or '.gitconfig' in m for m in mounts) + assert '/releases/' not in script and 'maturin-action' not in script + env = {k: v for k, v in os.environ.items() if k.startswith('FAKE_') or k in + ('HOME', 'PATH', 'REAL_PYTHON')} + for i, arg in enumerate(args): + if arg == '-e': + key, sep, value = args[i + 1].partition('=') + if sep or key in os.environ: + env[key] = value if sep else os.environ[key] + index = args.index('bash') + result = subprocess.run(['bash', *args[index + 1:]], + input=script.replace('/opt/coreav-maturin', source), + text=True, env=env, timeout=15) + sys.exit(result.returncode) +''' + for name in ('curl', 'docker', 'python3', 'rustup', 'rustc', 'uv', 'patchelf', 'sudo'): + path = self.tools / name + path.write_text(f'#!{sys.executable}\n' + mock) + path.chmod(0o755) + self.env = {'PATH': str(self.tools) + os.pathsep + os.environ['PATH'], + 'HOME': str(self.root / 'home'), 'RUNNER_TEMP': str(self.root), + 'GITHUB_WORKSPACE': str(self.root), + 'COREAV_CI_ARCHIVE_DIR': str(self.root / 'cache'), + 'FAKE_LOG': str(self.log), 'FAKE_DOWNLOAD': str(self.wheel), + 'FAKE_MISSING_LIBDIR': str(self.root / 'missing-libdir'), + 'REAL_PYTHON': sys.executable, + 'CARGO_HOME': '/do-not-forward', + 'PYO3_CROSS_LIB_DIR': '/cross/python', + 'CC_aarch64_unknown_linux_gnu': 'aarch64-linux-gnu-gcc', + 'MATURIN_TEST_CROSS': 'kept', + 'SSH_AUTH_SOCK': '/do-not-mount-agent', + 'UNRELATED_SECRET': 'do-not-forward'} + + def run_installer(self, mode, platform, *args): + return subprocess.run(['bash', str(self.root / 'scripts/install-ci-maturin.sh'), + mode, platform, *args], env=self.env, capture_output=True, + text=True, timeout=30) + + def events(self, tool): + if not self.log.exists(): + return [] + return [row for row in map(json.loads, self.log.read_text().splitlines()) + if row['tool'] == tool] + + def cache(self): + return self.root / 'cache' / f'maturin-wheel-1.16.0-{self.sha256}.archive' + + def test_verified_install_and_cache_hit_for_every_build_path(self): + for platform in PLATFORMS: + with self.subTest(platform=platform): + self.log.unlink(missing_ok=True) + self.cache().unlink(missing_ok=True) + destination = self.root / platform + first = self.run_installer('install', platform, str(destination)) + self.assertEqual(first.returncode, 0, first.stderr) + self.assertEqual((destination / 'maturin').read_bytes(), self.binary) + self.assertEqual(self.cache().read_bytes(), self.wheel.read_bytes()) + self.assertEqual(len(self.events('curl')), 1) + flags = self.events('curl')[0]['args'] + for flag in ('--fail', '--retry', '--retry-all-errors', '--retry-max-time', + '--connect-timeout', '--max-time'): + self.assertIn(flag, flags) + self.assertGreater(int(flags[flags.index('--retry') + 1]), 0) + self.assertLessEqual(int(flags[flags.index('--retry') + 1]), 8) + self.assertLessEqual(int(flags[flags.index('--retry-max-time') + 1]), 360) + second = self.run_installer('install', platform, str(destination)) + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual(len(self.events('curl')), 1) + + def test_invalid_cache_is_never_installed_and_is_replaced_only_by_verified_bytes(self): + for platform in PLATFORMS: + with self.subTest(platform=platform): + self.cache().parent.mkdir(exist_ok=True) + self.cache().write_bytes(b'corrupt cached wheel') + self.env['FAIL_DOWNLOAD'] = '1' + destination = self.root / platform + rejected = self.run_installer('install', platform, str(destination)) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn('reason=ci_archive_cache_rejected', rejected.stderr) + self.assertFalse((destination / 'maturin').exists()) + del self.env['FAIL_DOWNLOAD'] + recovered = self.run_installer('install', platform, str(destination)) + self.assertEqual(recovered.returncode, 0, recovered.stderr) + self.assertEqual((destination / 'maturin').read_bytes(), self.binary) + self.assertEqual(self.cache().read_bytes(), self.wheel.read_bytes()) + + def test_invalid_download_fails_before_extraction_or_build_for_every_path(self): + self.wheel.write_bytes(b'unverified download') + for platform in PLATFORMS: + with self.subTest(platform=platform): + result = self.run_installer('build', platform, *BUILD_ARGS) + self.assertNotEqual(result.returncode, 0) + self.assertIn('reason=ci_archive_digest_mismatch', result.stderr) + self.assertFalse(self.events('docker')) + self.assertFalse(self.events('maturin')) + self.assertFalse(self.cache().exists()) + + def test_linux_builds_transport_verified_host_executable_and_preserve_environment(self): + for platform, image, target, policy in ( + ('linux-x86_64', 'quay.io/pypa/manylinux2014_x86_64:latest', + 'x86_64-unknown-linux-gnu', []), + ('linux-arm64', 'ghcr.io/rust-cross/manylinux_2_28-cross:aarch64', + 'aarch64-unknown-linux-gnu', ['--manylinux', '2_28'])): + with self.subTest(platform=platform): + self.log.unlink(missing_ok=True) + result = self.run_installer('build', platform, *BUILD_ARGS) + self.assertEqual(result.returncode, 0, result.stderr) + docker = self.events('docker') + self.assertEqual(len(docker), 1) + self.assertIn(image, docker[0]['args']) + built = self.events('maturin') + self.assertEqual(len(built), 1) + self.assertEqual(built[0]['args'], ['build', '--target', target, *policy, *BUILD_ARGS]) + env = built[0]['env'] + for name in ('PYO3_CROSS_LIB_DIR', 'CC_aarch64_unknown_linux_gnu', 'MATURIN_TEST_CROSS'): + self.assertEqual(env[name], self.env[name]) + for name in ('CARGO_HOME', 'SSH_AUTH_SOCK', 'UNRELATED_SECRET'): + self.assertNotIn(name, env) + self.assertEqual(env['CARGO_INCREMENTAL'], '0') + self.assertEqual(env['CARGO_TERM_COLOR'], 'always') + self.assertIn('/opt/python/cp39-cp39/bin', env['PATH']) + self.assertIn('/opt/python/cp312-cp312/bin', env['PATH']) + rustup = [row['args'] for row in self.events('rustup')] + self.assertIn(['update', '--no-self-update', 'stable'], rustup) + self.assertIn(['override', 'set', 'stable'], rustup) + self.assertIn(['component', 'add', 'llvm-tools-preview'], rustup) + self.assertIn(['target', 'add', target], rustup) + self.assertEqual([r['args'] for r in self.events('python3') if r['args'][:2] == ['-m', 'pip']], + [['-m', 'pip', 'install', '--user', 'cffi']]) + + def test_macos_build_is_native_and_preserves_sdk_defaults_and_overrides(self): + for custom in (False, True): + with self.subTest(custom=custom): + self.log.unlink(missing_ok=True) + if custom: + self.env.update(DEVELOPER_DIR='/custom/xcode', SDKROOT='/custom/sdk') + result = self.run_installer('build', 'macos-arm64', *BUILD_ARGS) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.events('docker')) + built = self.events('maturin') + self.assertEqual(len(built), 1) + self.assertEqual(built[0]['args'], ['build', '--target', 'aarch64-apple-darwin', *BUILD_ARGS]) + self.assertEqual(built[0]['env']['DEVELOPER_DIR'], + '/custom/xcode' if custom else '/Applications/Xcode.app/Contents/Developer') + self.assertEqual(built[0]['env']['SDKROOT'], '/custom/sdk' if custom else + '/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk') + self.assertIn(['target', 'add', '--toolchain', 'stable', 'aarch64-apple-darwin'], + [row['args'] for row in self.events('rustup')]) + + def test_unsupported_platform_fails_without_download(self): + result = self.run_installer('install', 'linux-riscv64', str(self.root / 'bin')) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.events('curl')) + + +class MaturinWorkflow(unittest.TestCase): + def test_full_upstream_license_and_source_provenance_are_retained(self): + script = (ROOT / 'scripts/install-ci-maturin.sh').read_text() + lines = script.splitlines() + end = lines.index('# SOFTWARE.') + notice = '\n'.join(line.removeprefix('#').removeprefix(' ') + for line in lines[1:end + 1]) + '\n' + self.assertEqual(hashlib.sha256(notice.encode()).hexdigest(), + 'f4a1e1b67d82997496952af1e970ebda93c5dfccfa41b4834f5bed8dbfb4b06f') + for marker in ('https://github.com/PyO3/maturin-action', + 'e83996d129638aa358a18fbd1dfb82f0b0fb5d3b', '# Ported:', '# New:', '# CI-only'): + self.assertIn(marker, script) + + def test_built_wheels_refuse_ci_glue_and_missing_artifacts(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'dist').mkdir() + command = ['bash', str(ROOT / 'scripts/install-ci-maturin.sh'), + 'verify-artifacts', 'linux-x86_64'] + missing = subprocess.run(command, cwd=root, capture_output=True, text=True) + self.assertNotEqual(missing.returncode, 0) + self.assertIn('ci_maturin_missing_wheels', missing.stderr) + wheel = root / 'dist' / 'synthetic.whl' + with zipfile.ZipFile(wheel, 'w') as archive: + archive.writestr('coreav/__init__.py', '') + self.assertEqual(subprocess.run(command, cwd=root).returncode, 0) + with zipfile.ZipFile(wheel, 'a') as archive: + archive.writestr('coreav/install-ci-maturin.sh', 'CI-only glue') + copied = subprocess.run(command, cwd=root, capture_output=True, text=True) + self.assertNotEqual(copied.returncode, 0) + self.assertIn('ci_build_glue_in_wheel', copied.stderr) + + @classmethod + def setUpClass(cls): + cls.workflow = json.loads(subprocess.check_output([ + 'ruby', '-rjson', '-rpsych', '-e', + 'puts JSON.generate(Psych.load_file(ARGV[0]))', + str(ROOT / '.github/workflows/python.yml')], text=True, timeout=15)) + + def test_all_wheel_jobs_use_verified_acquisition_without_the_hidden_action_download(self): + jobs = self.workflow['jobs'] + for platform in PLATFORMS: + with self.subTest(platform=platform): + steps = jobs[platform]['steps'] + self.assertFalse(any('maturin-action' in step.get('uses', '') for step in steps)) + pin = next(i for i, step in enumerate(steps) if step.get('id') == 'maturin-pin') + self.assertEqual(steps[pin]['run'], + f'scripts/install-ci-maturin.sh metadata {platform} >> "$GITHUB_OUTPUT"') + cache = next(i for i, step in enumerate(steps) + if step.get('uses') == './.github/actions/ci-archive-cache') + self.assertEqual(steps[cache]['with'], { + key: '${{ steps.maturin-pin.outputs.' + key + ' }}' + for key in ('tool', 'version', 'sha256')}) + build = next(i for i, step in enumerate(steps) + if step.get('run', '').startswith('scripts/install-ci-maturin.sh build')) + self.assertLess(pin, cache) + self.assertLess(cache, build) + self.assertEqual(shlex.split(steps[build]['run']), + ['scripts/install-ci-maturin.sh', 'build', platform, *BUILD_ARGS]) + verify, = [i for i, step in enumerate(steps) + if step.get('run') == f'scripts/install-ci-maturin.sh verify-artifacts {platform}'] + self.assertLess(build, verify) + self.assertEqual(jobs[platform]['runs-on'], + 'macos-15' if platform == 'macos-arm64' else 'ubuntu-24.04') + self.assertIn('python3 -I -m unittest discover -s scripts/tests -p \'test_*.py\'', + (ROOT / '.github/workflows/deny.yml').read_text()) + + def test_pin_changes_run_python_ci_and_supported_paths_use_current_official_hashes(self): + steps = self.workflow['jobs']['python-changes']['steps'] + patterns = next(step['with']['paths'].splitlines() for step in steps if step.get('id') == 'filter') + for path in ('scripts/maturin.env', 'scripts/install-ci-maturin.sh', + 'scripts/fetch-ci-archive.sh', 'scripts/tests/test_maturin_downloads.py', + '.github/actions/ci-archive-cache/action.yml'): + self.assertTrue(any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns), path) + for platform, digest in ( + ('linux-x86_64', '837db2f4d93d4899c3ac393fb3cc9eebf35b6002336e5d574f4a4fb6c49fe283'), + ('linux-arm64', '837db2f4d93d4899c3ac393fb3cc9eebf35b6002336e5d574f4a4fb6c49fe283'), + ('macos-arm64', '131fcc17ddb3ccc48aa183d2172ebf994c322b858e1b9694fb8744d783df66f6')): + output = subprocess.check_output(['bash', str(ROOT / 'scripts/install-ci-maturin.sh'), + 'metadata', platform], text=True, timeout=15) + self.assertEqual(output, f'tool=maturin-wheel\nversion=1.16.0\nsha256={digest}\n') + + +if __name__ == '__main__': + unittest.main()