cachekit-core 0.7.2

LZ4 compression, xxHash3 integrity, AES-256-GCM encryption for byte payloads
Documentation
name: Release

on:
  push:
    branches:
      - main
  # Manual re-publish escape hatch: lets the publish job run for the version
  # currently on main when an automated release published the tag but the
  # publish job failed downstream (e.g. a transient Sigstore/Fulcio outage).
  workflow_dispatch: {}

permissions:
  contents: write
  pull-requests: write

# Serialise release runs so a push and a manual dispatch can't race into two
# concurrent cargo publish attempts. Never cancel an in-flight publish.
concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  # GitHub-hosted: this job inherits contents+PR write and mints an App
  # installation token, so it must not share a writable build cache with other
  # jobs — job-level `permissions:` does not isolate a filesystem, and untrusted
  # build-script/proc-macro code could poison a cache a later credentialed job
  # runs against. A fresh hosted VM has no shared cache to poison, and
  # release-please needs no warm cache anyway.
  release-please:
    runs-on: ubuntu-latest
    outputs:
      release_created: ${{ steps.release.outputs.release_created }}
      tag_name: ${{ steps.release.outputs.tag_name }}
    steps:
      - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
        id: app-token
        with:
          app-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}

      - uses: googleapis/release-please-action@8b8fd2cc23b2e18957157a9d923d75aa0c6f6ad5 # v4
        id: release
        with:
          token: ${{ steps.app-token.outputs.token }}

      # release-please's config schema has no assignees option, so assign the
      # release PR here. outputs.pr is set whenever the release PR was created
      # OR updated, so this re-runs on every push to main while a release PR is
      # open — adding an already-present assignee is a no-op, so that's safe.
      # github-script, NOT `gh`: it calls the REST API in-process, so the step
      # does not depend on the gh CLI being on the runner image.
      # outputs.pr is passed raw via env and parsed in JS — never
      # through template-position fromJson, which is evaluated even when if: is
      # false and crashes on '' for no-release pushes (LAB-865).
      - name: Assign release PR to 27Bslash6
        if: ${{ steps.release.outputs.pr }}
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
        env:
          PR_JSON: ${{ steps.release.outputs.pr }}
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            try {
              const pr = JSON.parse(process.env.PR_JSON);
              await github.rest.issues.addAssignees({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                assignees: ['27Bslash6'],
              });
              core.info(`Assigned 27Bslash6 to release PR #${pr.number}`);
            } catch (error) {
              core.setFailed(`Failed to assign release PR in ${context.repo.owner}/${context.repo.repo} (PR_JSON length ${process.env.PR_JSON.length}): ${error.message}`);
            }

  # The untrusted-code half of a release. Tests and the verifying package
  # build run dependency build scripts and proc-macros, so they run here, with
  # no id-token and no crates.io token. `publish` below runs no dependency
  # code. GitHub-hosted, like every job in this workflow.
  verify:
    needs: release-please
    # Same gate as `publish`: an automated release, or a manual dispatch from
    # main (workflow_dispatch can fire from any ref; requiring main stops a
    # feature branch from driving a publish). Every job checks out github.sha,
    # which is fixed for the run, so all three build the same commit.
    # Explicit == 'true' avoids relying on string-coercion of the action output.
    if: ${{ needs.release-please.outputs.release_created == 'true' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }}
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - name: Cache cargo registry
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2

      - name: Run tests before publish
        run: cargo test --locked --all-features

      - name: Package and verify the crate
        # Builds the packaged crate — the build `publish` skips with --no-verify.
        run: cargo package --locked

  # cargo-sbom only reads `cargo metadata`, so this job builds nothing from the
  # project: no dependency build script can rewrite the SBOM before upload.
  sbom:
    needs: verify
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
    # `publish` downloads this exact upload and checks this digest, so an
    # artifact replaced later in the run is refused rather than attested.
    outputs:
      artifact-id: ${{ steps.upload.outputs.artifact-id }}
      sha256: ${{ steps.sbom.outputs.sha256 }}
    steps:
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        run: |
          rustup toolchain install stable --profile minimal
          rustup default stable

      - name: Install cargo-sbom
        run: cargo install cargo-sbom --version 0.10.0 --locked

      - name: Generate SBOM
        id: sbom
        # An empty SBOM fails the release: attesting it would claim a
        # dependency-free crate.
        run: |
          cargo sbom --output-format cyclone_dx_json_1_6 > sbom.cdx.json
          count=$(jq -e '.components | length' sbom.cdx.json) || {
            echo "::error::SBOM is empty or not JSON"; exit 1; }
          echo "SBOM components: $count"
          if [ "${count:-0}" -lt 1 ]; then
            echo "::error::SBOM lists no components"
            exit 1
          fi
          echo "sha256=$(sha256sum sbom.cdx.json | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"

      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        id: upload
        with:
          name: sbom
          path: sbom.cdx.json
          if-no-files-found: error

  publish:
    # With no status function in `if:`, GitHub adds an implicit success(), so
    # a failed or skipped verify or sbom skips the publish.
    needs: [release-please, verify, sbom]
    if: ${{ needs.release-please.outputs.release_created == 'true' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }}
    runs-on: ubuntu-latest
    timeout-minutes: 30
    # crates.io trusted publishing matches owner, repository, workflow file
    # and this environment, never the branch; the environment's deployment
    # branch policy admits main alone.
    environment: release
    permissions:
      contents: read
      id-token: write
      attestations: write
    steps:
      # Every step here can mint a crates.io publish token, so nothing runs
      # that doesn't need it: no dependency code (verification happened in
      # `verify`), no cache, and no third-party action besides
      # rust-lang/crates-io-auth-action, which mints the token. The rest are
      # first-party actions/*.
      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        run: |
          rustup toolchain install stable --profile minimal
          rustup default stable

      - name: Package crate
        # --no-verify: packaging alone runs no dependency code. cargo packages
        # deterministically, so these are the bytes `cargo publish` uploads —
        # the check after publishing proves it against crates.io.
        run: |
          cargo package --locked --no-verify
          (cd target/package && sha256sum -- *.crate) > "$RUNNER_TEMP/attested.sha256"
          cat "$RUNNER_TEMP/attested.sha256"

      # Into runner.temp, never the checkout: cargo refuses to package or
      # publish a tree with untracked files.
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          artifact-ids: ${{ needs.sbom.outputs.artifact-id }}
          path: ${{ runner.temp }}/sbom

      - name: Check the SBOM is the one `sbom` generated
        env:
          SBOM_SHA256: ${{ needs.sbom.outputs.sha256 }}
        run: |
          cd "$RUNNER_TEMP/sbom"
          printf '%s  %s\n' "$SBOM_SHA256" sbom.cdx.json | sha256sum --strict -c

      # Attest before publishing: a crates.io upload is irreversible, so a
      # Sigstore outage blocks the release instead of shipping it unattested.
      - name: Attest Build Provenance
        uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
        with:
          subject-path: target/package/*.crate

      - name: Attest SBOM
        uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
        with:
          subject-path: target/package/*.crate
          sbom-path: ${{ runner.temp }}/sbom/sbom.cdx.json

      - name: Get crates.io token
        # Exchanges this job's OIDC token for a 30-minute crates.io token; the
        # action's post step revokes it when the job ends.
        uses: rust-lang/crates-io-auth-action@3268eb413133e2960fa4ae0e963ca76380c54cd9 # v1.0.6
        id: auth

      - name: Publish to crates.io
        run: cargo publish --locked --no-verify
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}

      - name: Check the uploaded crate is the attested file
        # Downloads what crates.io now serves and compares it with the digest
        # the attestations name. A mismatch means the attestations cover a
        # file nobody can download. The crate is already live and a version
        # cannot be re-uploaded, so a re-run cannot repair it: release a
        # patch, then yank the version. This step makes the mismatch loud.
        run: |
          mkdir "$RUNNER_TEMP/uploaded"
          for path in target/package/cachekit-core-[0-9]*.crate; do
            file=${path##*/}
            curl -sSf --connect-timeout 10 --max-time 60 --retry 5 --retry-all-errors --retry-delay 10 \
              -o "$RUNNER_TEMP/uploaded/$file" "https://static.crates.io/crates/cachekit-core/$file"
          done
          cd "$RUNNER_TEMP/uploaded"
          if ! sha256sum --strict -c ../attested.sha256; then
            echo "::error::crates.io serves a .crate that differs from the attested file. The version is live and cannot be re-uploaded: do not re-run. Release a patch, then yank the version."
            exit 1
          fi