From 1ca2412242294909ceeaa0f219009b277a5ed09b Mon Sep 17 00:00:00 2001 From: plx Date: Mon, 27 Jul 2026 12:41:46 -0500 Subject: [PATCH 1/6] Build fail-closed release pipeline --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 550 ++++++++++++++++++++ AGENTIC_NAVIGATION_GUIDE.md | 6 +- CHANGELOG.md | 7 + README.md | 9 +- docs/release-policy.md | 123 ++++- docs/repository-protections.md | 10 +- docs/v0.2-contract.md | 17 +- justfile | 4 + release/pipeline.toml | 24 + scripts/release_artifacts.py | 887 ++++++++++++++++++++++++++++++++ tests/test_release_artifacts.py | 288 +++++++++++ 12 files changed, 1909 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 release/pipeline.toml create mode 100644 scripts/release_artifacts.py create mode 100644 tests/test_release_artifacts.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3195dd5..34810bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -312,10 +312,12 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Run release-identity checker regressions + - name: Run release identity and artifact regressions run: >- PYTHONDONTWRITEBYTECODE=1 python3 -m unittest - tests/test_check_release_identity.py -v + tests/test_check_release_identity.py + tests/test_release_artifacts.py + -v - name: Assert prepared version and external tag input run: python3 scripts/check_release_identity.py --tag v0.2.0 - name: Prove published baseline and exact packaged install identity diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ba3c42d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,550 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + candidate_tag: + description: Exact prepared tag identity to rehearse + required: true + default: v0.2.0 + type: string + failure_injection: + description: Deliberate fail-closed proof; never publishes + required: true + default: none + type: choice + options: + - none + - tag-mismatch + - package-smoke + +env: + CANDIDATE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_tag || github.ref_name }} + CARGO_TERM_COLOR: always + SOURCE_REF: ${{ github.ref }} + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +# All remote actions use immutable, reviewed commits. The manual path is a +# rehearsal only. OIDC and write permission exist solely in the tag-triggered, +# protected-environment publish job after release-gate succeeds. +jobs: + identity: + name: Exact source identity + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + env: + FAILURE_INJECTION: ${{ inputs.failure_injection || 'none' }} + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + with: + toolchain: "1.97.1" + cache: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Reject a source/tag/version mismatch before every downstream job + shell: bash + run: | + checked_tag="$CANDIDATE_TAG" + if [[ "$FAILURE_INJECTION" == "tag-mismatch" ]]; then + checked_tag="v999.0.0-injected-mismatch" + fi + python3 scripts/check_release_identity.py --tag "$checked_tag" + - name: Require the exact checked-out commit and immutable tag source + shell: bash + run: | + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + test "$GITHUB_REF" = "refs/tags/$CANDIDATE_TAG" + remote_tag="$(git ls-remote --refs origin "refs/tags/$CANDIDATE_TAG")" + test "$(cut -f1 <<<"$remote_tag")" = "$GITHUB_SHA" + test "$(wc -l <<<"$remote_tag" | tr -d ' ')" = "1" + git fetch --no-tags --depth=1 origin main + test "$(git rev-parse origin/main)" = "$GITHUB_SHA" + fi + git diff --exit-code + - name: Run release identity and artifact regressions + run: >- + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest + tests/test_check_release_identity.py + tests/test_release_artifacts.py + -v + - name: Prove binary-only package identity and migration baseline + run: cargo test --locked --test issue_64_release_identity -- --nocapture + + quality: + name: Release quality and supply-chain gates + needs: identity + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + with: + toolchain: "1.97.1" + components: clippy,rustfmt + cache: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6 + with: + tool: cargo-audit@0.22.1,cargo-about@0.9.0 + - name: Check formatting + run: cargo fmt -- --check + - name: Deny every Clippy warning + run: cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Deny every rustdoc warning + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --locked --all-features --no-deps --document-private-items + - name: Audit the exact dependency graph + run: cargo audit --file Cargo.lock + - name: Verify licenses and attribution + shell: bash + run: | + cargo about generate about.hbs \ + --output-file "$RUNNER_TEMP/THIRD_PARTY_LICENSES.md" + cmp THIRD_PARTY_LICENSES.md "$RUNNER_TEMP/THIRD_PARTY_LICENSES.md" + - name: Verify package manifest, CLI contract, and absent library target + run: >- + cargo test --locked + --test issue_54_binary_only_package + --test issue_62_package_boundary + --test issue_64_release_identity + --test issue_66_readme_examples + -- --nocapture + - name: Install checksum-pinned workflow auditors + env: + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + ACTIONLINT_VERSION: 1.7.12 + ZIZMOR_SHA256: aa1facd105f0d83fe5c55b1adcd9d7417de5d83aa27471f91dc0b66cf3803577 + ZIZMOR_VERSION: 1.25.2 + shell: bash + run: | + actionlint_archive="$RUNNER_TEMP/actionlint.tar.gz" + curl --fail --location --silent --show-error \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + --output "$actionlint_archive" + echo "$ACTIONLINT_SHA256 $actionlint_archive" | sha256sum --check - + tar -xzf "$actionlint_archive" -C "$RUNNER_TEMP" actionlint + + zizmor_archive="$RUNNER_TEMP/zizmor.tar.gz" + curl --fail --location --silent --show-error \ + "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-x86_64-unknown-linux-gnu.tar.gz" \ + --output "$zizmor_archive" + echo "$ZIZMOR_SHA256 $zizmor_archive" | sha256sum --check - + tar -xzf "$zizmor_archive" -C "$RUNNER_TEMP" zizmor + + "$RUNNER_TEMP/actionlint" .github/workflows/*.yml .github/examples/*.yml + "$RUNNER_TEMP/zizmor" --pedantic --no-ignores \ + .github/workflows/ .github/examples/readme-verify.yml + + platform-tests: + name: Full tests (${{ matrix.os }}) + needs: identity + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + with: + toolchain: "1.97.1" + cache: false + - name: Run the complete locked debug suite + env: + GUIDE_FORMAT_REQUIRE_CONFORMANCE: all + run: cargo test --workspace --all-targets --all-features --locked -- --nocapture + - name: Run the complete locked release suite + env: + GUIDE_FORMAT_REQUIRE_CONFORMANCE: all + run: >- + cargo test --workspace --all-targets --all-features + --release --locked -- --nocapture + + msrv: + name: Full MSRV gates + needs: identity + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: read + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + with: + toolchain: "1.85.0" + components: clippy + cache: false + - name: Check every MSRV target and feature + run: cargo check --locked --all-targets --all-features + - name: Test every MSRV target and feature + env: + GUIDE_FORMAT_REQUIRE_CONFORMANCE: all + run: cargo test --locked --all-targets --all-features -- --nocapture + - name: Deny warnings at the declared MSRV + run: cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Package and install at the declared MSRV + shell: bash + run: | + cargo package --locked + cargo install \ + --path target/package/agentic-navigation-guide-0.2.0 \ + --locked \ + --root "$RUNNER_TEMP/msrv-install" + + package: + name: Exact crate package and installed smoke + needs: [identity, quality] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + env: + FAILURE_INJECTION: ${{ inputs.failure_injection || 'none' }} + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + with: + toolchain: "1.97.1" + cache: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Build, verify, and unpack the exact crate archive + run: cargo package --locked + - name: Install the exact unpacked crate in a clean root + shell: bash + run: | + cargo install \ + --path target/package/agentic-navigation-guide-0.2.0 \ + --locked \ + --root "$RUNNER_TEMP/package-install" + - name: Exercise installed success and failure behavior + shell: bash + run: | + injection=() + if [[ "$FAILURE_INJECTION" == "package-smoke" ]]; then + injection+=(--inject-failure) + fi + python3 scripts/release_artifacts.py smoke-binary \ + --binary "$RUNNER_TEMP/package-install/bin/agentic-navigation-guide" \ + --tag "$CANDIDATE_TAG" \ + "${injection[@]}" + - name: Rehearse Cargo publication without external state + run: cargo publish --dry-run --locked + - name: Upload the exact crate archive + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-crate + path: target/package/agentic-navigation-guide-0.2.0.crate + if-no-files-found: error + retention-days: 14 + + native-archives: + name: Native archive (${{ matrix.os }}) + needs: [identity, quality, platform-tests, msrv] + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + with: + toolchain: "1.97.1" + cache: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Resolve the actual native Rust host + id: host + run: python3 scripts/release_artifacts.py host-triple + - name: Build the release binary twice in isolated target directories + shell: bash + run: | + CARGO_TARGET_DIR="$RUNNER_TEMP/release-a" \ + cargo build --release --locked --bin agentic-navigation-guide + CARGO_TARGET_DIR="$RUNNER_TEMP/release-b" \ + cargo build --release --locked --bin agentic-navigation-guide + - name: Require byte-identical binaries and create a normalized archive + env: + BINARY_SUFFIX: ${{ runner.os == 'Windows' && '.exe' || '' }} + HOST_TRIPLE: ${{ steps.host.outputs.host-triple }} + shell: bash + run: | + python3 scripts/release_artifacts.py archive \ + --binary "$RUNNER_TEMP/release-a/release/agentic-navigation-guide$BINARY_SUFFIX" \ + --comparison-binary "$RUNNER_TEMP/release-b/release/agentic-navigation-guide$BINARY_SUFFIX" \ + --host-triple "$HOST_TRIPLE" \ + --tag "$CANDIDATE_TAG" \ + --output-directory target/release-dist + - name: Smoke-test the exact archive on its target operating system + shell: bash + run: | + archive=(target/release-dist/*) + test "${#archive[@]}" -eq 1 + python3 scripts/release_artifacts.py smoke-archive \ + --archive "${archive[0]}" \ + --tag "$CANDIDATE_TAG" + - name: Upload the exact native archive + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-native-${{ runner.os }} + path: target/release-dist + if-no-files-found: error + retention-days: 14 + + assemble: + name: Checksums, SBOM, provenance, and downloaded verification + needs: [package, native-archives] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + with: + toolchain: "1.97.1" + cache: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Download every independently uploaded distribution + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-* + path: target/release-bundle + merge-multiple: true + digest-mismatch: error + - name: Use the candidate commit timestamp for deterministic metadata + shell: bash + run: echo "SOURCE_DATE_EPOCH=$(git show -s --format=%ct "$GITHUB_SHA")" >> "$GITHUB_ENV" + - name: Generate SPDX 2.3 dependency and license SBOM + run: >- + python3 scripts/release_artifacts.py sbom + --commit "$GITHUB_SHA" + --output target/release-bundle/agentic-navigation-guide-0.2.0.spdx.json + - name: Generate and verify SHA-256 checksums + shell: bash + run: | + subjects=(target/release-bundle/*) + python3 scripts/release_artifacts.py checksums \ + --output target/release-bundle/SHA256SUMS \ + "${subjects[@]}" + python3 scripts/release_artifacts.py verify-checksums \ + --directory target/release-bundle \ + --checksums target/release-bundle/SHA256SUMS + - name: Generate the rehearsal in-toto/SLSA provenance statement + run: >- + python3 scripts/release_artifacts.py provenance + --checksums target/release-bundle/SHA256SUMS + --commit "$GITHUB_SHA" + --ref "$SOURCE_REF" + --run-id "$GITHUB_RUN_ID" + --run-attempt "$GITHUB_RUN_ATTEMPT" + --output target/release-bundle/agentic-navigation-guide-0.2.0.intoto.json + - name: Verify the complete downloaded release bundle + run: >- + python3 scripts/release_artifacts.py verify-bundle + --directory target/release-bundle + --checksums target/release-bundle/SHA256SUMS + --sbom target/release-bundle/agentic-navigation-guide-0.2.0.spdx.json + --provenance target/release-bundle/agentic-navigation-guide-0.2.0.intoto.json + --tag "$CANDIDATE_TAG" + --commit "$GITHUB_SHA" + --ref "$SOURCE_REF" + - name: Upload the complete non-publishing rehearsal bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-bundle + path: target/release-bundle + if-no-files-found: error + retention-days: 14 + + release-gate: + name: Fail-closed release gate + if: always() + needs: + - identity + - quality + - platform-tests + - msrv + - package + - native-archives + - assemble + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Require every release prerequisite to pass + env: + RELEASE_RESULTS: ${{ toJSON(needs) }} + run: >- + jq --exit-status + 'all(.[]; .result == "success")' + <<<"$RELEASE_RESULTS" + + rehearsal: + name: Non-publishing rehearsal complete + if: github.event_name == 'workflow_dispatch' && inputs.failure_injection == 'none' + needs: release-gate + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Record that publication capability was structurally unavailable + run: | + echo "All gates and artifact steps passed for $CANDIDATE_TAG." + echo "The rehearsal job has no OIDC or contents-write permission." + + publish: + name: Protected Trusted Publishing + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: release-gate + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: release + url: ${{ steps.release.outputs.url }} + permissions: + attestations: write # Persist signed build and SBOM attestations. + contents: write # Create the immutable GitHub Release and assets. + id-token: write # Mint short-lived Sigstore and crates.io identities. + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1.16.1 + with: + toolchain: "1.97.1" + cache: false + - name: Download the exact gated bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-bundle + path: target/release-bundle + digest-mismatch: error + - name: Reverify checksums and exact source identity in the protected job + run: >- + python3 scripts/release_artifacts.py verify-bundle + --directory target/release-bundle + --checksums target/release-bundle/SHA256SUMS + --sbom target/release-bundle/agentic-navigation-guide-0.2.0.spdx.json + --provenance target/release-bundle/agentic-navigation-guide-0.2.0.intoto.json + --tag "$CANDIDATE_TAG" + --commit "$GITHUB_SHA" + --ref "$SOURCE_REF" + - name: Sign build provenance for every distribution + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-checksums: target/release-bundle/SHA256SUMS + - name: Sign the SPDX SBOM attestation + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-checksums: target/release-bundle/SHA256SUMS + sbom-path: target/release-bundle/agentic-navigation-guide-0.2.0.spdx.json + - name: Create or verify the immutable GitHub Release before crate publication + id: release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + recovery_directory="$RUNNER_TEMP/existing-release" + if gh release view "$CANDIDATE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + mkdir -p "$recovery_directory" + gh release download "$CANDIDATE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir "$recovery_directory" + cmp target/release-bundle/SHA256SUMS \ + "$recovery_directory/SHA256SUMS" + python3 scripts/release_artifacts.py verify-bundle \ + --directory "$recovery_directory" \ + --checksums "$recovery_directory/SHA256SUMS" \ + --sbom "$recovery_directory/agentic-navigation-guide-0.2.0.spdx.json" \ + --provenance "$recovery_directory/agentic-navigation-guide-0.2.0.intoto.json" \ + --tag "$CANDIDATE_TAG" \ + --commit "$GITHUB_SHA" \ + --ref "$SOURCE_REF" + else + gh release create "$CANDIDATE_TAG" target/release-bundle/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + --latest=false \ + --title "agentic-navigation-guide $CANDIDATE_TAG" + fi + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$CANDIDATE_TAG")" + jq --exit-status \ + --arg tag "$CANDIDATE_TAG" \ + 'select(.tag_name == $tag and .draft == false and .immutable == true)' \ + <<<"$release_json" >/dev/null + release_url="$(jq --raw-output '.html_url' <<<"$release_json")" + echo "url=$release_url" >> "$GITHUB_OUTPUT" + - name: Check whether recovery already published the identical crate + id: crate-state + run: >- + python3 scripts/release_artifacts.py crates-version-state + --crate-archive + target/release-bundle/agentic-navigation-guide-0.2.0.crate + - name: Exchange GitHub OIDC identity for a short-lived crates.io token + if: steps.crate-state.outputs.state == 'publish-required' + id: crates-auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + - name: Publish the exact gated crate without a long-lived token + if: steps.crate-state.outputs.state == 'publish-required' + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }} + run: cargo publish --locked diff --git a/AGENTIC_NAVIGATION_GUIDE.md b/AGENTIC_NAVIGATION_GUIDE.md index aa6d2f6..79cbf27 100644 --- a/AGENTIC_NAVIGATION_GUIDE.md +++ b/AGENTIC_NAVIGATION_GUIDE.md @@ -69,7 +69,7 @@ - README.md - docs/ # Maintained user and contract documentation - v0.2-contract.md # Normative v0.2 guide language and filesystem mapping - - release-policy.md # Version/tag identity and compatibility baseline rules + - release-policy.md # Release DAG, artifact, OIDC, recovery, version, and compatibility rules - maintainer-continuity.md # Time-bounded sole-maintainer exception, authority, and recovery policy - repository-protections.md # Required checks, release-tag rules, protected environment, and audit procedure - history/ # Clearly non-normative retained design records @@ -77,6 +77,7 @@ - Specification.md # Original specification (docs/history/Specification.md), retained as non-normative evidence - release/ - identity.toml # Machine-readable prepared version and pinned baseline evidence + - pipeline.toml # Exact workflow, artifact, and personal Trusted Publisher identity - maintainer-continuity.toml # Machine-readable public owner, missing-control, and exception-expiry record - benchmarks/ - issue-59-baseline.json # Versioned fixed-fixture release performance reference @@ -98,6 +99,7 @@ - ci.yml # Cross-platform, release-quality, and stable required-CI aggregate - claude.yml # Trusted-maintainer interactive Claude workflow - claude-code-review.yml # Internal pull-request review workflow + - release.yml # Non-publishing rehearsal and protected tag-only Trusted Publishing DAG - repository-protection-audit.yml # Weekly public GitHub-control drift audit - site-check.yml # Pull-request site validation - site-publish.yml # GitHub Pages publication @@ -111,6 +113,7 @@ - check_coverage.py # Fail-closed overall and critical-module branch-aware coverage policy - check_mutation_report.py # Reviewed blocker-mutation completeness and survivor gate - check_release_identity.py # Fail-closed version/tag/changelog/baseline checker + - release_artifacts.py # Deterministic archives, smoke, checksums, SPDX, provenance, and recovery checks - audit_github_protections.py # Live ruleset, tag, environment, and secret-name policy audit - get_next_production_readiness_issue.py # Select the next remediation issue from live GitHub state - run_performance_baseline.py # Fixed-fixture release timing and resource regression harness @@ -140,6 +143,7 @@ - test_check_coverage.py # Coverage report floor and missing-instrumentation regressions - test_check_mutation_report.py # Mutation report completeness and survivor regressions - test_check_release_identity.py # Release-identity checker mutation regressions + - test_release_artifacts.py # Release artifact, provenance, failure-injection, and DAG regressions - test_audit_github_protections.py # Offline GitHub-control comparison and pagination regressions - test_get_next_production_readiness_issue.py # Offline selector regression suite - test_performance_baseline.py # Performance matrix, scaling, metadata, and reference regressions diff --git a/CHANGELOG.md b/CHANGELOG.md index 7622aef..3fde028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ complete and the final candidate is revalidated. owner approval with administrator bypass disabled. The zero independent review count and allowed self-review are explicit consequences of the dated single-maintainer exception. +- A manual non-publishing release rehearsal now runs every identity, supported + platform, MSRV, quality, package/install, native rebuild/archive/smoke, + checksum, SPDX, provenance, and downloaded-bundle gate behind one aggregate. + Real publication is tag-only, owner-approved, OIDC-based, and ordered so an + API-verified immutable GitHub Release exists before crates.io publication. + Recovery reuses the same tag and exact checksums; it never moves a tag or + replaces an asset. - `docs/v0.2-contract.md` is mechanically enforced as the sole normative v0.2 specification. The original root `Specification.md` is preserved with its Git history under `docs/history/`, where an unmistakable dated banner and diff --git a/README.md b/README.md index 5da50c3..8f31ce3 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,11 @@ cargo uninstall agentic-navigation-guide ``` Omitting `--locked` asks Cargo to resolve a different dependency graph. -Homebrew packages and prebuilt GitHub Release archives are not currently -supported installation channels. If the release pipeline in issue #63 adds a -channel, its exact install, upgrade, uninstall, checksum, and smoke commands -must be added here and to the README test before publication. +Homebrew is not a supported installation channel. The release workflow also +builds native GitHub Release archives, verifies their SHA-256 checksums, and +extracts and smoke-tests each archive on its target operating system. Those +archives are portable release artifacts, not a package-manager install or +upgrade channel; Cargo remains the supported managed lifecycle. ## Quickstart diff --git a/docs/release-policy.md b/docs/release-policy.md index 7bf3b57..aa71473 100644 --- a/docs/release-policy.md +++ b/docs/release-policy.md @@ -23,8 +23,8 @@ python3 scripts/check_release_identity.py --tag v0.2.0 ``` The check validates an input; it does not create a tag, crate, GitHub Release, -or other publication. The trusted publishing workflow owned by issue #63 must -pass its real tag ref to this checker before any release action. +or other publication. The trusted publishing workflow passes its real tag ref +to this checker before any release action. ## Maintainer continuity and release authority @@ -40,10 +40,127 @@ established the strongest operable controls for the personal repository: pull-request-only `main` changes, current required CI, immutable release tags, and a tag-scoped owner-approved `release` environment. The absence of an independent pull-request or deployment approver remains residual risk. Issue -#63 must use short-lived crates.io identity scoped to that environment. +#63's workflow uses short-lived crates.io identity scoped to that environment. Publication after the expiry date is blocked without a verified backup or a new explicit maintainer decision. +## Release workflow + +[`release/pipeline.toml`](../release/pipeline.toml) records the expected +personal-repository identity. The only production identity is: + +- repository `plx/agentic-navigation-guide`; +- workflow `.github/workflows/release.yml`; +- protected environment `release`; +- crate and binary `agentic-navigation-guide`; and +- tag `v0.2.0` for the prepared candidate. + +The workflow has two entry points. A manual dispatch is always a +non-publishing rehearsal. A `v*` tag event may reach the `publish` job only +after the one `release-gate` aggregate sees every prerequisite succeed. The +production tag must name the exact prepared version, resolve to the checked-out +commit, and equal current protected `main`; a stale or moved candidate fails +before build, package, attestation, or publication. + +The gate requires: + +1. source, Cargo, lockfile, CLI, changelog, package-target, and migration + identity; +2. complete locked debug and release suites on Linux, macOS, and Windows; +3. complete check, test, Clippy, package, and install gates on Rust `1.85.0`; +4. rustfmt, all-target/all-feature Clippy with warnings denied, rustdoc with + warnings denied, RustSec, license/attribution, workflow, manifest, and + binary-only compatibility checks; +5. exact `cargo package`, clean-root installation, success/failure smoke, and + `cargo publish --dry-run`; +6. two isolated release builds on every native runner, requiring byte-identical + binaries before normalized `.tar.gz` or `.zip` creation; +7. target-OS extraction and success/failure smoke of each exact archive; and +8. re-download, SHA-256 verification, SPDX 2.3 SBOM generation, an in-toto/SLSA + provenance statement, and complete bundle verification. + +The reproducibility claim is deliberately narrow: two release binaries built +on the same hosted runner, toolchain, commit, and locked graph must be byte for +byte identical. Archive timestamps, ownership, order, modes, and gzip/ZIP +metadata are normalized. The project does not claim that different runner +images, operating systems, architectures, or future toolchains produce the +same bytes. + +The rehearsal produces the crate archive, three native archives, checksums, +SBOM, provenance statement, installed/smoke evidence, and complete bundle as +short-retention Actions artifacts. It has only `contents: read`; the protected +environment, OIDC permission, attestations API, crates.io, tags, and GitHub +Releases are structurally unavailable. `tag-mismatch` and `package-smoke` +manual choices are deliberate red runs used to prove that early identity or +late installed-artifact failure cannot satisfy `release-gate`. + +The tag-only `publish` job re-verifies the downloaded bundle inside the +owner-approved `release` environment. That job alone receives +`id-token: write`, `attestations: write`, and `contents: write`. It creates +Sigstore-backed GitHub build and SPDX attestations, creates or verifies an +immutable GitHub Release, verifies the API's `immutable` result, and only then +exchanges GitHub OIDC identity for a short-lived crates.io token. No repository +or environment publication secret is used. + +The crates.io Trusted Publisher must be registered with exactly `plx`, +`agentic-navigation-guide`, `release.yml`, and `release`. GitHub's repository +setting **Enable release immutability** must also be enabled for future +releases. Until both hosted settings are verified, the source mechanism is +ready for rehearsal but real publication remains blocked: OIDC exchange or +the post-release immutability assertion fails before `cargo publish`. + +## Non-publishing rehearsal + +From the Actions page, run the `Release` workflow on trusted `main` with +candidate tag `v0.2.0` and failure injection `none`. This does not create a tag +or release. Download `release-bundle` from the completed run and verify it: + +```sh +python3 scripts/release_artifacts.py verify-checksums \ + --directory target/release-bundle \ + --checksums target/release-bundle/SHA256SUMS +``` + +The full hosted rehearsal is required before a release decision. Local helper +tests are useful development evidence but do not replace native runner, +protected-environment, OIDC, or hosted artifact behavior. + +## Release and recovery runbook + +No recovery step may delete, move, recreate, or reuse a `v*` tag. + +1. Merge the independently audited candidate through protected `main`; verify + the continuity exception is active and all hosted controls match their + checked-in policies. +2. Run the non-publishing rehearsal and both deliberate failure injections. +3. Verify the exact Trusted Publisher and future-release immutability settings. +4. Create `v0.2.0` once at the exact current `main` commit. The tag-triggered + workflow must pass every gate and pause for `release` approval. +5. If a gate fails before the protected job, fix source through a new pull + request. The existing immutable tag cannot move, so that candidate is + abandoned and a new version decision is required. +6. If the protected job fails before an immutable GitHub Release exists, + correct only the hosted configuration and rerun the same failed workflow + attempt. Do not push the tag again. +7. If GitHub created a mutable release, the workflow rejects it before + crates.io authentication. Remove that release record, enable future-release + immutability, and rerun the same tagged workflow; do not delete or change + the tag. +8. If an immutable GitHub Release exists but crates.io publication failed, + configure or restore only the exact Trusted Publisher and rerun the same + tagged workflow. Recovery downloads and verifies every immutable asset + against the same checksums, commit, and ref before retrying. +9. If crates.io already contains `0.2.0`, recovery continues only when its + registry checksum exactly matches the gated `.crate`. A missing or different + checksum is a stop condition requiring an incident record; it is never + repaired by moving the tag or replacing release assets. + +The workflow intentionally publishes the immutable GitHub Release before the +crate. This ensures a missing immutability control cannot leave a crate +published from a mutable asset set. A short interval where the immutable +release exists before crates.io succeeds is recoverable from the same tag and +bundle. + ## Rust and dependency support Rust `1.85.0` is the minimum supported toolchain for the complete product: diff --git a/docs/repository-protections.md b/docs/repository-protections.md index 54b3d41..9b16e1b 100644 --- a/docs/repository-protections.md +++ b/docs/repository-protections.md @@ -99,12 +99,12 @@ Self-review is an explicit single-maintainer exception, not independent approval. The approval still creates a deliberate, auditable pause between a tag-triggered workflow and access to the environment. -Issue #63 owns the future release workflow and crates.io Trusted Publisher. -It must scope the OIDC identity to exactly +Issue #63's reviewed release workflow scopes the intended OIDC identity to +exactly `plx/agentic-navigation-guide`, that reviewed workflow filename, and the -`release` environment. Until that identity exists, the absence of publication -credentials fails closed: this issue does not install a token or publish -anything. +`release` environment. Until the matching crates.io Trusted Publisher is +registered, the absence of a short-lived publication credential fails closed. +No long-lived token is installed. ## Inspection and recurring audit diff --git a/docs/v0.2-contract.md b/docs/v0.2-contract.md index cf4241e..c12ba47 100644 --- a/docs/v0.2-contract.md +++ b/docs/v0.2-contract.md @@ -1559,11 +1559,14 @@ allowlist changes. GitHub Actions may display `if:`-guarded Windows-only steps as skipped on Unix; those are redundant named evidence after the complete suite, not skipped host-applicable tests. -The current prepared release-identity job depends on the complete -three-platform matrix, so package preparation cannot pass after a platform -failure. There is no publication workflow in v0.2 source yet; issue #63 owns -that mechanism and MUST depend on or invoke these same locked platform gates -rather than defining a weaker release-only suite. +The prepared release-identity job depends on the complete three-platform +matrix, so package preparation cannot pass after a platform failure. The +release workflow repeats these locked debug and release suites on the exact +candidate across all three platforms, repeats complete MSRV gates, and places +every identity, quality, package, native-archive, checksum, SBOM, provenance, +and downloaded-artifact result behind one fail-closed aggregate. A manual +rehearsal has no publication permission; only the exact immutable tag path may +enter the protected `release` environment and request short-lived identity. ### Decision rationale @@ -2359,7 +2362,7 @@ The release and test design is: | #58 | Added owned temporary-root subprocess harnesses with child-only environment control; made intentional current-directory coverage explicit; retained #42's deterministic transient-entry result; and bound #54/#62's empty-facade metadata and negative-consumer gates; see the [#58 audit](../audits/2026-07-27-issue-58-hermetic-tests.md) | | #59 | Added clean all-target LLVM line/branch coverage with fail-closed overall and critical-module floors; a reviewed 15-case original-blocker mutation sentinel set with complete dispositions; exact P0/P1 regression traceability; and fixed release performance/resource fixtures with a versioned no-regression reference, without restoring a public facade or adding random-input generation; see the [#59 audit](../audits/2026-07-27-issue-59-coverage-performance.md) | | #62 | Added the root-anchored 33-path package allowlist; machine-checks the exact manifest; builds, installs, and smoke-tests Cargo's unpacked package; asserts one binary and zero Rust-linkable targets; and requires the expected missing-library/unresolved-import diagnostics from a path-dependent negative consumer | -| #63 | Gate releases on no library target, the exact named binary, valid maintained documentation metadata, the packaged CLI contract, and the approved compatibility baseline | +| #63 | Added the fail-closed rehearsal and tag-only Trusted Publishing workflow; gates no library target, the exact named binary, maintained documentation metadata, packaged CLI behavior, the approved compatibility baseline, platform/MSRV suites, normalized native archives, checksums, SPDX, provenance, and immutable-release recovery | | #64 | Pinned the prepared `0.2.0` identity, consumed #54's pre-removal report, separately recorded the exact 128-entry published baseline, documented every removal and the no-shim process migration, and added fail-closed future CLI/package baseline selection | | #66 | State the CLI-only boundary and process migration concisely; remove or retarget the docs.rs badge and set package documentation metadata to maintained CLI/contract documentation | | #67 | Preserve this decision in the complete normative support and CLI documentation | @@ -2574,7 +2577,7 @@ permission to combine implementation tickets. | #58 | Use owned temporary-root subprocess harnesses, forbid process-global test mutation, retain #42's deterministic transient-entry failure, and bind #54/#62's empty-facade and rejected-consumer gates; see the [#58 audit](../audits/2026-07-27-issue-58-hermetic-tests.md) | | #59 | Measure the private engine through clean CLI/unit line and branch coverage, reviewed original-blocker mutation sentinels, exact regression traceability, and fixed release performance/resource baselines; do not represent the absent library as uncovered behavior or add a public facade; see the [#59 audit](../audits/2026-07-27-issue-59-coverage-performance.md) | | #62 | Added the exact package allowlist and CI manifest gate; installs and smoke-tests Cargo's unpacked binary; proves zero Rust-linkable targets; and requires the expected no-library import failure from an exact path-dependent consumer | -| #63 | Gate the release on exact package-target shape, maintained documentation metadata, and the complete supported CLI compatibility baseline | +| #63 | Added one fail-closed release aggregate over exact package/CLI compatibility, platform/MSRV suites, native artifact rebuild/smoke, checksums, SPDX/provenance, protected OIDC publication, and same-tag immutable recovery | | #64 | Pinned the prepared version/tag/changelog identity, the immutable published baseline and complete removal/no-shim migration, and the future compatible-line and breaking-line baseline rules | | #66 | Link the concise README to this contract, publish the no-shim process migration, remove/retarget the docs.rs badge, and set maintained package documentation metadata | | #67 | Completed this sole normative document with the machine-checked command/argument ledger, streams and exits, platform/MSRV/version support, bounded security/resource/reporting limits, maintained-doc lint/link gates, and zero pending conformance rows | diff --git a/justfile b/justfile index 6c485eb..3197bdf 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,10 @@ check-release-identity tag="v0.2.0": test-release-identity: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests/test_check_release_identity.py -v +# Run deterministic release artifact and fail-closed workflow regressions. +test-release-artifacts: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests/test_release_artifacts.py -v + # Compare live repository protections with the reviewed issue #65 payloads. audit-github-protections *args: PYTHONDONTWRITEBYTECODE=1 python3 scripts/audit_github_protections.py {{ args }} diff --git a/release/pipeline.toml b/release/pipeline.toml new file mode 100644 index 0000000..227412b --- /dev/null +++ b/release/pipeline.toml @@ -0,0 +1,24 @@ +schema = 1 +workflow_filename = "release.yml" +repository = "plx/agentic-navigation-guide" +environment = "release" +candidate_tag = "v0.2.0" +crate = "agentic-navigation-guide" +binary = "agentic-navigation-guide" +supported_runners = [ + "ubuntu-latest", + "macos-latest", + "windows-latest", +] +archive_license_files = [ + "LICENSE-APACHE", + "LICENSE-MIT", + "NOTICE", + "README.md", + "THIRD_PARTY_LICENSES.md", +] +reproducibility_claim = "same-runner-release-binary-byte-for-byte" +trusted_publisher_owner = "plx" +trusted_publisher_repository = "agentic-navigation-guide" +trusted_publisher_workflow = "release.yml" +trusted_publisher_environment = "release" diff --git a/scripts/release_artifacts.py b/scripts/release_artifacts.py new file mode 100644 index 0000000..de1c2c2 --- /dev/null +++ b/scripts/release_artifacts.py @@ -0,0 +1,887 @@ +#!/usr/bin/env python3 +"""Build and verify deterministic, traceable release artifacts.""" + +from __future__ import annotations + +import argparse +import datetime +import gzip +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import tarfile +import tempfile +import tomllib +from typing import Any, Iterable, Mapping, Sequence +import urllib.error +import urllib.request +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +IDENTITY_PATH = ROOT / "release" / "identity.toml" +PIPELINE_PATH = ROOT / "release" / "pipeline.toml" +LOCKFILE_PATH = ROOT / "Cargo.lock" +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +SPDX_RE = re.compile(r"[^A-Za-z0-9.-]+") +EXPECTED_ARCHIVE_COUNT = 3 +EXPECTED_DISTRIBUTION_COUNT = 4 + + +class ReleaseError(RuntimeError): + """A fail-closed release-artifact validation error.""" + + +def load_toml(path: Path) -> dict[str, Any]: + with path.open("rb") as handle: + return tomllib.load(handle) + + +def identity() -> dict[str, Any]: + value = load_toml(IDENTITY_PATH) + required = { + "package", + "version", + "binary", + "tag_prefix", + "license", + "supported_product", + "linkable_rust_targets", + } + missing = sorted(required.difference(value)) + if missing: + raise ReleaseError(f"release identity is missing keys: {', '.join(missing)}") + return value + + +def pipeline() -> dict[str, Any]: + value = load_toml(PIPELINE_PATH) + if value.get("schema") != 1: + raise ReleaseError("release pipeline schema must be 1") + return value + + +def expected_tag(release_identity: Mapping[str, Any]) -> str: + return f"{release_identity['tag_prefix']}{release_identity['version']}" + + +def require_tag(tag: str, release_identity: Mapping[str, Any]) -> None: + expected = expected_tag(release_identity) + if tag != expected: + raise ReleaseError(f"release tag mismatch: expected {expected!r}, observed {tag!r}") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def normalized_member(name: str) -> PurePosixPath: + member = PurePosixPath(name) + if member.is_absolute() or ".." in member.parts or not member.parts: + raise ReleaseError(f"unsafe archive member {name!r}") + return member + + +def archive_entries( + binary: Path, + release_identity: Mapping[str, Any], + host_triple: str, +) -> list[tuple[str, bytes, int]]: + configuration = pipeline() + root_name = ( + f"{release_identity['package']}-{release_identity['version']}-{host_triple}" + ) + entries = [ + ( + f"{root_name}/{binary.name}", + binary.read_bytes(), + 0o755, + ) + ] + for relative in configuration["archive_license_files"]: + source = ROOT / relative + if not source.is_file(): + raise ReleaseError(f"required archive file is missing: {relative}") + entries.append((f"{root_name}/{relative}", source.read_bytes(), 0o644)) + return sorted(entries) + + +def write_tar_gz(output: Path, entries: Iterable[tuple[str, bytes, int]]) -> None: + with output.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar: + for name, contents, mode in entries: + normalized_member(name) + info = tarfile.TarInfo(name) + info.size = len(contents) + info.mode = mode + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + tar.addfile(info, io.BytesIO(contents)) + + +def write_zip(output: Path, entries: Iterable[tuple[str, bytes, int]]) -> None: + with zipfile.ZipFile( + output, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + for name, contents, mode in entries: + normalized_member(name) + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = mode << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, contents, compresslevel=9) + + +def create_archive( + binary: Path, + comparison_binary: Path, + host_triple: str, + tag: str, + output_directory: Path, +) -> Path: + release_identity = identity() + require_tag(tag, release_identity) + for label, path in ( + ("release binary", binary), + ("comparison release binary", comparison_binary), + ): + if not path.is_file(): + raise ReleaseError(f"{label} does not exist: {path}") + first_hash = sha256_file(binary) + second_hash = sha256_file(comparison_binary) + if first_hash != second_hash: + raise ReleaseError( + "same-runner release binary is not byte reproducible: " + f"{first_hash} != {second_hash}" + ) + if not re.fullmatch(r"[A-Za-z0-9_.-]+", host_triple): + raise ReleaseError(f"invalid host triple {host_triple!r}") + + output_directory.mkdir(parents=True, exist_ok=True) + stem = ( + f"{release_identity['package']}-{release_identity['version']}-{host_triple}" + ) + entries = archive_entries(binary, release_identity, host_triple) + if "windows" in host_triple: + if binary.suffix.lower() != ".exe": + raise ReleaseError("a Windows release binary must have an .exe suffix") + output = output_directory / f"{stem}.zip" + write_zip(output, entries) + else: + if binary.suffix: + raise ReleaseError("a non-Windows release binary must not have a suffix") + output = output_directory / f"{stem}.tar.gz" + write_tar_gz(output, entries) + return output + + +def archive_members(archive: Path) -> list[str]: + if archive.name.endswith(".tar.gz"): + with tarfile.open(archive, mode="r:gz") as handle: + names = handle.getnames() + elif archive.suffix == ".zip": + with zipfile.ZipFile(archive) as handle: + names = handle.namelist() + else: + raise ReleaseError(f"unsupported release archive {archive.name!r}") + return [str(normalized_member(name)) for name in names] + + +def extract_archive(archive: Path, destination: Path) -> None: + members = archive_members(archive) + if len(members) != len(set(members)): + raise ReleaseError(f"release archive contains duplicate members: {archive}") + if archive.name.endswith(".tar.gz"): + with tarfile.open(archive, mode="r:gz") as handle: + handle.extractall(destination, filter="data") + else: + with zipfile.ZipFile(archive) as handle: + handle.extractall(destination) + + +def expected_version_line(release_identity: Mapping[str, Any]) -> str: + return f"{release_identity['binary']} {release_identity['version']}" + + +def smoke_binary(binary: Path, tag: str, inject_failure: bool = False) -> None: + release_identity = identity() + require_tag(tag, release_identity) + if not binary.is_file(): + raise ReleaseError(f"smoke-test binary does not exist: {binary}") + expected_version = expected_version_line(release_identity) + if inject_failure: + expected_version += "-injected-package-smoke-failure" + + version = subprocess.run( + [str(binary), "--version"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if version.returncode != 0: + raise ReleaseError( + f"release binary --version failed with {version.returncode}: " + f"{version.stderr.strip()}" + ) + if version.stdout.strip() != expected_version: + raise ReleaseError( + "release binary version mismatch: " + f"expected {expected_version!r}, observed {version.stdout.strip()!r}" + ) + + invalid = subprocess.run( + [str(binary), "__release_smoke_invalid_command__"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if invalid.returncode != 2: + raise ReleaseError( + "release binary failure smoke returned " + f"{invalid.returncode}, expected Clap usage status 2" + ) + combined = f"{invalid.stdout}\n{invalid.stderr}" + if "__release_smoke_invalid_command__" not in combined or "Usage:" not in combined: + raise ReleaseError("release binary failure smoke omitted safe usage diagnostics") + + +def smoke_archive(archive: Path, tag: str, inject_failure: bool = False) -> None: + release_identity = identity() + require_tag(tag, release_identity) + members = archive_members(archive) + required_names = set(pipeline()["archive_license_files"]) + root_names = {PurePosixPath(member).parts[0] for member in members} + if len(root_names) != 1: + raise ReleaseError("release archive must contain exactly one root directory") + root_name = next(iter(root_names)) + expected_root_prefix = ( + f"{release_identity['package']}-{release_identity['version']}-" + ) + if not root_name.startswith(expected_root_prefix): + raise ReleaseError( + f"release archive root {root_name!r} does not match {expected_root_prefix!r}" + ) + leaf_names = {str(PurePosixPath(member).relative_to(root_name)) for member in members} + binary_name = str(release_identity["binary"]) + if archive.suffix == ".zip": + binary_name += ".exe" + expected_members = required_names.union({binary_name}) + if leaf_names != expected_members: + raise ReleaseError( + "release archive member set mismatch: " + f"expected {sorted(expected_members)!r}, observed {sorted(leaf_names)!r}" + ) + + with tempfile.TemporaryDirectory(prefix="release-archive-smoke-") as temporary: + destination = Path(temporary) + extract_archive(archive, destination) + binary = destination / root_name / binary_name + if os.name != "nt": + binary.chmod(0o755) + smoke_binary(binary, tag, inject_failure) + + +def checksum_lines(files: Sequence[Path]) -> list[str]: + if not files: + raise ReleaseError("at least one checksum subject is required") + by_name: dict[str, Path] = {} + for path in files: + if not path.is_file(): + raise ReleaseError(f"checksum subject does not exist: {path}") + if path.name in by_name: + raise ReleaseError(f"duplicate checksum subject name: {path.name}") + if "\n" in path.name or "\r" in path.name: + raise ReleaseError(f"unsafe checksum subject name: {path.name!r}") + by_name[path.name] = path + return [f"{sha256_file(by_name[name])} {name}" for name in sorted(by_name)] + + +def write_checksums(output: Path, files: Sequence[Path]) -> None: + lines = checksum_lines(files) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") + + +def parse_checksums(path: Path) -> dict[str, str]: + checksums: dict[str, str] = {} + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), + start=1, + ): + digest, separator, name = line.partition(" ") + if not separator or not SHA256_RE.fullmatch(digest): + raise ReleaseError(f"invalid checksum line {line_number}") + member = normalized_member(name) + if len(member.parts) != 1: + raise ReleaseError(f"checksum subject must be a basename: {name!r}") + if name in checksums: + raise ReleaseError(f"duplicate checksum subject: {name}") + checksums[name] = digest + if not checksums: + raise ReleaseError("checksum manifest is empty") + return checksums + + +def verify_checksums(directory: Path, checksums_path: Path) -> dict[str, str]: + checksums = parse_checksums(checksums_path) + for name, expected in checksums.items(): + subject = directory / name + if not subject.is_file(): + raise ReleaseError(f"checksum subject is missing: {name}") + observed = sha256_file(subject) + if observed != expected: + raise ReleaseError( + f"checksum mismatch for {name}: expected {expected}, observed {observed}" + ) + return checksums + + +def spdx_id(*parts: str) -> str: + value = "-".join(parts) + normalized = SPDX_RE.sub("-", value).strip("-") + if not normalized: + normalized = hashlib.sha256(value.encode()).hexdigest() + return f"SPDXRef-{normalized}" + + +def source_date() -> str: + raw_epoch = os.environ.get("SOURCE_DATE_EPOCH", "0") + try: + epoch = int(raw_epoch) + except ValueError as error: + raise ReleaseError("SOURCE_DATE_EPOCH must be an integer") from error + if epoch < 0: + raise ReleaseError("SOURCE_DATE_EPOCH must not be negative") + return datetime.datetime.fromtimestamp( + epoch, + tz=datetime.timezone.utc, + ).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def cargo_metadata() -> dict[str, Any]: + result = subprocess.run( + ["cargo", "metadata", "--locked", "--format-version", "1"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + raise ReleaseError(f"cargo metadata failed: {result.stderr.strip()}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise ReleaseError("cargo metadata returned invalid JSON") from error + + +def lock_checksums() -> dict[tuple[str, str], str]: + lock = load_toml(LOCKFILE_PATH) + result: dict[tuple[str, str], str] = {} + for package in lock.get("package", []): + checksum = package.get("checksum") + if isinstance(checksum, str) and SHA256_RE.fullmatch(checksum): + result[(str(package["name"]), str(package["version"]))] = checksum + return result + + +def build_sbom(commit: str) -> dict[str, Any]: + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ReleaseError("SBOM commit must be a full lowercase Git SHA") + metadata = cargo_metadata() + release_identity = identity() + root_id = metadata.get("resolve", {}).get("root") + packages = metadata.get("packages", []) + package_by_id = {str(package["id"]): package for package in packages} + if root_id not in package_by_id: + raise ReleaseError("cargo metadata has no root package") + lock_hashes = lock_checksums() + document_namespace = ( + "https://github.com/plx/agentic-navigation-guide/" + f"sbom/{release_identity['version']}/{commit}" + ) + spdx_packages = [] + id_map: dict[str, str] = {} + for package_id in sorted(package_by_id): + package = package_by_id[package_id] + name = str(package["name"]) + version = str(package["version"]) + source = package.get("source") + package_spdx_id = spdx_id(name, version, hashlib.sha256(package_id.encode()).hexdigest()[:12]) + id_map[package_id] = package_spdx_id + item: dict[str, Any] = { + "SPDXID": package_spdx_id, + "name": name, + "versionInfo": version, + "downloadLocation": str(source) if source else "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": package.get("license") or "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": f"pkg:cargo/{name}@{version}", + } + ], + } + checksum = lock_hashes.get((name, version)) + if checksum: + item["checksums"] = [{"algorithm": "SHA256", "checksumValue": checksum}] + spdx_packages.append(item) + + relationships = [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": id_map[str(root_id)], + } + ] + for node in sorted( + metadata.get("resolve", {}).get("nodes", []), + key=lambda value: str(value["id"]), + ): + source_id = str(node["id"]) + if source_id not in id_map: + continue + for dependency in sorted(str(value) for value in node.get("dependencies", [])): + if dependency in id_map: + relationships.append( + { + "spdxElementId": id_map[source_id], + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": id_map[dependency], + } + ) + + return { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": f"{release_identity['package']}-{release_identity['version']}", + "documentNamespace": document_namespace, + "creationInfo": { + "created": source_date(), + "creators": ["Tool: scripts/release_artifacts.py"], + "licenseListVersion": "3.27", + }, + "documentDescribes": [id_map[str(root_id)]], + "packages": spdx_packages, + "relationships": relationships, + } + + +def write_json(path: Path, value: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", + ) + + +def build_provenance( + checksums_path: Path, + commit: str, + ref: str, + run_id: str, + run_attempt: str, +) -> dict[str, Any]: + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ReleaseError("provenance commit must be a full lowercase Git SHA") + if not ref.startswith("refs/"): + raise ReleaseError("provenance ref must start with refs/") + release_identity = identity() + subjects = [ + {"name": name, "digest": {"sha256": digest}} + for name, digest in sorted(parse_checksums(checksums_path).items()) + ] + return { + "_type": "https://in-toto.io/Statement/v1", + "subject": subjects, + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": ( + "https://github.com/plx/agentic-navigation-guide/" + "blob/main/docs/release-policy.md#release-workflow" + ), + "externalParameters": { + "ref": ref, + "tag": expected_tag(release_identity), + "workflow": pipeline()["workflow_filename"], + }, + "internalParameters": { + "runId": str(run_id), + "runAttempt": str(run_attempt), + }, + "resolvedDependencies": [ + { + "uri": ( + "git+https://github.com/plx/" + "agentic-navigation-guide.git" + ), + "digest": {"gitCommit": commit}, + } + ], + }, + "runDetails": { + "builder": { + "id": ( + "https://github.com/plx/agentic-navigation-guide/" + "actions/workflows/release.yml" + ) + }, + "metadata": { + "invocationId": ( + "https://github.com/plx/agentic-navigation-guide/" + f"actions/runs/{run_id}/attempts/{run_attempt}" + ) + }, + }, + }, + } + + +def verify_sbom(path: Path, commit: str) -> None: + value = json.loads(path.read_text(encoding="utf-8")) + release_identity = identity() + if value.get("spdxVersion") != "SPDX-2.3": + raise ReleaseError("release SBOM is not SPDX 2.3") + expected_namespace_suffix = f"/{release_identity['version']}/{commit}" + if not str(value.get("documentNamespace", "")).endswith( + expected_namespace_suffix + ): + raise ReleaseError("release SBOM does not identify the candidate commit") + root_packages = [ + package + for package in value.get("packages", []) + if package.get("name") == release_identity["package"] + and package.get("versionInfo") == release_identity["version"] + ] + if len(root_packages) != 1: + raise ReleaseError("release SBOM must describe the exact root package once") + if not value.get("relationships"): + raise ReleaseError("release SBOM has no dependency relationships") + + +def verify_provenance( + path: Path, + checksums: Mapping[str, str], + commit: str, + ref: str, +) -> None: + value = json.loads(path.read_text(encoding="utf-8")) + if value.get("_type") != "https://in-toto.io/Statement/v1": + raise ReleaseError("release provenance is not an in-toto v1 statement") + if value.get("predicateType") != "https://slsa.dev/provenance/v1": + raise ReleaseError("release provenance is not a SLSA v1 predicate") + subjects = { + str(subject.get("name")): str(subject.get("digest", {}).get("sha256")) + for subject in value.get("subject", []) + } + if subjects != dict(checksums): + raise ReleaseError("release provenance subjects differ from checksums") + definition = value.get("predicate", {}).get("buildDefinition", {}) + dependencies = definition.get("resolvedDependencies", []) + if dependencies != [ + { + "uri": "git+https://github.com/plx/agentic-navigation-guide.git", + "digest": {"gitCommit": commit}, + } + ]: + raise ReleaseError("release provenance does not resolve the exact commit") + if definition.get("externalParameters", {}).get("ref") != ref: + raise ReleaseError("release provenance does not resolve the exact ref") + + +def verify_bundle( + directory: Path, + checksums_path: Path, + sbom_path: Path, + provenance_path: Path, + tag: str, + commit: str, + ref: str, +) -> None: + release_identity = identity() + require_tag(tag, release_identity) + checksums = verify_checksums(directory, checksums_path) + archive_names = [ + name + for name in checksums + if name.endswith(".tar.gz") or name.endswith(".zip") + ] + crate_name = f"{release_identity['package']}-{release_identity['version']}.crate" + if len(archive_names) != EXPECTED_ARCHIVE_COUNT: + raise ReleaseError( + f"release bundle must contain {EXPECTED_ARCHIVE_COUNT} native archives" + ) + if crate_name not in checksums: + raise ReleaseError(f"release bundle is missing {crate_name}") + if len(checksums) != EXPECTED_DISTRIBUTION_COUNT + 1: + raise ReleaseError( + "release checksums must cover three native archives, one crate, and one SBOM" + ) + if sbom_path.name not in checksums: + raise ReleaseError("release checksums do not cover the SBOM") + verify_sbom(sbom_path, commit) + verify_provenance(provenance_path, checksums, commit, ref) + for archive_name in archive_names: + members = archive_members(directory / archive_name) + for required in pipeline()["archive_license_files"]: + if not any(member.endswith(f"/{required}") for member in members): + raise ReleaseError(f"{archive_name} omits required {required}") + + +def crates_version_state(crate_archive: Path) -> str: + release_identity = identity() + expected_name = ( + f"{release_identity['package']}-{release_identity['version']}.crate" + ) + if crate_archive.name != expected_name: + raise ReleaseError( + f"crate archive must be named {expected_name!r}, observed {crate_archive.name!r}" + ) + url = ( + "https://crates.io/api/v1/crates/" + f"{release_identity['package']}/{release_identity['version']}" + ) + request = urllib.request.Request( + url, + headers={ + "User-Agent": ( + "agentic-navigation-guide-release-workflow/0.2 " + "(plxgithub@gmail.com)" + ) + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + return "publish-required" + raise ReleaseError( + f"crates.io version lookup failed with HTTP {error.code}" + ) from error + except urllib.error.URLError as error: + raise ReleaseError(f"crates.io version lookup failed: {error.reason}") from error + published = payload.get("version", {}).get("checksum") + observed = sha256_file(crate_archive) + if published != observed: + raise ReleaseError( + "crates.io already contains this version with a different archive checksum" + ) + return "already-published-matching" + + +def output_github(name: str, value: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT") + if output_path: + with Path(output_path).open("a", encoding="utf-8", newline="\n") as handle: + handle.write(f"{name}={value}\n") + + +def command_archive(arguments: argparse.Namespace) -> None: + archive = create_archive( + arguments.binary, + arguments.comparison_binary, + arguments.host_triple, + arguments.tag, + arguments.output_directory, + ) + output_github("archive", archive.name) + print( + json.dumps( + { + "archive": archive.name, + "sha256": sha256_file(archive), + "reproducible": True, + }, + sort_keys=True, + ) + ) + + +def command_host_triple(_: argparse.Namespace) -> None: + result = subprocess.run( + ["rustc", "-vV"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise ReleaseError(f"rustc -vV failed: {result.stderr.strip()}") + host_lines = [ + line.removeprefix("host: ").strip() + for line in result.stdout.splitlines() + if line.startswith("host: ") + ] + if len(host_lines) != 1: + raise ReleaseError("rustc -vV did not report exactly one host triple") + host = host_lines[0] + if not re.fullmatch(r"[A-Za-z0-9_.-]+", host): + raise ReleaseError(f"rustc reported an invalid host triple {host!r}") + output_github("host-triple", host) + print(host) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + host = commands.add_parser("host-triple") + host.set_defaults(handler=command_host_triple) + + archive = commands.add_parser("archive") + archive.add_argument("--binary", type=Path, required=True) + archive.add_argument("--comparison-binary", type=Path, required=True) + archive.add_argument("--host-triple", required=True) + archive.add_argument("--tag", required=True) + archive.add_argument("--output-directory", type=Path, required=True) + archive.set_defaults(handler=command_archive) + + smoke_binary_parser = commands.add_parser("smoke-binary") + smoke_binary_parser.add_argument("--binary", type=Path, required=True) + smoke_binary_parser.add_argument("--tag", required=True) + smoke_binary_parser.add_argument("--inject-failure", action="store_true") + smoke_binary_parser.set_defaults( + handler=lambda arguments: smoke_binary( + arguments.binary, + arguments.tag, + arguments.inject_failure, + ) + ) + + smoke_archive_parser = commands.add_parser("smoke-archive") + smoke_archive_parser.add_argument("--archive", type=Path, required=True) + smoke_archive_parser.add_argument("--tag", required=True) + smoke_archive_parser.add_argument("--inject-failure", action="store_true") + smoke_archive_parser.set_defaults( + handler=lambda arguments: smoke_archive( + arguments.archive, + arguments.tag, + arguments.inject_failure, + ) + ) + + checksums = commands.add_parser("checksums") + checksums.add_argument("--output", type=Path, required=True) + checksums.add_argument("files", nargs="+", type=Path) + checksums.set_defaults( + handler=lambda arguments: write_checksums(arguments.output, arguments.files) + ) + + verify = commands.add_parser("verify-checksums") + verify.add_argument("--directory", type=Path, required=True) + verify.add_argument("--checksums", type=Path, required=True) + verify.set_defaults( + handler=lambda arguments: verify_checksums( + arguments.directory, + arguments.checksums, + ) + ) + + sbom = commands.add_parser("sbom") + sbom.add_argument("--commit", required=True) + sbom.add_argument("--output", type=Path, required=True) + sbom.set_defaults( + handler=lambda arguments: write_json( + arguments.output, + build_sbom(arguments.commit), + ) + ) + + provenance = commands.add_parser("provenance") + provenance.add_argument("--checksums", type=Path, required=True) + provenance.add_argument("--commit", required=True) + provenance.add_argument("--ref", required=True) + provenance.add_argument("--run-id", required=True) + provenance.add_argument("--run-attempt", required=True) + provenance.add_argument("--output", type=Path, required=True) + provenance.set_defaults( + handler=lambda arguments: write_json( + arguments.output, + build_provenance( + arguments.checksums, + arguments.commit, + arguments.ref, + arguments.run_id, + arguments.run_attempt, + ), + ) + ) + + bundle = commands.add_parser("verify-bundle") + bundle.add_argument("--directory", type=Path, required=True) + bundle.add_argument("--checksums", type=Path, required=True) + bundle.add_argument("--sbom", type=Path, required=True) + bundle.add_argument("--provenance", type=Path, required=True) + bundle.add_argument("--tag", required=True) + bundle.add_argument("--commit", required=True) + bundle.add_argument("--ref", required=True) + bundle.set_defaults( + handler=lambda arguments: verify_bundle( + arguments.directory, + arguments.checksums, + arguments.sbom, + arguments.provenance, + arguments.tag, + arguments.commit, + arguments.ref, + ) + ) + + state = commands.add_parser("crates-version-state") + state.add_argument("--crate-archive", type=Path, required=True) + + def handle_state(arguments: argparse.Namespace) -> None: + value = crates_version_state(arguments.crate_archive) + output_github("state", value) + print(value) + + state.set_defaults(handler=handle_state) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + arguments = parser.parse_args(argv) + try: + arguments.handler(arguments) + except ( + ReleaseError, + OSError, + json.JSONDecodeError, + subprocess.SubprocessError, + tarfile.TarError, + zipfile.BadZipFile, + ) as error: + print(f"release artifact validation failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py new file mode 100644 index 0000000..891f2dd --- /dev/null +++ b/tests/test_release_artifacts.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "release_artifacts.py" +SPEC = importlib.util.spec_from_file_location("release_artifacts", SCRIPT) +assert SPEC and SPEC.loader +RELEASE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RELEASE) +COMMIT = "1" * 40 + + +class ReleaseArtifactsTests(unittest.TestCase): + def test_wrong_tag_fails_before_artifact_access(self) -> None: + missing = Path("/definitely/missing/release-binary") + with tempfile.TemporaryDirectory() as temporary: + with self.assertRaisesRegex(RELEASE.ReleaseError, "release tag mismatch"): + RELEASE.create_archive( + missing, + missing, + "x86_64-unknown-linux-gnu", + "v9.9.9", + Path(temporary), + ) + + def test_mismatched_rebuild_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + first = directory / "agentic-navigation-guide" + second = directory / "comparison" + first.write_bytes(b"first") + second.write_bytes(b"second") + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "not byte reproducible", + ): + RELEASE.create_archive( + first, + second, + "x86_64-unknown-linux-gnu", + "v0.2.0", + directory / "dist", + ) + + def test_normalized_archive_is_byte_reproducible_and_complete(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + binary = directory / "agentic-navigation-guide" + comparison = directory / "comparison" + binary.write_bytes(b"reviewed release binary") + comparison.write_bytes(binary.read_bytes()) + first = RELEASE.create_archive( + binary, + comparison, + "x86_64-unknown-linux-gnu", + "v0.2.0", + directory / "first", + ) + second = RELEASE.create_archive( + binary, + comparison, + "x86_64-unknown-linux-gnu", + "v0.2.0", + directory / "second", + ) + self.assertEqual(first.read_bytes(), second.read_bytes()) + members = RELEASE.archive_members(first) + expected_leaves = { + "agentic-navigation-guide", + "LICENSE-APACHE", + "LICENSE-MIT", + "NOTICE", + "README.md", + "THIRD_PARTY_LICENSES.md", + } + self.assertEqual( + {Path(member).name for member in members}, + expected_leaves, + ) + + def test_checksum_verification_rejects_tampering_and_traversal(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + subject = directory / "artifact.tar.gz" + subject.write_bytes(b"trusted") + checksums = directory / "SHA256SUMS" + RELEASE.write_checksums(checksums, [subject]) + self.assertEqual( + RELEASE.verify_checksums(directory, checksums), + {"artifact.tar.gz": hashlib.sha256(b"trusted").hexdigest()}, + ) + subject.write_bytes(b"tampered") + with self.assertRaisesRegex(RELEASE.ReleaseError, "checksum mismatch"): + RELEASE.verify_checksums(directory, checksums) + checksums.write_text(f"{'0' * 64} ../escape\n", encoding="utf-8") + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "unsafe archive member", + ): + RELEASE.parse_checksums(checksums) + + def test_smoke_injection_deliberately_blocks_an_otherwise_valid_binary( + self, + ) -> None: + version = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="agentic-navigation-guide 0.2.0\n", + stderr="", + ) + invalid = subprocess.CompletedProcess( + args=[], + returncode=2, + stdout="", + stderr=( + "error: unrecognized subcommand " + "'__release_smoke_invalid_command__'\n" + "Usage: agentic-navigation-guide\n" + ), + ) + with tempfile.TemporaryDirectory() as temporary: + binary = Path(temporary) / "agentic-navigation-guide" + binary.write_bytes(b"placeholder") + with mock.patch.object( + RELEASE.subprocess, + "run", + side_effect=[version, invalid], + ): + RELEASE.smoke_binary(binary, "v0.2.0") + with mock.patch.object( + RELEASE.subprocess, + "run", + return_value=version, + ): + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "version mismatch", + ): + RELEASE.smoke_binary( + binary, + "v0.2.0", + inject_failure=True, + ) + + def test_provenance_binds_exact_checksums_commit_and_ref(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + checksums = directory / "SHA256SUMS" + checksums.write_text(f"{'a' * 64} artifact.tar.gz\n", encoding="utf-8") + value = RELEASE.build_provenance( + checksums, + COMMIT, + "refs/heads/main", + "100", + "2", + ) + path = directory / "provenance.json" + RELEASE.write_json(path, value) + RELEASE.verify_provenance( + path, + {"artifact.tar.gz": "a" * 64}, + COMMIT, + "refs/heads/main", + ) + value["predicate"]["buildDefinition"]["resolvedDependencies"][0][ + "digest" + ]["gitCommit"] = "2" * 40 + RELEASE.write_json(path, value) + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "exact commit", + ): + RELEASE.verify_provenance( + path, + {"artifact.tar.gz": "a" * 64}, + COMMIT, + "refs/heads/main", + ) + + def test_sbom_has_one_root_and_resolved_dependencies(self) -> None: + root_id = "path+file:///root#agentic-navigation-guide@0.2.0" + dependency_id = ( + "registry+https://github.com/rust-lang/crates.io-index#clap@4.5.0" + ) + metadata = { + "packages": [ + { + "id": root_id, + "name": "agentic-navigation-guide", + "version": "0.2.0", + "source": None, + "license": "MIT OR Apache-2.0", + }, + { + "id": dependency_id, + "name": "clap", + "version": "4.5.0", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "license": "MIT OR Apache-2.0", + }, + ], + "resolve": { + "root": root_id, + "nodes": [ + {"id": root_id, "dependencies": [dependency_id]}, + {"id": dependency_id, "dependencies": []}, + ], + }, + } + with ( + mock.patch.object(RELEASE, "cargo_metadata", return_value=metadata), + mock.patch.object( + RELEASE, + "lock_checksums", + return_value={("clap", "4.5.0"): "b" * 64}, + ), + mock.patch.dict(RELEASE.os.environ, {"SOURCE_DATE_EPOCH": "0"}), + ): + sbom = RELEASE.build_sbom(COMMIT) + self.assertEqual(sbom["spdxVersion"], "SPDX-2.3") + self.assertEqual(sbom["creationInfo"]["created"], "1970-01-01T00:00:00Z") + self.assertEqual(len(sbom["packages"]), 2) + self.assertEqual( + {relationship["relationshipType"] for relationship in sbom["relationships"]}, + {"DESCRIBES", "DEPENDS_ON"}, + ) + + def test_release_workflow_has_one_fail_closed_gate_and_publish_boundary( + self, + ) -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + gate = workflow.split(" release-gate:", 1)[1].split( + " rehearsal:", + 1, + )[0] + publish = workflow.split(" publish:", 1)[1] + before_publish = workflow.split(" publish:", 1)[0] + + for prerequisite in [ + "- identity", + "- quality", + "- platform-tests", + "- msrv", + "- package", + "- native-archives", + "- assemble", + ]: + self.assertIn(prerequisite, gate) + self.assertIn("all(.[]; .result == \"success\")", gate) + self.assertNotIn("id-token: write", before_publish) + self.assertIn("environment:\n name: release", publish) + self.assertIn("id-token: write", publish) + self.assertIn("rust-lang/crates-io-auth-action@", publish) + self.assertNotIn("secrets.", publish) + self.assertIn(".immutable == true", publish) + self.assertIn("steps.crate-state.outputs.state == 'publish-required'", publish) + self.assertIn("github.event_name == 'push'", publish) + + def test_pipeline_identity_is_exact_and_personal(self) -> None: + configuration = RELEASE.pipeline() + expected = { + "repository": "plx/agentic-navigation-guide", + "environment": "release", + "candidate_tag": "v0.2.0", + "trusted_publisher_owner": "plx", + "trusted_publisher_repository": "agentic-navigation-guide", + "trusted_publisher_workflow": "release.yml", + "trusted_publisher_environment": "release", + } + self.assertEqual( + {key: configuration[key] for key in expected}, + expected, + ) + + +if __name__ == "__main__": + unittest.main() From 711c9750d4dd0e6d065f9cd8e193422d421351c0 Mon Sep 17 00:00:00 2001 From: plx Date: Mon, 27 Jul 2026 12:42:29 -0500 Subject: [PATCH 2/6] Run temporary hosted release rehearsal --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba3c42d..9c31fad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,9 @@ name: Release on: + # Temporary issue-#63 hosted rehearsal trigger; remove after PR evidence. + pull_request: + branches: [main] push: tags: - "v*" From 937b353b07cf28ebdfa47f952686bf6ef2514a48 Mon Sep 17 00:00:00 2001 From: plx Date: Mon, 27 Jul 2026 12:43:53 -0500 Subject: [PATCH 3/6] Tighten release bundle recovery checks --- .github/workflows/release.yml | 8 +++++++- scripts/release_artifacts.py | 23 +++++++++++++++++++++++ tests/test_release_artifacts.py | 27 +++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c31fad..06bd9f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ on: - package-smoke env: - CANDIDATE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_tag || github.ref_name }} + CANDIDATE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_tag || github.event_name == 'pull_request' && 'v0.2.0' || github.ref_name }} CARGO_TERM_COLOR: always SOURCE_REF: ${{ github.ref }} @@ -542,6 +542,12 @@ jobs: python3 scripts/release_artifacts.py crates-version-state --crate-archive target/release-bundle/agentic-navigation-guide-0.2.0.crate + - name: Prove Cargo rebuilds the exact gated crate bytes + shell: bash + run: | + cargo package --locked + cmp target/release-bundle/agentic-navigation-guide-0.2.0.crate \ + target/package/agentic-navigation-guide-0.2.0.crate - name: Exchange GitHub OIDC identity for a short-lived crates.io token if: steps.crate-state.outputs.state == 'publish-required' id: crates-auth diff --git a/scripts/release_artifacts.py b/scripts/release_artifacts.py index de1c2c2..7c5d11e 100644 --- a/scripts/release_artifacts.py +++ b/scripts/release_artifacts.py @@ -626,6 +626,29 @@ def verify_bundle( release_identity = identity() require_tag(tag, release_identity) checksums = verify_checksums(directory, checksums_path) + observed_files = { + entry.name + for entry in directory.iterdir() + if entry.is_file() + } + unexpected_non_files = sorted( + entry.name + for entry in directory.iterdir() + if not entry.is_file() + ) + expected_files = set(checksums).union( + { + checksums_path.name, + provenance_path.name, + } + ) + if observed_files != expected_files or unexpected_non_files: + raise ReleaseError( + "release bundle file set mismatch: " + f"expected {sorted(expected_files)!r}, " + f"observed files {sorted(observed_files)!r}, " + f"non-files {unexpected_non_files!r}" + ) archive_names = [ name for name in checksums diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 891f2dd..58cd535 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -186,6 +186,33 @@ def test_provenance_binds_exact_checksums_commit_and_ref(self) -> None: "refs/heads/main", ) + def test_bundle_rejects_an_unreviewed_extra_asset(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + checksums = directory / "SHA256SUMS" + provenance = directory / "provenance.json" + (directory / "expected.bin").write_bytes(b"expected") + expected_hash = hashlib.sha256(b"expected").hexdigest() + checksums.write_text( + f"{expected_hash} expected.bin\n", + encoding="utf-8", + ) + provenance.write_text("{}\n", encoding="utf-8") + (directory / "unreviewed.bin").write_bytes(b"unexpected") + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "release bundle file set mismatch", + ): + RELEASE.verify_bundle( + directory, + checksums, + directory / "expected.bin", + provenance, + "v0.2.0", + COMMIT, + "refs/heads/main", + ) + def test_sbom_has_one_root_and_resolved_dependencies(self) -> None: root_id = "path+file:///root#agentic-navigation-guide@0.2.0" dependency_id = ( From 1b46d528cb9beb27bf2e74dbfdc7f5a038b1eb20 Mon Sep 17 00:00:00 2001 From: plx Date: Mon, 27 Jul 2026 12:56:40 -0500 Subject: [PATCH 4/6] Harden release pipeline review boundaries --- .github/workflows/release.yml | 24 ++-- scripts/release_artifacts.py | 76 ++++++++++++ tests/test_release_artifacts.py | 211 ++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06bd9f6..6e46389 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,7 @@ jobs: if [[ "$FAILURE_INJECTION" == "tag-mismatch" ]]; then checked_tag="v999.0.0-injected-mismatch" fi + python3 scripts/release_artifacts.py check-config python3 scripts/check_release_identity.py --tag "$checked_tag" - name: Require the exact checked-out commit and immutable tag source shell: bash @@ -173,7 +174,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: @@ -272,7 +273,7 @@ jobs: - name: Rehearse Cargo publication without external state run: cargo publish --dry-run --locked - name: Upload the exact crate archive - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-crate path: target/package/agentic-navigation-guide-0.2.0.crate @@ -289,7 +290,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: @@ -308,9 +309,18 @@ jobs: - name: Build the release binary twice in isolated target directories shell: bash run: | - CARGO_TARGET_DIR="$RUNNER_TEMP/release-a" \ + release_rustflags="$RUSTFLAGS" + if [[ "$RUNNER_OS" == "Windows" ]]; then + # MSVC's linker otherwise records timestamps in PE/COFF output. + release_rustflags="$release_rustflags -C link-arg=/Brepro" + fi + export SOURCE_DATE_EPOCH + SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")" + RUSTFLAGS="$release_rustflags" \ + CARGO_TARGET_DIR="$RUNNER_TEMP/release-a" \ cargo build --release --locked --bin agentic-navigation-guide - CARGO_TARGET_DIR="$RUNNER_TEMP/release-b" \ + RUSTFLAGS="$release_rustflags" \ + CARGO_TARGET_DIR="$RUNNER_TEMP/release-b" \ cargo build --release --locked --bin agentic-navigation-guide - name: Require byte-identical binaries and create a normalized archive env: @@ -333,7 +343,7 @@ jobs: --archive "${archive[0]}" \ --tag "$CANDIDATE_TAG" - name: Upload the exact native archive - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-native-${{ runner.os }} path: target/release-dist @@ -404,7 +414,7 @@ jobs: --commit "$GITHUB_SHA" --ref "$SOURCE_REF" - name: Upload the complete non-publishing rehearsal bundle - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-bundle path: target/release-bundle diff --git a/scripts/release_artifacts.py b/scripts/release_artifacts.py index 7c5d11e..11fdf49 100644 --- a/scripts/release_artifacts.py +++ b/scripts/release_artifacts.py @@ -26,6 +26,7 @@ ROOT = Path(__file__).resolve().parents[1] IDENTITY_PATH = ROOT / "release" / "identity.toml" PIPELINE_PATH = ROOT / "release" / "pipeline.toml" +MANIFEST_PATH = ROOT / "Cargo.toml" LOCKFILE_PATH = ROOT / "Cargo.lock" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SPDX_RE = re.compile(r"[^A-Za-z0-9.-]+") @@ -63,9 +64,81 @@ def pipeline() -> dict[str, Any]: value = load_toml(PIPELINE_PATH) if value.get("schema") != 1: raise ReleaseError("release pipeline schema must be 1") + release_identity = identity() + manifest = load_toml(MANIFEST_PATH).get("package", {}) + repository_url = str(manifest.get("repository", "")) + repository_prefix = "https://github.com/" + if not repository_url.startswith(repository_prefix): + raise ReleaseError("Cargo package repository must be a GitHub HTTPS URL") + manifest_repository = repository_url.removeprefix(repository_prefix).removesuffix( + ".git" + ) + repository = str(value.get("repository", "")) + owner, separator, repository_name = repository.partition("/") + expected = { + "repository": manifest_repository, + "candidate_tag": expected_tag(release_identity), + "crate": release_identity["package"], + "binary": release_identity["binary"], + "trusted_publisher_owner": owner, + "trusted_publisher_repository": repository_name, + "trusted_publisher_workflow": value.get("workflow_filename"), + "trusted_publisher_environment": value.get("environment"), + } + for key, expected_value in expected.items(): + if value.get(key) != expected_value: + raise ReleaseError( + f"release pipeline {key}: expected {expected_value!r}, " + f"observed {value.get(key)!r}" + ) + if not separator or not owner or not repository_name: + raise ReleaseError("release pipeline repository must be owner/name") + workflow_filename = str(value.get("workflow_filename", "")) + if ( + PurePosixPath(workflow_filename).name != workflow_filename + or not workflow_filename.endswith(".yml") + ): + raise ReleaseError("release pipeline workflow filename must be one .yml basename") + if value.get("supported_runners") != [ + "ubuntu-latest", + "macos-latest", + "windows-latest", + ]: + raise ReleaseError("release pipeline supported runner matrix drifted") + if value.get("reproducibility_claim") != ( + "same-runner-release-binary-byte-for-byte" + ): + raise ReleaseError("release pipeline reproducibility claim drifted") return value +def check_pipeline_workflow() -> None: + configuration = pipeline() + workflow_path = ROOT / ".github" / "workflows" / configuration["workflow_filename"] + workflow = workflow_path.read_text(encoding="utf-8") + matrix = ", ".join(configuration["supported_runners"]) + required_fragments = { + "candidate tag default": f"default: {configuration['candidate_tag']}", + "protected environment": ( + f"environment:\n name: {configuration['environment']}" + ), + "supported runner matrix": f"os: [{matrix}]", + "Windows reproducibility linker": "-C link-arg=/Brepro", + "Trusted Publisher action": "rust-lang/crates-io-auth-action@", + "immutable release assertion": ".immutable == true", + } + for label, fragment in required_fragments.items(): + if fragment not in workflow: + raise ReleaseError( + f"release workflow omits configured {label}: {fragment!r}" + ) + if workflow.count(required_fragments["supported runner matrix"]) != 2: + raise ReleaseError( + "release workflow must use the configured runner matrix for tests " + "and native archives" + ) + + def expected_tag(release_identity: Mapping[str, Any]) -> str: return f"{release_identity['tag_prefix']}{release_identity['version']}" @@ -774,6 +847,9 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) + check_config = commands.add_parser("check-config") + check_config.set_defaults(handler=lambda _: check_pipeline_workflow()) + host = commands.add_parser("host-triple") host.set_defaults(handler=command_host_triple) diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 58cd535..44610d6 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -2,12 +2,14 @@ import hashlib import importlib.util +import io import json from pathlib import Path import subprocess import tempfile import unittest from unittest import mock +import urllib.error ROOT = Path(__file__).resolve().parents[1] @@ -108,6 +110,32 @@ def test_checksum_verification_rejects_tampering_and_traversal(self) -> None: ): RELEASE.parse_checksums(checksums) + def test_checksums_reject_missing_subjects_and_duplicate_basenames(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + first_directory = directory / "first" + second_directory = directory / "second" + first_directory.mkdir() + second_directory.mkdir() + first = first_directory / "artifact.bin" + second = second_directory / "artifact.bin" + first.write_bytes(b"first") + second.write_bytes(b"second") + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "duplicate checksum subject name", + ): + RELEASE.checksum_lines([first, second]) + + checksums = directory / "SHA256SUMS" + RELEASE.write_checksums(checksums, [first]) + first.unlink() + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "checksum subject is missing", + ): + RELEASE.verify_checksums(first_directory, checksums) + def test_smoke_injection_deliberately_blocks_an_otherwise_valid_binary( self, ) -> None: @@ -151,6 +179,69 @@ def test_smoke_injection_deliberately_blocks_an_otherwise_valid_binary( inject_failure=True, ) + def test_smoke_archive_extracts_the_exact_member_set(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + binary = directory / "agentic-navigation-guide" + comparison = directory / "comparison" + binary.write_bytes(b"native executable") + comparison.write_bytes(binary.read_bytes()) + archive = RELEASE.create_archive( + binary, + comparison, + "x86_64-unknown-linux-gnu", + "v0.2.0", + directory / "dist", + ) + + def inspect_extracted( + extracted_binary: Path, + tag: str, + inject_failure: bool, + ) -> None: + self.assertEqual(extracted_binary.read_bytes(), b"native executable") + self.assertEqual(extracted_binary.name, "agentic-navigation-guide") + self.assertEqual(tag, "v0.2.0") + self.assertFalse(inject_failure) + + with mock.patch.object( + RELEASE, + "smoke_binary", + side_effect=inspect_extracted, + ) as smoke: + RELEASE.smoke_archive(archive, "v0.2.0") + smoke.assert_called_once() + + def test_windows_archive_smoke_selects_the_exe_member(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + binary = directory / "agentic-navigation-guide.exe" + comparison = directory / "comparison.exe" + binary.write_bytes(b"windows executable") + comparison.write_bytes(binary.read_bytes()) + archive = RELEASE.create_archive( + binary, + comparison, + "x86_64-pc-windows-msvc", + "v0.2.0", + directory / "dist", + ) + + def inspect_extracted( + extracted_binary: Path, + _tag: str, + _inject_failure: bool, + ) -> None: + self.assertEqual(extracted_binary.read_bytes(), b"windows executable") + self.assertEqual(extracted_binary.name, "agentic-navigation-guide.exe") + + with mock.patch.object( + RELEASE, + "smoke_binary", + side_effect=inspect_extracted, + ): + RELEASE.smoke_archive(archive, "v0.2.0") + def test_provenance_binds_exact_checksums_commit_and_ref(self) -> None: with tempfile.TemporaryDirectory() as temporary: directory = Path(temporary) @@ -261,6 +352,42 @@ def test_sbom_has_one_root_and_resolved_dependencies(self) -> None: {"DESCRIBES", "DEPENDS_ON"}, ) + def test_sbom_verifier_rejects_each_required_identity_boundary(self) -> None: + valid = { + "spdxVersion": "SPDX-2.3", + "documentNamespace": ( + "https://github.com/plx/agentic-navigation-guide/sbom/0.2.0/" + f"{COMMIT}" + ), + "packages": [ + { + "name": "agentic-navigation-guide", + "versionInfo": "0.2.0", + } + ], + "relationships": [{"relationshipType": "DESCRIBES"}], + } + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "sbom.json" + RELEASE.write_json(path, valid) + RELEASE.verify_sbom(path, COMMIT) + mutations = { + "SPDX 2.3": {"spdxVersion": "SPDX-2.2"}, + "candidate commit": {"documentNamespace": "https://example.invalid"}, + "root package": {"packages": []}, + "dependency relationships": {"relationships": []}, + } + for expected_error, mutation in mutations.items(): + with self.subTest(expected_error=expected_error): + invalid = dict(valid) + invalid.update(mutation) + RELEASE.write_json(path, invalid) + with self.assertRaisesRegex( + RELEASE.ReleaseError, + expected_error, + ): + RELEASE.verify_sbom(path, COMMIT) + def test_release_workflow_has_one_fail_closed_gate_and_publish_boundary( self, ) -> None: @@ -293,6 +420,8 @@ def test_release_workflow_has_one_fail_closed_gate_and_publish_boundary( self.assertIn(".immutable == true", publish) self.assertIn("steps.crate-state.outputs.state == 'publish-required'", publish) self.assertIn("github.event_name == 'push'", publish) + self.assertIn('[[ "$RUNNER_OS" == "Windows" ]]', before_publish) + self.assertIn("-C link-arg=/Brepro", before_publish) def test_pipeline_identity_is_exact_and_personal(self) -> None: configuration = RELEASE.pipeline() @@ -309,6 +438,88 @@ def test_pipeline_identity_is_exact_and_personal(self) -> None: {key: configuration[key] for key in expected}, expected, ) + RELEASE.check_pipeline_workflow() + + def test_pipeline_rejects_identity_drift_at_runtime(self) -> None: + drifted = RELEASE.load_toml(RELEASE.PIPELINE_PATH) + drifted["candidate_tag"] = "v9.9.9" + real_load_toml = RELEASE.load_toml + + def load_with_drift(path: Path): + if path == RELEASE.PIPELINE_PATH: + return drifted + return real_load_toml(path) + + with ( + mock.patch.object( + RELEASE, + "load_toml", + side_effect=load_with_drift, + ), + self.assertRaisesRegex( + RELEASE.ReleaseError, + "candidate_tag", + ), + ): + RELEASE.pipeline() + + def test_crates_version_state_is_fail_closed_and_recoverable(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + archive = ( + Path(temporary) + / "agentic-navigation-guide-0.2.0.crate" + ) + archive.write_bytes(b"exact crate bytes") + checksum = hashlib.sha256(archive.read_bytes()).hexdigest() + def raise_not_found(*_args, **_kwargs): + error = urllib.error.HTTPError( + "https://crates.io/example", + 404, + "not found", + {}, + io.BytesIO(), + ) + error.close() + raise error + + with mock.patch.object( + RELEASE.urllib.request, + "urlopen", + side_effect=raise_not_found, + ): + self.assertEqual( + RELEASE.crates_version_state(archive), + "publish-required", + ) + + matching = io.BytesIO( + json.dumps({"version": {"checksum": checksum}}).encode() + ) + with mock.patch.object( + RELEASE.urllib.request, + "urlopen", + return_value=matching, + ): + self.assertEqual( + RELEASE.crates_version_state(archive), + "already-published-matching", + ) + + mismatched = io.BytesIO( + json.dumps({"version": {"checksum": "0" * 64}}).encode() + ) + with ( + mock.patch.object( + RELEASE.urllib.request, + "urlopen", + return_value=mismatched, + ), + self.assertRaisesRegex( + RELEASE.ReleaseError, + "different archive checksum", + ), + ): + RELEASE.crates_version_state(archive) if __name__ == "__main__": From 1ee17ee83a44e6ce800b632f6d87e435c88e0cbb Mon Sep 17 00:00:00 2001 From: plx Date: Mon, 27 Jul 2026 13:11:56 -0500 Subject: [PATCH 5/6] Prove package smoke failure closes release gate --- .github/workflows/release.yml | 7 ++-- scripts/release_artifacts.py | 4 +++ tests/test_release_artifacts.py | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e46389..8c559be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,8 @@ jobs: permissions: contents: read env: - FAILURE_INJECTION: ${{ inputs.failure_injection || 'none' }} + # Temporary hosted proof for issue #63; remove with the PR trigger. + FAILURE_INJECTION: ${{ github.event_name == 'pull_request' && 'package-smoke' || inputs.failure_injection || 'none' }} steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: @@ -78,6 +79,7 @@ jobs: remote_tag="$(git ls-remote --refs origin "refs/tags/$CANDIDATE_TAG")" test "$(cut -f1 <<<"$remote_tag")" = "$GITHUB_SHA" test "$(wc -l <<<"$remote_tag" | tr -d ' ')" = "1" + # Release tags must be cut from current protected main, not a stale ancestor. git fetch --no-tags --depth=1 origin main test "$(git rev-parse origin/main)" = "$GITHUB_SHA" fi @@ -237,7 +239,8 @@ jobs: permissions: contents: read env: - FAILURE_INJECTION: ${{ inputs.failure_injection || 'none' }} + # Temporary hosted proof for issue #63; remove with the PR trigger. + FAILURE_INJECTION: ${{ github.event_name == 'pull_request' && 'package-smoke' || inputs.failure_injection || 'none' }} steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: diff --git a/scripts/release_artifacts.py b/scripts/release_artifacts.py index 11fdf49..8f825c4 100644 --- a/scripts/release_artifacts.py +++ b/scripts/release_artifacts.py @@ -117,6 +117,8 @@ def check_pipeline_workflow() -> None: workflow_path = ROOT / ".github" / "workflows" / configuration["workflow_filename"] workflow = workflow_path.read_text(encoding="utf-8") matrix = ", ".join(configuration["supported_runners"]) + # These literal checks intentionally make policy-relevant YAML reformatting a + # reviewed configuration change instead of silently accepting equivalent drift. required_fragments = { "candidate tag default": f"default: {configuration['candidate_tag']}", "protected environment": ( @@ -771,6 +773,8 @@ def crates_version_state(crate_archive: Path) -> str: ) }, ) + # Registry uncertainty fails closed. Recovery reruns the same immutable + # workflow rather than hiding an outage behind an automatic retry. try: with urllib.request.urlopen(request, timeout=30) as response: payload = json.load(response) diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 44610d6..a76af7b 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -304,6 +304,70 @@ def test_bundle_rejects_an_unreviewed_extra_asset(self) -> None: "refs/heads/main", ) + def test_bundle_rejects_wrong_archive_count_and_missing_crate(self) -> None: + def prepare(directory: Path, names: list[str]) -> tuple[Path, Path, Path]: + for name in names: + (directory / name).write_bytes(name.encode()) + checksums = directory / "SHA256SUMS" + RELEASE.write_checksums( + checksums, + [directory / name for name in names], + ) + sbom = directory / "agentic-navigation-guide-0.2.0.spdx.json" + provenance = directory / "agentic-navigation-guide-0.2.0.intoto.json" + provenance.write_text("{}\n", encoding="utf-8") + return checksums, sbom, provenance + + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + checksums, sbom, provenance = prepare( + directory, + [ + "agentic-navigation-guide-0.2.0.crate", + "agentic-navigation-guide-0.2.0-linux.tar.gz", + "agentic-navigation-guide-0.2.0-macos.tar.gz", + "agentic-navigation-guide-0.2.0.spdx.json", + ], + ) + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "must contain 3 native archives", + ): + RELEASE.verify_bundle( + directory, + checksums, + sbom, + provenance, + "v0.2.0", + COMMIT, + "refs/heads/main", + ) + + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + checksums, sbom, provenance = prepare( + directory, + [ + "agentic-navigation-guide-0.2.0-linux.tar.gz", + "agentic-navigation-guide-0.2.0-macos.tar.gz", + "agentic-navigation-guide-0.2.0-windows.zip", + "agentic-navigation-guide-0.2.0.spdx.json", + ], + ) + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "missing agentic-navigation-guide-0.2.0.crate", + ): + RELEASE.verify_bundle( + directory, + checksums, + sbom, + provenance, + "v0.2.0", + COMMIT, + "refs/heads/main", + ) + def test_sbom_has_one_root_and_resolved_dependencies(self) -> None: root_id = "path+file:///root#agentic-navigation-guide@0.2.0" dependency_id = ( From 2620b062b031f1d51cd163f1afbc52d9584bf918 Mon Sep 17 00:00:00 2001 From: plx Date: Mon, 27 Jul 2026 13:23:13 -0500 Subject: [PATCH 6/6] Record hosted release pipeline evidence --- .github/workflows/release.yml | 11 +- AGENTIC_NAVIGATION_GUIDE.md | 1 + .../2026-07-27-issue-63-release-pipeline.md | 139 ++++++++++++++++++ docs/release-policy.md | 10 +- docs/repository-protections.md | 5 + 5 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 audits/2026-07-27-issue-63-release-pipeline.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c559be..03fa038 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,6 @@ name: Release on: - # Temporary issue-#63 hosted rehearsal trigger; remove after PR evidence. - pull_request: - branches: [main] push: tags: - "v*" @@ -25,7 +22,7 @@ on: - package-smoke env: - CANDIDATE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_tag || github.event_name == 'pull_request' && 'v0.2.0' || github.ref_name }} + CANDIDATE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_tag || github.ref_name }} CARGO_TERM_COLOR: always SOURCE_REF: ${{ github.ref }} @@ -47,8 +44,7 @@ jobs: permissions: contents: read env: - # Temporary hosted proof for issue #63; remove with the PR trigger. - FAILURE_INJECTION: ${{ github.event_name == 'pull_request' && 'package-smoke' || inputs.failure_injection || 'none' }} + FAILURE_INJECTION: ${{ inputs.failure_injection || 'none' }} steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: @@ -239,8 +235,7 @@ jobs: permissions: contents: read env: - # Temporary hosted proof for issue #63; remove with the PR trigger. - FAILURE_INJECTION: ${{ github.event_name == 'pull_request' && 'package-smoke' || inputs.failure_injection || 'none' }} + FAILURE_INJECTION: ${{ inputs.failure_injection || 'none' }} steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: diff --git a/AGENTIC_NAVIGATION_GUIDE.md b/AGENTIC_NAVIGATION_GUIDE.md index 79cbf27..1960d66 100644 --- a/AGENTIC_NAVIGATION_GUIDE.md +++ b/AGENTIC_NAVIGATION_GUIDE.md @@ -168,6 +168,7 @@ - 2026-07-27-issue-102-windows-device-ledger.md # Windows namespace pre-access oracle and real-platform precedence evidence - 2026-07-27-issue-29-filesystem-safety-gate.md # Verification, discovery, and filesystem-safety component-gate proof - 2026-07-27-issue-55-platform-conformance.md # Full-suite platform matrix, capability audit, and red-gate evidence + - 2026-07-27-issue-63-release-pipeline.md # Hosted release DAG, artifact, reproducibility, and fail-closed evidence - production-readiness-remediation-goal.md # End-to-end next-session execution runbook - production-readiness-work-selection.md # Remediation burndown ordering and selector contract - production-readiness-reassessment-playbook.md # Reassessment procedure diff --git a/audits/2026-07-27-issue-63-release-pipeline.md b/audits/2026-07-27-issue-63-release-pipeline.md new file mode 100644 index 0000000..e654ccb --- /dev/null +++ b/audits/2026-07-27-issue-63-release-pipeline.md @@ -0,0 +1,139 @@ +# Issue #63 release-pipeline evidence + +Date: 2026-07-27 + +Repository: `plx/agentic-navigation-guide` + +Pull request: +[#135](https://github.com/plx/agentic-navigation-guide/pull/135) + +## Decision boundary + +This issue builds and rehearses the release mechanism. It does not create the +`v0.2.0` tag, publish a crate, or create a GitHub Release. The repository +remains personal under the issue #71 single-maintainer exception: no +organization, team, second administrator, or independent release approver is +assumed. + +The reviewed source and hosted rehearsal are complete. GitHub's +repository-level immutable-release setting is enabled, and the tag-scoped +`release` environment is configured. The exact crates.io Trusted Publisher is +not yet configured, so the pull request and issue remain open and real +publication remains blocked. + +No fuzzing, randomized generation, mutation campaign, tag, release, crate +publication, or other external release state was created by this work. + +## Implemented release contract + +[`release/pipeline.toml`](../release/pipeline.toml) and +[`scripts/release_artifacts.py`](../scripts/release_artifacts.py) define and +verify one release identity: + +- repository `plx/agentic-navigation-guide`; +- workflow `.github/workflows/release.yml`; +- protected environment `release`; +- crate and binary `agentic-navigation-guide`; +- prepared version `0.2.0` and candidate tag `v0.2.0`; and +- Rust `1.85.0` as the full-product minimum supported toolchain. + +The workflow accepts only a manual non-publishing rehearsal or a `v*` tag. A +production tag must match the prepared version, resolve to the exact checked +out commit, and equal current protected `main`. Every remote action is pinned +to a full commit. All ordinary jobs have read-only contents permission. Only +the tag-only `publish` job, after the aggregate gate and protected-environment +approval, receives OIDC, attestation, and contents-write permissions. + +The aggregate requires source identity; release-quality and supply-chain +checks; full debug and release suites on Linux, macOS, and Windows; full MSRV +checks; exact crate packaging, clean installation, success and failure smoke, +and Cargo publication dry run; native archive rebuild and target-OS smoke; +checksums; SPDX 2.3 SBOM; provenance; and downloaded-bundle verification. + +The tag-only job reverifies the gated bundle, creates GitHub build and SBOM +attestations, creates or verifies an immutable GitHub Release, and verifies +the release API's immutable result before requesting short-lived crates.io +identity. Recovery never moves, deletes, recreates, or reuses a release tag. + +## Hosted positive rehearsal + +[Release run +30291537804](https://github.com/plx/agentic-navigation-guide/actions/runs/30291537804) +passed the complete non-publishing DAG on the pull request: + +| Gate | Result | +| --- | --- | +| Exact source identity | Passed | +| Quality and supply-chain gates | Passed | +| Full Linux, macOS, and Windows debug/release suites | Passed | +| Full Rust `1.85.0` gates | Passed | +| Exact crate package, clean install, smoke, and dry run | Passed | +| Two native builds and deterministic archives per OS | Passed | +| Download, checksums, SPDX, provenance, and bundle verification | Passed | +| Aggregate release gate | Passed | +| Protected publish job | Skipped by the non-tag event | + +The first hosted archive rehearsal detected that independently built Windows +PE binaries differed. The final implementation fixes the source of that +nondeterminism with MSVC `/Brepro` plus a fixed source epoch. Run +`30291537804` then required matching bytes before each of the three normalized +archives was created and smoke-tested. + +The uploaded `release-bundle` was downloaded independently after the run. +`verify-bundle` accepted its three native archives, exact crate, SHA-256 +manifest, SPDX document, and in-toto provenance. The provenance recorded the +tested pull-request merge commit and `refs/pull/135/merge`, rather than +claiming a tag or protected-main source that did not exist. + +## Hosted fail-closed evidence + +[Release run +30290452743](https://github.com/plx/agentic-navigation-guide/actions/runs/30290452743) +used a deliberate tag/version mismatch. Exact source identity failed before +any downstream build, artifact, aggregate, or publication job could succeed. + +[Release run +30292694917](https://github.com/plx/agentic-navigation-guide/actions/runs/30292694917) +used the deterministic `package-smoke` injection. Exact package construction +and clean installation passed, then the installed success/failure behavior +step rejected the deliberately altered expected version against the observed +`agentic-navigation-guide 0.2.0`. The crate dry run and upload were skipped, +bundle assembly could not run, the aggregate gate failed, and the protected +publish job was skipped. + +The temporary pull-request trigger and forced failure selection used to obtain +this hosted evidence were removed after the proof. The final workflow exposes +failure injection only on manual non-publishing rehearsals. + +## Hosted controls + +| Control | Status on 2026-07-27 | +| --- | --- | +| Personal repository | `plx/agentic-navigation-guide`; no organization required or implied | +| Protected environment | `release`; `v*` tags only, owner approval required, administrator bypass disabled, no publication secret | +| Release-tag controls | Owner-only creation and no-bypass update/deletion prohibition | +| GitHub immutable releases | Enabled and verified through the repository API for future releases | +| crates.io Trusted Publisher | Pending: must be exactly owner `plx`, repository `agentic-navigation-guide`, workflow `release.yml`, environment `release` | + +The available local crates.io credential could not authenticate to the Trusted +Publisher configuration endpoint, and no signed-in browser session was +available. No credential value was printed, copied, changed, or committed. +This is an external configuration blocker, not a source fallback: the workflow +contains no long-lived publication token and fails closed until the exact +publisher exists. + +## Acceptance mapping + +| Acceptance criterion | Evidence and disposition | +| --- | --- | +| One non-publishing rehearsal passes end to end | Complete hosted run `30291537804` passed; independently downloaded bundle passed verification | +| Every gate is fail-closed and required by publish | One aggregate depends on every prerequisite; tag mismatch and installed-package failure both prevented downstream success | +| Crate and binaries trace to one immutable commit/tag | Provenance and checksums bind all rehearsal artifacts to one tested commit/ref; production additionally requires an immutable exact tag at current `main` | +| Checksums, SBOM/provenance, licenses, and smoke results exist | Produced and verified in the successful hosted bundle | +| Trusted publishing and protected approval use no exposed long-lived token | Protected environment is configured and source uses tag-only OIDC; exact crates.io publisher remains pending | +| Failure injection proves publication cannot bypass a failed gate | Hosted tag-mismatch and package-smoke runs both blocked publication | +| Recovery avoids tag mutation | Checked-in runbook requires same-run recovery and forbids moving, deleting, recreating, or reusing a tag | + +Issue #63 is not complete until the exact crates.io Trusted Publisher is +configured and verified. No acceptance exception is recorded for that +criterion. diff --git a/docs/release-policy.md b/docs/release-policy.md index aa71473..80f472a 100644 --- a/docs/release-policy.md +++ b/docs/release-policy.md @@ -104,10 +104,12 @@ or environment publication secret is used. The crates.io Trusted Publisher must be registered with exactly `plx`, `agentic-navigation-guide`, `release.yml`, and `release`. GitHub's repository -setting **Enable release immutability** must also be enabled for future -releases. Until both hosted settings are verified, the source mechanism is -ready for rehearsal but real publication remains blocked: OIDC exchange or -the post-release immutability assertion fails before `cargo publish`. +setting **Enable release immutability** was enabled and verified through the +repository API on 2026-07-27; this is a repository-level control and does not +require a GitHub organization. The exact crates.io Trusted Publisher remains +unconfigured. Until it is registered and verified, the source mechanism is +ready for rehearsal but real publication remains blocked because the OIDC +exchange fails before `cargo publish`. ## Non-publishing rehearsal diff --git a/docs/repository-protections.md b/docs/repository-protections.md index 9b16e1b..741c034 100644 --- a/docs/repository-protections.md +++ b/docs/repository-protections.md @@ -106,6 +106,11 @@ exactly registered, the absence of a short-lived publication credential fails closed. No long-lived token is installed. +GitHub's repository-level immutable-release setting was enabled and verified +through the repository API on 2026-07-27. It applies to future releases and +does not require a GitHub organization. The issue #63 workflow independently +checks the created release's API state before it requests crates.io identity. + ## Inspection and recurring audit Anyone can reproduce the public portion of the check: