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'