From 4bf59d1f5577e8f9f0e63a5551a80f0bc70dbeb9 Mon Sep 17 00:00:00 2001 From: Andrew Briscoe Date: Mon, 27 Jul 2026 00:34:58 -0600 Subject: [PATCH] ci(release): authenticate prepared artifacts Bind crate and GitHub publication to one verified prepare run, align release-time manifest and lockfile stamping transactionally, and pin the transitive release action surface. Add executable release contracts plus the maintainer runbook so provenance, artifact completeness, retry behavior, and dependency boundaries remain enforced. --- .github/workflows/release-prepare.yml | 109 ++++++++------ .github/workflows/release-validate.yml | 47 +++--- .github/workflows/release.yml | 136 ++++++++++-------- .github/workflows/test.yml | 128 ++++++++++++----- Cargo.lock | 2 + README.md | 6 + RELEASING.md | 141 ++++++++++++++++++ scripts/ci/stamp_version.sh | 98 +++++++++++-- scripts/ci/verify_release_provenance.sh | 63 ++++++++ scripts/test-release-provenance.sh | 78 ++++++++++ scripts/test-release-workflow-contract.sh | 166 ++++++++++++++++++++++ scripts/test-stamp-version.sh | 143 +++++++++++++++++++ 12 files changed, 947 insertions(+), 170 deletions(-) create mode 100644 RELEASING.md create mode 100755 scripts/ci/verify_release_provenance.sh create mode 100755 scripts/test-release-provenance.sh create mode 100755 scripts/test-release-workflow-contract.sh create mode 100755 scripts/test-stamp-version.sh diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index b3b0744..2b435ca 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -1,13 +1,9 @@ -# Tag gate for a releasable build. Pushing a v* tag validates the tag, runs the -# full test suite, and builds the `pagedb-fsck` binaries once. Nothing is -# published and nothing leaves the repo. +# Tag gate for a releasable build. A v* tag validates the version, runs the +# complete reusable suite, builds each pagedb-fsck archive once, and records +# immutable provenance. This workflow never publishes a crate or a release. # -# The manual `Release` workflow then consumes THIS run's binary artifacts by run -# ID for the GitHub Release, so a failure in the publish or release stage can be -# fixed and retried without repeating the tests or the compile. -# -# Artifacts are retained 14 days: that is the window in which a `Release` run can -# still resume from this prepare run. +# Artifacts are retained for 14 days. A maintainer can retry distribution during +# that window without rebuilding or silently substituting another tag's files. name: Release Prepare run-name: Release Prepare ${{ github.ref_name }} @@ -33,14 +29,13 @@ jobs: with: ref: ${{ github.ref_name }} - # Full lint + cross-platform test + wasm/features/bench gates. Calls test.yml - # directly rather than ci.yml, whose PR-draft gate skips under workflow_call. + # Calls test.yml directly because ci.yml's pull-request draft gate does not + # describe a tag build. ci: name: CI Gate needs: validate-version uses: ./.github/workflows/test.yml - # ── Build pagedb-fsck binary per target ───────────────────────────────── build-binaries: name: Build fsck (${{ matrix.label }}) needs: [validate-version, ci] @@ -61,8 +56,8 @@ jobs: target: aarch64-apple-darwin label: macos-arm64 ext: "" - # x86_64-apple-darwin cross-compiles on Apple Silicon; the Intel - # macos-13 image was retired, so build it on the arm64 runner. + # The x86_64 target cross-compiles on the current Apple Silicon + # runner; the retired Intel runner is not a release dependency. - runs-on: macos-latest target: x86_64-apple-darwin label: macos-x64 @@ -72,41 +67,52 @@ jobs: label: windows-x64 ext: ".exe" steps: - - uses: actions/checkout@v7 - - - name: Install Rust + target - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - targets: ${{ matrix.target }} + persist-credentials: false - - uses: Swatinem/rust-cache@v2 + - name: Install Rust and target + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: - prefix-key: fsck-${{ matrix.target }} + targets: ${{ matrix.target }} - name: Set version from tag shell: bash - run: bash scripts/ci/stamp_version.sh "${{ needs.validate-version.outputs.version }}" + env: + VERSION: ${{ needs.validate-version.outputs.version }} + run: bash scripts/ci/stamp_version.sh "$VERSION" - name: Build pagedb-fsck - run: cargo build --release --bin pagedb-fsck --target ${{ matrix.target }} + env: + TARGET: ${{ matrix.target }} + run: cargo build --locked --release --bin pagedb-fsck --target "$TARGET" - - name: Package (Unix) + - name: Package archive if: runner.os != 'Windows' + env: + LABEL: ${{ matrix.label }} + TARGET: ${{ matrix.target }} + VERSION: ${{ needs.validate-version.outputs.version }} run: | set -euo pipefail - cd target/${{ matrix.target }}/release + cd "target/${TARGET}/release" chmod +x pagedb-fsck - tar czf pagedb-fsck-${{ needs.validate-version.outputs.version }}-${{ matrix.label }}.tar.gz pagedb-fsck + tar czf "pagedb-fsck-${VERSION}-${LABEL}.tar.gz" pagedb-fsck - - name: Package (Windows) + - name: Package archive if: runner.os == 'Windows' shell: pwsh + env: + EXT: ${{ matrix.ext }} + LABEL: ${{ matrix.label }} + TARGET: ${{ matrix.target }} + VERSION: ${{ needs.validate-version.outputs.version }} run: | - cd target/${{ matrix.target }}/release - $name = "pagedb-fsck-${{ needs.validate-version.outputs.version }}-${{ matrix.label }}.zip" - Compress-Archive -Path "pagedb-fsck${{ matrix.ext }}" -DestinationPath $name + Set-Location "target/$env:TARGET/release" + $name = "pagedb-fsck-$env:VERSION-$env:LABEL.zip" + Compress-Archive -Path "pagedb-fsck$env:EXT" -DestinationPath $name - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: fsck-${{ matrix.label }} path: | @@ -115,29 +121,52 @@ jobs: if-no-files-found: error retention-days: 14 - summary: - name: Release instructions + provenance: + name: Release provenance and instructions needs: [validate-version, build-binaries] runs-on: ubuntu-latest steps: - - name: Write summary + - name: Record prepared release env: + COMMIT: ${{ needs.validate-version.outputs.commit }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + TAG: ${{ github.ref_name }} VERSION: ${{ needs.validate-version.outputs.version }} - REPO: ${{ github.repository }} + run: | + jq -n \ + --arg commit "$COMMIT" \ + --arg repository "$REPOSITORY" \ + --arg run_id "$RUN_ID" \ + --arg tag "$TAG" \ + --arg version "$VERSION" \ + '{schema:1, repository:$repository, tag:$tag, version:$version, commit:$commit, prepare_run_id:$run_id}' \ + > release-provenance.json + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-provenance + path: release-provenance.json + if-no-files-found: error + retention-days: 14 + + - name: Write summary + env: + REPOSITORY: ${{ github.repository }} RUN_ID: ${{ github.run_id }} + VERSION: ${{ needs.validate-version.outputs.version }} run: | { echo "## Prepared v${VERSION}" echo - echo "Tests passed and the fsck binaries are built. Publish with:" + echo "Tests passed, binaries were built, and provenance was recorded." + echo "Distribute this exact run with:" echo echo '```sh' - echo "gh workflow run release.yml -R ${REPO} \\" + echo "gh workflow run release.yml -R ${REPOSITORY} \\" echo " -f tag=v${VERSION} \\" echo " -f prepare_run_id=${RUN_ID}" echo '```' echo - echo "If one stage fails, re-run the same command with only the" - echo "remaining stage enabled (e.g. \`-f publish_crate=false\`)." - echo "Nothing is recompiled." + echo "Retry only a failed stage by disabling the completed one." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-validate.yml b/.github/workflows/release-validate.yml index a32c14d..0809c88 100644 --- a/.github/workflows/release-validate.yml +++ b/.github/workflows/release-validate.yml @@ -1,6 +1,6 @@ -# Reusable tag/version validation. Called by release-prepare.yml (from the -# pushed tag) and release.yml (from the dispatched tag), so the two entry -# points can never disagree about what a tag means. +# Reusable tag/version validation. Release Prepare calls this from the pushed +# tag, and Release calls it again from the manually dispatched tag. Both stages +# therefore agree on tag syntax, package version, and the exact tag commit. name: Release Validate @@ -8,7 +8,7 @@ on: workflow_call: inputs: ref: - description: "Git ref to validate (a v* tag)" + description: "Release tag to validate (vX.Y.Z or vX.Y.Z-label.N)" required: true type: string outputs: @@ -18,6 +18,9 @@ on: is_full_release: description: "true when the version carries no prerelease suffix" value: ${{ jobs.validate.outputs.is_full_release }} + commit: + description: "Commit referenced by the validated tag" + value: ${{ jobs.validate.outputs.commit }} permissions: contents: read @@ -29,13 +32,16 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} is_full_release: ${{ steps.version.outputs.is_full_release }} + commit: ${{ steps.version.outputs.commit }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ inputs.ref }} + fetch-depth: 0 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - name: Validate tag against Cargo.toml id: version @@ -43,33 +49,42 @@ jobs: TAG: ${{ inputs.ref }} run: | set -euo pipefail - TAG_VERSION="${TAG#v}" + if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)(-[A-Za-z][A-Za-z0-9-]*\.[0-9]+)?$ ]]; then + echo "::error::Invalid tag '$TAG'. Expected vX.Y.Z or vX.Y.Z-label.N" + exit 1 + fi + + TAG_VERSION="${TAG#v}" + TAG_BASE="${BASH_REMATCH[1]}" CARGO_VERSION=$(cargo metadata --no-deps --format-version=1 \ | jq -r '.packages[] | select(.name == "pagedb") | .version') - CARGO_BASE=$(echo "$CARGO_VERSION" | grep -oP '^\d+\.\d+\.\d+') + if [[ ! "$CARGO_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then + echo "::error::Cargo.toml carries an invalid package version '$CARGO_VERSION'" + exit 1 + fi + CARGO_BASE="${BASH_REMATCH[1]}" echo "Tag version: $TAG_VERSION" echo "Cargo.toml version: $CARGO_VERSION" echo "Cargo.toml base: $CARGO_BASE" - if [[ ! "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-[a-zA-Z]+\.[0-9]+)?$ ]]; then - echo "::error::Invalid tag format '$TAG'. Expected: vX.Y.Z or vX.Y.Z-label.N" + if [[ "$TAG_BASE" != "$CARGO_BASE" ]]; then + echo "::error::Base version mismatch: tag '$TAG_BASE' != Cargo.toml '$CARGO_BASE'" exit 1 fi - TAG_BASE="${BASH_REMATCH[1]}" - - if [[ "$TAG_BASE" != "$CARGO_BASE" ]]; then - echo "::error::Base version mismatch! Tag '$TAG_BASE' != Cargo.toml '$CARGO_BASE'" + TAG_COMMIT=$(git rev-parse "refs/tags/${TAG}^{commit}") + HEAD_COMMIT=$(git rev-parse HEAD) + if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then + echo "::error::Checkout '$HEAD_COMMIT' does not match tag '$TAG_COMMIT'" exit 1 fi - # Full release = no hyphen suffix (v0.1.0, not v0.1.0-beta.1) if [[ "$TAG_VERSION" == *-* ]]; then echo "is_full_release=false" >> "$GITHUB_OUTPUT" else echo "is_full_release=true" >> "$GITHUB_OUTPUT" fi - echo "version=$TAG_VERSION" >> "$GITHUB_OUTPUT" + echo "commit=$TAG_COMMIT" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a788f5f..0d396a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,9 @@ -# Manual distribution workflow. Push a v* tag first; `Release Prepare` runs the -# test suite and builds the fsck binaries. Then run this workflow with that tag -# and the successful prepare run ID. +# Manual distribution workflow. Release Prepare has already tested the tag, +# built the binaries, and recorded their provenance. This workflow publishes the +# crate and/or creates the GitHub Release without repeating the expensive build. # -# The GitHub Release consumes the prepare run's artifacts, so a failed publish -# or release stage is retried by re-dispatching with only that stage enabled — -# the tests and the binaries are never rebuilt to fix a crates.io timeout or a -# bad release body. -# -# Stages: -# publish_crate -> crates.io (single crate; verifying publish) -# github_release -> GitHub Release with the fsck binaries attached -# -# Each stage is idempotent: an already-indexed version is skipped, the release -# is overwritten in place. Re-running is safe. +# Both stages are retryable. An existing crates.io version is skipped, while the +# GitHub Release is updated in place by action-gh-release. # # Required secret: CARGO_REGISTRY_TOKEN. @@ -23,11 +14,11 @@ on: workflow_dispatch: inputs: tag: - description: "Release tag to publish, e.g. v0.1.0" + description: "Release tag to distribute, e.g. v0.1.0" required: true type: string prepare_run_id: - description: "Successful Release Prepare run ID holding the binaries" + description: "Successful Release Prepare run ID holding the artifacts" required: true type: string publish_crate: @@ -35,7 +26,7 @@ on: type: boolean default: true github_release: - description: "Create the GitHub Release" + description: "Create or update the GitHub Release" type: boolean default: true @@ -50,84 +41,109 @@ env: CARGO_TERM_COLOR: always jobs: - # Always runs. Cheap, and it guarantees the dispatched tag still means what - # the prepare run assumed it meant. validate-version: uses: ./.github/workflows/release-validate.yml with: ref: ${{ inputs.tag }} - # ── crates.io ──────────────────────────────────────────────────────────────── - # pagedb is a single leaf crate — no publish-order tiers. A verifying publish - # (no --no-verify) is the strongest guarantee the uploaded tarball builds from - # a clean checkout, and there is no inter-crate index race to skip it for. + # Both publication stages depend on this exact prepare run. Validating once + # here prevents a crate publish from bypassing the binary/provenance gate. + validate-prepare: + name: Validate prepared artifacts + needs: validate-version + runs-on: ubuntu-latest + permissions: + actions: read # Needed to read the selected prepare run's artifacts. + contents: read # Needed to check out the validated release tag. + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + ref: ${{ inputs.tag }} + + - name: Download prepared release provenance + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: release-provenance + run-id: ${{ inputs.prepare_run_id }} + github-token: ${{ github.token }} + path: ./provenance + + - name: Download fsck artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: "fsck-*" + run-id: ${{ inputs.prepare_run_id }} + github-token: ${{ github.token }} + path: ./artifacts + merge-multiple: true + + - name: Verify prepared release provenance + env: + EXPECTED_COMMIT: ${{ needs.validate-version.outputs.commit }} + EXPECTED_REPOSITORY: ${{ github.repository }} + EXPECTED_RUN_ID: ${{ inputs.prepare_run_id }} + EXPECTED_TAG: ${{ inputs.tag }} + EXPECTED_VERSION: ${{ needs.validate-version.outputs.version }} + run: bash scripts/ci/verify_release_provenance.sh provenance/release-provenance.json artifacts + + # PageDB is one publishable crate, so there are no publish-order tiers or + # index-race workarounds. cargo publish keeps its normal verification build. publish-crate: name: Publish to crates.io - needs: validate-version + needs: [validate-version, validate-prepare] if: inputs.publish_crate runs-on: ubuntu-latest environment: crates.io steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false ref: ${{ inputs.tag }} - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - # The verifying publish compiles the packaged lib + the pagedb-fsck bin - # against crates.io deps. The io-uring VFS backend needs nothing extra, - # but clang/libclang keeps parity with the test image in case a future - # build-dep needs bindgen. - - name: Install LLVM/Clang - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang libclang-dev + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - name: Set version from tag - run: bash scripts/ci/stamp_version.sh "${{ needs.validate-version.outputs.version }}" + env: + VERSION: ${{ needs.validate-version.outputs.version }} + run: bash scripts/ci/stamp_version.sh "$VERSION" - name: Publish pagedb env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | set -euo pipefail - VERSION=$(cargo metadata --no-deps --format-version=1 \ + VERSION=$(cargo metadata --locked --no-deps --format-version=1 \ | jq -r '.packages[] | select(.name == "pagedb") | .version') - is_published() { - curl -sf \ - -H "User-Agent: pagedb-ci (github.com/nodedb-lab/pagedb)" \ - "https://crates.io/api/v1/crates/pagedb/$1" > /dev/null 2>&1 - } - - if is_published "$VERSION"; then - echo "pagedb@$VERSION already published — skipping" + if curl -fsS \ + -H "User-Agent: pagedb-ci (github.com/nodedb-lab/pagedb)" \ + "https://crates.io/api/v1/crates/pagedb/${VERSION}" \ + >/dev/null 2>&1; then + echo "pagedb@${VERSION} already published - skipping" exit 0 fi - echo "Publishing pagedb@$VERSION..." - # --allow-dirty: stamp_version.sh may have edited Cargo.toml for a - # prerelease tag; the verifying publish still runs. - cargo publish -p pagedb --allow-dirty + echo "Publishing pagedb@${VERSION}..." + # The tag may carry a prerelease suffix that stamp_version.sh applies + # to Cargo.toml. --allow-dirty permits that deliberate manifest edit; + # cargo publish still performs its package verification build. + cargo publish -p pagedb --locked --allow-dirty - # ── GitHub Release ─────────────────────────────────────────────────────────── - # Independent of publish-crate: a crates.io outage must not block cutting the - # release, and re-running this stage alone is the fix for a bad release body. + # Crate publication and GitHub release creation are independent so either + # external service can be retried without repeating the successful stage. github-release: name: Create GitHub Release - needs: validate-version + needs: [validate-version, validate-prepare] if: inputs.github_release runs-on: ubuntu-latest permissions: - contents: write - actions: read + contents: write # Needed to create or update the tag's GitHub release. + actions: read # Needed to download the accepted prepare-run artifacts. steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.tag }} - fetch-depth: 0 - - name: Download fsck artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: "fsck-*" run-id: ${{ inputs.prepare_run_id }} @@ -136,7 +152,7 @@ jobs: merge-multiple: true - name: Create GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: tag_name: ${{ inputs.tag }} name: pagedb ${{ needs.validate-version.outputs.version }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5989ac6..a7c2ffe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,14 +41,16 @@ jobs: name: Lint, Format & Docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: prefix-key: lint @@ -74,6 +76,12 @@ jobs: - name: Run doctests run: cargo test -p pagedb --doc --all-features + - name: Verify release contracts + run: | + bash scripts/test-stamp-version.sh + bash scripts/test-release-provenance.sh + bash scripts/test-release-workflow-contract.sh + # Enforces the rule in CLAUDE.md: no checklist-letter or build-order # scaffolding may leak into source / Cargo.toml / README. Greps for # the explicit forbidden patterns; fails the job if any are present. @@ -97,17 +105,19 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: prefix-key: test-${{ matrix.os }} - name: Install cargo-nextest - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 with: tool: nextest @@ -118,7 +128,7 @@ jobs: - name: Upload nextest report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: nextest-${{ matrix.os }} path: target/nextest/default/junit.xml @@ -143,17 +153,19 @@ jobs: # Only presence is checked, never the value. PAGEDB_INVARIANT_CHECKS: "1" steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: prefix-key: invariants - name: Install cargo-nextest - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 with: tool: nextest @@ -162,7 +174,7 @@ jobs: - name: Upload nextest report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: nextest-invariants path: target/nextest/default/junit.xml @@ -193,19 +205,23 @@ jobs: - target: armv7-linux-androideabi label: android-armv7 (AndroidVfs / thread-pool) steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install Rust + target - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: prefix-key: cross-${{ matrix.target }} - name: cargo check --lib - run: cargo check -p pagedb --target ${{ matrix.target }} --lib + env: + TARGET: ${{ matrix.target }} + run: cargo check -p pagedb --target "$TARGET" --lib # ───────────────────────────────────────────────────────────────────────── wasm: @@ -216,67 +232,99 @@ jobs: matrix: include: - target: wasm32-unknown-unknown - features: --features opfs + feature: opfs label: wasm32 (OpfsVfs) - target: wasm32-wasip1 - features: "" + feature: "" label: wasi (WasiVfs) steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install Rust + target - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: prefix-key: wasm-${{ matrix.target }} - name: cargo check --lib - run: cargo check -p pagedb --target ${{ matrix.target }} --lib ${{ matrix.features }} + env: + FEATURE: ${{ matrix.feature }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + if [[ -n "$FEATURE" ]]; then + cargo check -p pagedb --target "$TARGET" --lib --features "$FEATURE" + else + cargo check -p pagedb --target "$TARGET" --lib + fi # ───────────────────────────────────────────────────────────────────────── features: - name: Feature matrix (${{ matrix.flags }}) + name: Feature matrix (${{ matrix.config }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - flags: - - "" # default features - - "--no-default-features" - - "--features compression" - - "--all-features" + config: [default, no-default, compression, all] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: prefix-key: features - name: cargo check - run: cargo check -p pagedb --lib --bins --tests --benches ${{ matrix.flags }} + env: + CONFIG: ${{ matrix.config }} + run: | + set -euo pipefail + case "$CONFIG" in + default) + cargo check -p pagedb --lib --bins --tests --benches + ;; + no-default) + cargo check -p pagedb --lib --bins --tests --benches --no-default-features + ;; + compression) + cargo check -p pagedb --lib --bins --tests --benches --features compression + ;; + all) + cargo check -p pagedb --lib --bins --tests --benches --all-features + ;; + *) + echo "::error::Unknown feature-matrix configuration '$CONFIG'" + exit 1 + ;; + esac # ───────────────────────────────────────────────────────────────────────── bench: name: Benchmark (dry run) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable # The isolated comparison package links RocksDB (librocksdb-sys), whose # build runs bindgen and needs libclang. - name: Install LLVM/Clang run: sudo apt-get update && sudo apt-get install -y clang libclang-dev - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: prefix-key: bench @@ -293,11 +341,13 @@ jobs: name: Dependency audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - name: Install cargo-deny - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 with: tool: cargo-deny - name: Run cargo-deny diff --git a/Cargo.lock b/Cargo.lock index 1a539a9..02b4e91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -506,6 +506,8 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "faultbox" version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a80929286082768f2ae95812c7aacd931ab3b1ad13c8ec70cda2a6c64994e9" dependencies = [ "serde", "serde_json", diff --git a/README.md b/README.md index fdba473..1d28bba 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,12 @@ The API surface is intentionally redb-shaped. The lift is mostly sync→async Pre-1.0. The format is stabilizing toward a freeze; expect format-version bumps until that lands. The on-disk format is versioned and cipher-agile by design, so future migration paths exist — but until 1.0, treat data as throwaway. +## Releases + +Maintainers prepare a tagged build and distribute that exact run in separate +workflows. The [release guide](RELEASING.md) documents the local contracts, +provenance checks, retry procedure, and final publication verification. + ## License Dual-licensed under [MIT](LICENSE-MIT) **OR** [Apache-2.0](LICENSE-APACHE), at your option. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a08d9cb --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,141 @@ +# Releasing PageDB + +PageDB separates preparation from distribution so a crates.io or GitHub +Release retry cannot silently substitute another build: + +1. `Release Prepare` runs from a pushed `v*` tag. It validates the tag, runs + the complete reusable test suite, builds five `pagedb-fsck` archives, and + records immutable provenance. It does not publish anything. +2. `Release` is dispatched manually with the tag and successful prepare run + ID. It can publish the crate, create the GitHub Release, or retry either + stage independently. + +The prepare-run ID is not trusted by itself. Distribution requires its +provenance to match the repository, tag, package version, peeled tag commit, +and run ID, then requires the exact five expected archives. + +## Before tagging + +Start from the intended release commit on `main`, confirm its normal CI is +green, and update `CHANGELOG.md`. The base version in `Cargo.toml` must match +the tag's `X.Y.Z` component: + +- `Cargo.toml` version `0.2.0` accepts `v0.2.0`; +- the same base accepts a prerelease such as `v0.2.0-beta.1`; +- other tag forms are rejected. + +Run the local release contracts: + +```bash +bash scripts/test-stamp-version.sh +bash scripts/test-release-provenance.sh +bash scripts/test-release-workflow-contract.sh +actionlint .github/workflows/release-prepare.yml \ + .github/workflows/release-validate.yml \ + .github/workflows/release.yml +cargo package --locked +``` + +These checks cover tag/stage wiring, exact artifact provenance, the macOS x64 +cross-build runner, immutable action pins, safe idempotent version and lockfile +stamping, rollback after a failed stamp, and the absence of external-engine or +native-toolchain release prerequisites. `actionlint` parses the three release +workflows independently of those contract assertions. `cargo package +--locked` verifies the crate payload without publishing it. + +The GitHub repository must already have: + +- `CARGO_REGISTRY_TOKEN` configured for the `crates.io` environment; +- the intended environment approval rules; +- GitHub Actions permission to create releases. + +Those are repository controls, not package dependencies. The release +workflows do not install RocksDB, SQLite, redb, Clang, libclang, or another +external benchmark engine. + +## Prepare the release + +Create and push the annotated tag through the normal reviewed Git procedure: + +```bash +git tag -a v0.2.0 -m "pagedb v0.2.0" +git push origin v0.2.0 +``` + +`Release Prepare` must complete: + +- reusable tag validation; +- the full `.github/workflows/test.yml` suite; +- `pagedb-fsck` builds for Linux x64, Linux arm64, macOS arm64, macOS x64, and + Windows x64; +- `release-provenance.json` upload; +- a summary containing the exact distribution command. + +The macOS x64 binary cross-compiles on the current Apple Silicon +`macos-latest` runner. All prepare artifacts are retained for 14 days. + +## Distribute the prepared run + +Use the command emitted by `Release Prepare`, or dispatch it explicitly: + +```bash +gh workflow run release.yml \ + -f tag=v0.2.0 \ + -f prepare_run_id=123456789 +``` + +Before either publication stage starts, the workflow downloads the selected +prepare run and requires exact equality for: + +- provenance schema; +- repository; +- tag; +- package version; +- peeled tag commit; +- prepare run ID. + +It also requires exactly named archives for all five supported release labels. +A wrong run ID therefore fails closed instead of attaching plausible-looking +files from another build. + +The crate stage aligns `Cargo.toml` and `Cargo.lock`, then performs a locked +verifying `cargo publish`. `--allow-dirty` permits the deliberate release +version edits; it does not disable Cargo's package verification build. An +already indexed crate version is a successful no-op. + +## Retry one stage + +Reuse the same tag and prepare run when only an external publication stage +failed: + +```bash +# Retry only the GitHub Release. +gh workflow run release.yml \ + -f tag=v0.2.0 \ + -f prepare_run_id=123456789 \ + -f publish_crate=false + +# Retry only crates.io publication. +gh workflow run release.yml \ + -f tag=v0.2.0 \ + -f prepare_run_id=123456789 \ + -f github_release=false +``` + +Create a new release commit and tag only when the source or packaged bytes must +change. + +## Final verification + +1. Confirm the crates.io version and checksum exist. +2. Confirm the GitHub Release points to the intended tag and has the correct + prerelease state. +3. Confirm all five `pagedb-fsck` archives are attached. +4. Download and unpack at least one archive. Run `pagedb-fsck` without + arguments and confirm it prints usage and exits with status 2. +5. Retain the prepare and distribution run URLs with the release record. + +If distribution is intentionally abandoned, leave the tag and prepare +artifacts unchanged until the decision is reviewed. + +Deleting or moving a published release tag is not a retry mechanism. diff --git a/scripts/ci/stamp_version.sh b/scripts/ci/stamp_version.sh index 518b0f1..210c685 100755 --- a/scripts/ci/stamp_version.sh +++ b/scripts/ci/stamp_version.sh @@ -1,27 +1,95 @@ #!/usr/bin/env bash -# Stamp the package version from a release tag. +# Stamp Cargo.toml's package version from a validated release tag. # -# scripts/ci/stamp_version.sh # e.g. 0.1.0, 0.1.0-beta.2 +# scripts/ci/stamp_version.sh # e.g. 0.1.0, 0.1.0-beta.3 # -# Rewrites `[package] version` in Cargo.toml. pagedb is a single leaf crate with -# no internal path deps, so there is nothing else to pin. -# -# No-ops when Cargo.toml already carries the target version, which keeps -# re-running a stage idempotent. +# PageDB is one publishable crate. Its workspace lockfile also records the root +# package version because the benchmark child package depends on PageDB by path. set -euo pipefail VERSION="${1:?usage: stamp_version.sh }" +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z][A-Za-z0-9-]*\.[0-9]+)?$ ]]; then + printf 'invalid version %q; expected X.Y.Z or X.Y.Z-label.N\n' "$VERSION" >&2 + exit 2 +fi -CURRENT=$(cargo metadata --no-deps --format-version=1 \ - | jq -r '.packages[] | select(.name == "pagedb") | .version') +CURRENT="$( + cargo metadata --no-deps --format-version=1 | + jq -r '.packages[] | select(.name == "pagedb") | .version' +)" +test -n "$CURRENT" || { + printf 'could not resolve the package version from Cargo.toml\n' >&2 + exit 1 +} -if [[ "$VERSION" == "$CURRENT" ]]; then - echo "Version already $VERSION — nothing to stamp." - exit 0 +manifest='Cargo.toml' +lockfile='Cargo.lock' +test -f "$lockfile" || { + printf 'missing %s; release stamping requires the tracked lockfile\n' "$lockfile" >&2 + exit 1 +} + +temporary="$(mktemp "${manifest}.tmp.XXXXXX")" +manifest_backup="$(mktemp "${manifest}.backup.XXXXXX")" +lock_backup="$(mktemp "${lockfile}.backup.XXXXXX")" +cleanup() { + rm -f -- "$temporary" "$manifest_backup" "$lock_backup" +} +trap cleanup EXIT +cp -p -- "$manifest" "$temporary" +cp -p -- "$manifest" "$manifest_backup" +cp -p -- "$lockfile" "$lock_backup" + +if [[ "$VERSION" != "$CURRENT" ]]; then + awk -v version="$VERSION" ' + BEGIN { + in_package = 0 + replaced = 0 + } + /^\[package\][[:space:]]*$/ { + in_package = 1 + print + next + } + /^\[/ { + in_package = 0 + } + in_package && /^[[:space:]]*version[[:space:]]*=/ { + if (replaced) { + exit 42 + } + print "version = \"" version "\"" + replaced = 1 + next + } + { + print + } + END { + if (!replaced) { + exit 43 + } + } + ' "$manifest" >"$temporary" || { + printf 'could not replace [package] version in %s\n' "$manifest" >&2 + exit 1 + } + mv -- "$temporary" "$manifest" fi -# First `version = "..."` in the file is [package]. -perl -i -pe 'if (!$done && /^version = "/) { s/^version = ".*"/version = "'"$VERSION"'"/; $done=1 }' Cargo.toml +if ! cargo update --quiet -p pagedb --precise "$VERSION" --offline; then + cp -p -- "$manifest_backup" "$manifest" + cp -p -- "$lock_backup" "$lockfile" + printf 'could not align %s with package version %s\n' "$lockfile" "$VERSION" >&2 + exit 1 +fi + +if [[ "$VERSION" == "$CURRENT" ]]; then + printf 'Version already %s - manifest and lockfile aligned.\n' "$VERSION" +else + printf 'Stamped package version: %s -> %s\n' "$CURRENT" "$VERSION" +fi -echo "Stamped package version: $CURRENT -> $VERSION" +trap - EXIT +cleanup diff --git a/scripts/ci/verify_release_provenance.sh b/scripts/ci/verify_release_provenance.sh new file mode 100755 index 0000000..22bbd23 --- /dev/null +++ b/scripts/ci/verify_release_provenance.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Verify that a prepared artifact set belongs to the requested release. + +set -euo pipefail + +die() { + printf 'release provenance: %s\n' "$*" >&2 + exit 1 +} + +provenance="${1:?usage: verify_release_provenance.sh }" +artifact_dir="${2:?usage: verify_release_provenance.sh }" + +: "${EXPECTED_COMMIT:?EXPECTED_COMMIT is required}" +: "${EXPECTED_REPOSITORY:?EXPECTED_REPOSITORY is required}" +: "${EXPECTED_RUN_ID:?EXPECTED_RUN_ID is required}" +: "${EXPECTED_TAG:?EXPECTED_TAG is required}" +: "${EXPECTED_VERSION:?EXPECTED_VERSION is required}" + +test -f "$provenance" || die "missing provenance file: $provenance" +test -d "$artifact_dir" || die "missing artifact directory: $artifact_dir" +[[ "$EXPECTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z][A-Za-z0-9-]*\.[0-9]+)?$ ]] || + die "invalid expected version: $EXPECTED_VERSION" + +jq -e -s \ + --arg commit "$EXPECTED_COMMIT" \ + --arg repository "$EXPECTED_REPOSITORY" \ + --arg run_id "$EXPECTED_RUN_ID" \ + --arg tag "$EXPECTED_TAG" \ + --arg version "$EXPECTED_VERSION" \ + 'length == 1 + and .[0].schema == 1 + and .[0].repository == $repository + and .[0].tag == $tag + and .[0].version == $version + and .[0].commit == $commit + and .[0].prepare_run_id == $run_id' \ + "$provenance" >/dev/null || + die "prepared run provenance does not match the requested release" + +expected_archives=( + "pagedb-fsck-${EXPECTED_VERSION}-linux-x64.tar.gz" + "pagedb-fsck-${EXPECTED_VERSION}-linux-arm64.tar.gz" + "pagedb-fsck-${EXPECTED_VERSION}-macos-arm64.tar.gz" + "pagedb-fsck-${EXPECTED_VERSION}-macos-x64.tar.gz" + "pagedb-fsck-${EXPECTED_VERSION}-windows-x64.zip" +) + +shopt -s nullglob dotglob +entries=("$artifact_dir"/*) +shopt -u nullglob dotglob +test "${#entries[@]}" -eq "${#expected_archives[@]}" || + die "expected exactly ${#expected_archives[@]} release archives" + +for entry in "${entries[@]}"; do + test -f "$entry" || die "unexpected non-file release attachment: $entry" +done +for archive in "${expected_archives[@]}"; do + test -f "$artifact_dir/$archive" || + die "prepared run is missing $archive" +done + +printf 'release provenance: ok\n' diff --git a/scripts/test-release-provenance.sh b/scripts/test-release-provenance.sh new file mode 100755 index 0000000..7e87032 --- /dev/null +++ b/scripts/test-release-provenance.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'release provenance contract: %s\n' "$*" >&2 + exit 1 +} + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +verifier="$repo_root/scripts/ci/verify_release_provenance.sh" +test -f "$verifier" || die "missing scripts/ci/verify_release_provenance.sh" + +fixture="$(mktemp -d "${TMPDIR:-/tmp}/pagedb-release-provenance.XXXXXX")" +trap 'rm -rf -- "$fixture"' EXIT +mkdir -p "$fixture/artifacts" "$fixture/provenance" + +export EXPECTED_COMMIT=0123456789abcdef0123456789abcdef01234567 +export EXPECTED_REPOSITORY=nodedb-lab/pagedb +export EXPECTED_RUN_ID=123456789 +export EXPECTED_TAG=v0.2.0-beta.1 +export EXPECTED_VERSION=0.2.0-beta.1 + +provenance="$fixture/provenance/release-provenance.json" +jq -n \ + --arg commit "$EXPECTED_COMMIT" \ + --arg repository "$EXPECTED_REPOSITORY" \ + --arg run_id "$EXPECTED_RUN_ID" \ + --arg tag "$EXPECTED_TAG" \ + --arg version "$EXPECTED_VERSION" \ + '{schema:1, repository:$repository, tag:$tag, version:$version, commit:$commit, prepare_run_id:$run_id}' \ + >"$provenance" + +for archive in \ + "pagedb-fsck-${EXPECTED_VERSION}-linux-x64.tar.gz" \ + "pagedb-fsck-${EXPECTED_VERSION}-linux-arm64.tar.gz" \ + "pagedb-fsck-${EXPECTED_VERSION}-macos-arm64.tar.gz" \ + "pagedb-fsck-${EXPECTED_VERSION}-macos-x64.tar.gz" \ + "pagedb-fsck-${EXPECTED_VERSION}-windows-x64.zip"; do + : >"$fixture/artifacts/$archive" +done + +verify() { + bash "$verifier" "$provenance" "$fixture/artifacts" +} + +verify | grep -F 'release provenance: ok' >/dev/null || + die "valid provenance and artifact set was rejected" + +for field in repository tag version commit prepare_run_id; do + original="$fixture/original.json" + cp "$provenance" "$original" + jq --arg field "$field" '.[$field] = "wrong"' "$original" >"$provenance" + if verify >"$fixture/invalid.out" 2>"$fixture/invalid.err"; then + die "mismatched $field was accepted" + fi + mv "$original" "$provenance" +done + +cp "$provenance" "$fixture/original.json" +jq '.schema = 2' "$fixture/original.json" >"$provenance" +if verify >"$fixture/invalid.out" 2>"$fixture/invalid.err"; then + die "unsupported provenance schema was accepted" +fi +mv "$fixture/original.json" "$provenance" + +missing="$fixture/artifacts/pagedb-fsck-${EXPECTED_VERSION}-macos-x64.tar.gz" +mv "$missing" "$fixture/missing.archive" +if verify >"$fixture/missing.out" 2>"$fixture/missing.err"; then + die "missing platform archive was accepted" +fi +mv "$fixture/missing.archive" "$missing" + +: >"$fixture/artifacts/unexpected.txt" +if verify >"$fixture/extra.out" 2>"$fixture/extra.err"; then + die "unexpected release attachment was accepted" +fi + +printf 'release provenance contract: ok\n' diff --git a/scripts/test-release-workflow-contract.sh b/scripts/test-release-workflow-contract.sh new file mode 100755 index 0000000..615b084 --- /dev/null +++ b/scripts/test-release-workflow-contract.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'release workflow contract: %s\n' "$*" >&2 + exit 1 +} + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +workflow_dir="$repo_root/.github/workflows" +prepare="$workflow_dir/release-prepare.yml" +validate="$workflow_dir/release-validate.yml" +release="$workflow_dir/release.yml" +test_workflow="$workflow_dir/test.yml" + +for path in "$prepare" "$validate" "$release" "$test_workflow"; do + test -f "$path" || die "missing ${path#"$repo_root"/}" +done + +grep -F 'name: Release Prepare' "$prepare" >/dev/null || + die "prepare workflow has the wrong name" +grep -F 'tags:' "$prepare" >/dev/null || + die "prepare workflow is not tag-triggered" + +( + cd "$repo_root" + cargo package -p pagedb --locked --allow-dirty --no-verify >/dev/null +) || die "tracked Cargo.lock cannot package the release crate" + +grep -F 'uses: ./.github/workflows/release-validate.yml' "$prepare" >/dev/null || + die "prepare workflow bypasses reusable tag validation" +grep -F 'uses: ./.github/workflows/test.yml' "$prepare" >/dev/null || + die "prepare workflow bypasses the full reusable test suite" +grep -F 'name: release-provenance' "$prepare" >/dev/null || + die "prepare workflow does not retain release provenance" +grep -F 'retention-days: 14' "$prepare" >/dev/null || + die "prepare artifacts do not retain the documented retry window" +grep -F 'cargo build --locked --release --bin pagedb-fsck' "$prepare" >/dev/null || + die "prepared binaries are not built from the aligned lockfile" + +grep -F 'workflow_call:' "$validate" >/dev/null || + die "tag validation is not reusable" +# shellcheck disable=SC2016 # Match the workflow expression literally. +grep -F 'ref: ${{ inputs.ref }}' "$validate" >/dev/null || + die "tag validation does not check out the requested ref" +grep -F 'commit:' "$validate" >/dev/null || + die "tag validation does not publish the exact tag commit" +# shellcheck disable=SC2016 # Match the shell expression literally. +grep -F 'git rev-parse "refs/tags/${TAG}^{commit}"' "$validate" >/dev/null || + die "tag validation does not resolve the tag's peeled commit" + +grep -F 'workflow_dispatch:' "$release" >/dev/null || + die "distribution workflow is not manually dispatched" +if grep -F 'tags:' "$release" >/dev/null; then + die "distribution workflow still publishes directly from a tag push" +fi +grep -F 'prepare_run_id:' "$release" >/dev/null || + die "distribution workflow does not require a prepare run" +grep -F 'validate-prepare:' "$release" >/dev/null || + die "distribution workflow has no shared prepare-run gate" +test "$( + grep -F -c 'needs: [validate-version, validate-prepare]' "$release" +)" -eq 2 || + die "crate and GitHub Release stages do not both depend on the prepare-run gate" +grep -F 'Verify prepared release provenance' "$release" >/dev/null || + die "distribution workflow does not verify prepared provenance" +grep -F 'run: bash scripts/ci/verify_release_provenance.sh' "$release" >/dev/null || + die "distribution workflow does not use the tested provenance verifier" +grep -F 'cargo publish -p pagedb --locked --allow-dirty' "$release" >/dev/null || + die "crate publication is not a locked verifying publish" + +for contract in \ + scripts/test-stamp-version.sh \ + scripts/test-release-provenance.sh \ + scripts/test-release-workflow-contract.sh; do + grep -F "bash $contract" "$test_workflow" >/dev/null || + die "$contract is not run by the reusable test workflow" +done + +macos_x64_entry="$( + awk ' + /target: x86_64-apple-darwin/ { + print previous + print + getline + print + exit + } + { previous = $0 } + ' "$prepare" +)" +grep -F 'runs-on: macos-latest' <<<"$macos_x64_entry" >/dev/null || + die "macOS x64 is not cross-built on the current Apple Silicon runner" +if grep -F 'macos-13' "$prepare" >/dev/null; then + die "prepare workflow still selects the retired Intel runner" +fi + +checkout_sha=3d3c42e5aac5ba805825da76410c181273ba90b1 +upload_sha=043fb46d1a93c77aae656e7c1c64a875d1fc6a0a +download_sha=3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c +release_sha=3d0d9888cb7fd7b750713d6e236d1fcb99157228 +rust_toolchain_sha=4cda84d5c5c54efe2404f9d843567869ab1699d4 +rust_cache_sha=e18b497796c12c097a38f9edb9d0641fb99eee32 +install_action_sha=41049aa56687c35e0afa74eed4f09cec4f9afabf + +if grep -E \ + 'uses: (actions/(checkout|upload-artifact|download-artifact)|softprops/action-gh-release|dtolnay/rust-toolchain)@(v[0-9]+|stable)' \ + "$prepare" "$validate" "$release" "$test_workflow" >/dev/null; then + die "a release action still uses a mutable tag" +fi + +while IFS= read -r use_line; do + action_ref="${use_line#*uses: }" + action_ref="${action_ref%% *}" + [[ "$action_ref" == ./* ]] && continue + [[ "$action_ref" =~ @[0-9a-f]{40}$ ]] || + die "release action is not immutably pinned: $action_ref" +done < <( + grep -h -E '^[[:space:]]*(- )?uses:' \ + "$prepare" "$validate" "$release" "$test_workflow" +) + +grep -R -F "uses: actions/checkout@$checkout_sha # v7" \ + "$prepare" "$validate" "$release" "$test_workflow" >/dev/null || + die "checkout v7 immutable pin is missing" +grep -F "uses: actions/upload-artifact@$upload_sha # v7" \ + "$prepare" "$test_workflow" >/dev/null || + die "upload-artifact v7 immutable pin is missing" +grep -F "uses: actions/download-artifact@$download_sha # v8" "$release" >/dev/null || + die "download-artifact v8 immutable pin is missing" +grep -F "uses: softprops/action-gh-release@$release_sha # v3" "$release" >/dev/null || + die "action-gh-release v3 immutable pin is missing" +grep -R -F "uses: dtolnay/rust-toolchain@$rust_toolchain_sha # stable" \ + "$prepare" "$validate" "$release" "$test_workflow" >/dev/null || + die "Rust toolchain action immutable pin is missing" +grep -F "uses: Swatinem/rust-cache@$rust_cache_sha # v2" "$test_workflow" >/dev/null || + die "Rust cache action immutable pin is missing" +grep -F "uses: taiki-e/install-action@$install_action_sha # v2.85.2" \ + "$test_workflow" >/dev/null || + die "tool installer action immutable pin is missing" + +checkout_count="$( + awk -v needle="uses: actions/checkout@$checkout_sha # v7" ' + index($0, needle) { count++ } + END { print count + 0 } + ' "$prepare" "$validate" "$release" "$test_workflow" +)" +credential_guard_count="$( + awk ' + index($0, "persist-credentials: false") { count++ } + END { print count + 0 } + ' "$prepare" "$validate" "$release" "$test_workflow" +)" +test "$checkout_count" -eq "$credential_guard_count" || + die "every release checkout must disable persisted credentials" + +if grep -F 'Swatinem/rust-cache' "$prepare" >/dev/null; then + die "release artifact builds must not depend on a mutable compiler cache" +fi + +if grep -E -i 'rocksdb|sqlite|redb|libclang|install llvm|install clang' \ + "$prepare" "$validate" "$release" >/dev/null; then + die "release workflows add an external-engine or native-toolchain prerequisite" +fi + +printf 'release workflow contract: ok\n' diff --git a/scripts/test-stamp-version.sh b/scripts/test-stamp-version.sh new file mode 100755 index 0000000..20b7ae0 --- /dev/null +++ b/scripts/test-stamp-version.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'stamp version contract: %s\n' "$*" >&2 + exit 1 +} + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_script="$repo_root/scripts/ci/stamp_version.sh" +test -f "$source_script" || die "missing scripts/ci/stamp_version.sh" + +fixture="$(mktemp -d "${TMPDIR:-/tmp}/pagedb-stamp-version.XXXXXX")" +trap 'rm -rf -- "$fixture"' EXIT +mkdir -p "$fixture/consumer/src" "$fixture/scripts/ci" "$fixture/src" +cp "$source_script" "$fixture/scripts/ci/stamp_version.sh" + +cat >"$fixture/Cargo.toml" <<'TOML' +[workspace] +members = ["consumer"] +resolver = "3" + +[package] +name = "pagedb" +version = "0.1.0" +edition = "2024" + +[package.metadata.fixture] +version = "9.9.9" +TOML + +cat >"$fixture/src/lib.rs" <<'RUST' +pub fn fixture() {} +RUST + +cat >"$fixture/consumer/Cargo.toml" <<'TOML' +[package] +name = "pagedb-stamp-consumer" +version = "0.0.0" +edition = "2024" + +[dependencies] +pagedb = { path = ".." } +TOML + +cat >"$fixture/consumer/src/lib.rs" <<'RUST' +pub fn fixture() { + pagedb::fixture(); +} +RUST + +( + cd "$fixture" + cargo generate-lockfile --quiet --offline +) + +run_stamp() { + ( + cd "$fixture" + bash scripts/ci/stamp_version.sh "$@" + ) +} + +file_mode() { + stat -f '%Lp' "$1" 2>/dev/null || stat -c '%a' "$1" +} + +original_mode="$(file_mode "$fixture/Cargo.toml")" +run_stamp 0.1.0 | grep -F 'Version already 0.1.0' >/dev/null || + die "same-version run is not an explicit no-op" + +run_stamp 0.1.0-beta.3 | grep -F 'Stamped package version: 0.1.0 -> 0.1.0-beta.3' >/dev/null || + die "valid prerelease version was not stamped" +grep -F 'version = "0.1.0-beta.3"' "$fixture/Cargo.toml" >/dev/null || + die "package version did not change" +grep -F 'version = "9.9.9"' "$fixture/Cargo.toml" >/dev/null || + die "metadata version was changed with the package version" +test "$(file_mode "$fixture/Cargo.toml")" = "$original_mode" || + die "stamping changed Cargo.toml permissions" +test "$( + cd "$fixture" + cargo metadata --locked --no-deps --format-version=1 | + jq -r '.packages[] | select(.name == "pagedb") | .version' +)" = '0.1.0-beta.3' || die "Cargo does not observe the stamped prerelease" +test "$( + awk ' + /^\[\[package\]\]$/ { in_pagedb = 0 } + /^name = "pagedb"$/ { in_pagedb = 1; next } + in_pagedb && /^version = / { + gsub(/^version = "|"$|"/, "") + print + exit + } + ' "$fixture/Cargo.lock" +)" = '0.1.0-beta.3' || die "workspace lockfile did not receive the stamped version" +( + cd "$fixture" + cargo package --quiet --locked --allow-dirty --no-verify --offline +) || die "stamped prerelease does not support a locked package build" + +before="$(shasum -a 256 "$fixture/Cargo.toml" | awk '{print $1}')" +run_stamp 0.1.0-beta.3 | grep -F 'Version already 0.1.0-beta.3' >/dev/null || + die "repeated prerelease run is not idempotent" +after="$(shasum -a 256 "$fixture/Cargo.toml" | awk '{print $1}')" +test "$before" = "$after" || die "idempotent run rewrote Cargo.toml" + +cp "$fixture/Cargo.lock" "$fixture/Cargo.lock.valid" +printf 'not a cargo lockfile\n' >"$fixture/Cargo.lock" +manifest_before="$(shasum -a 256 "$fixture/Cargo.toml" | awk '{print $1}')" +lock_before="$(shasum -a 256 "$fixture/Cargo.lock" | awk '{print $1}')" +if run_stamp 0.1.0-rc.1 >"$fixture/rollback.out" 2>"$fixture/rollback.err"; then + die "stamp succeeded when lockfile alignment failed" +fi +test "$(shasum -a 256 "$fixture/Cargo.toml" | awk '{print $1}')" = "$manifest_before" || + die "failed lockfile alignment did not restore Cargo.toml" +test "$(shasum -a 256 "$fixture/Cargo.lock" | awk '{print $1}')" = "$lock_before" || + die "failed lockfile alignment did not restore Cargo.lock" +mv "$fixture/Cargo.lock.valid" "$fixture/Cargo.lock" + +# shellcheck disable=SC2016 # Verify shell-looking input remains inert. +for invalid in \ + '1.2' \ + 'v1.2.3' \ + '1.2.3 beta.1' \ + '1.2.3-' \ + '1.2.3-beta' \ + '1.2.3-$(touch SHOULD_NOT_EXIST)'; do + before="$(shasum -a 256 "$fixture/Cargo.toml" | awk '{print $1}')" + if run_stamp "$invalid" >"$fixture/invalid.out" 2>"$fixture/invalid.err"; then + die "invalid version was accepted: $invalid" + fi + after="$(shasum -a 256 "$fixture/Cargo.toml" | awk '{print $1}')" + test "$before" = "$after" || die "invalid version modified Cargo.toml: $invalid" +done + +test ! -e "$fixture/SHOULD_NOT_EXIST" || + die "invalid version executed shell content" + +if run_stamp >"$fixture/missing.out" 2>"$fixture/missing.err"; then + die "missing version argument was accepted" +fi + +printf 'stamp version contract: ok\n'