release.yml

In .github/workflows/ · At 619cbad

619cbadHistory

Preview

Code · 35.4 KiB
name: "Release: Artifacts"

# Triggered by pushing a semver tag (v0.1.0, v1.2.3, etc.).
#
# This workflow:
#   1. Builds mkit binaries for macOS (arm64/x86_64) and
#      Linux (x86_64/arm64; glibc, dynamically linked) with a pinned Rust
#      toolchain (see rust/rust-toolchain.toml), one archive per target.
#      Windows is not a supported target (MKIT-6; see docs/INVARIANTS.md) —
#      WSL runs the Linux binary.
#   2. Produces a CycloneDX SBOM (sbom.cdx.json).
#   3. Signs every archive with cosign OIDC keyless (transparency log: Rekor)
#      and signs the aggregated SHA256SUMS the same way.
#   4. Generates a GitHub-native SLSA build provenance attestation
#      (actions/attest-build-provenance) over the archives, in addition to
#      the cosign signatures.
#   5. Aggregates all artifacts into a GitHub Release.
#
# macOS binaries are intentionally NOT codesigned or notarized — we do not
# hold an Apple Developer ID. Trust is established via cosign keyless
# signatures, which are verified by `install.sh` and documented in
# docs/RELEASE.md. Users who launch the binary by double-clicking
# in Finder will hit Gatekeeper; running from a terminal works.
#
# Cache safety: every Swatinem/rust-cache invocation here uses a release-
# specific `prefix-key`, partitioned away from the keys that PR-event
# workflows write to. This prevents a malicious PR from poisoning a cache
# that this workflow (which carries `secrets.MKIT_NPM_TOKEN` and
# `id-token: write`) would later deserialize. See the cache-poisoning
# supply-chain audit pass.
#
# No secrets are needed for cosign keyless — OIDC token comes from GitHub.

on:
  push:
    tags:
      - 'v*.*.*'
  # Manual escape hatch. GitHub occasionally de-dupes tag-push events when
  # a prior workflow for a nearby tag is still active or recently cancelled.
  # workflow_dispatch lets us force a release build against an existing tag
  # from the Actions UI. The workflow must be run from that tag ref, not from
  # a branch, so cosign's OIDC certificate identity remains tag-scoped.
  workflow_dispatch:
    inputs:
      tag:
        description: 'Existing tag to build (e.g. v0.1.0). Must already be pushed to origin.'
        required: true
        type: string

permissions:
  contents: read

env:
  RUST_VERSION: "1.95.0"
  BINARY_NAME: mkit

jobs:
  validate-release-tag:
    name: Validate release tag
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      tag: ${{ steps.validate.outputs.tag }}
      ref: ${{ steps.validate.outputs.ref }}
      tag_object: ${{ steps.validate.outputs.tag_object }}
      commit: ${{ steps.validate.outputs.commit }}
      version: ${{ steps.validate.outputs.version }}
    steps:
      - name: Checkout release refs
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          fetch-depth: 0

      - name: Validate tag trust and ancestry
        id: validate
        shell: bash
        env:
          DISPATCH_TAG: ${{ inputs.tag }}
          TRUSTED_FINGERPRINTS: ${{ vars.MKIT_RELEASE_GPG_FINGERPRINTS }}
        run: |
          set -euo pipefail

          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            TAG="${DISPATCH_TAG}"
          else
            TAG="${GITHUB_REF_NAME}"
          fi

          if [ -z "${TAG}" ]; then
            echo "ERROR: no tag resolved (event=${{ github.event_name }})" >&2
            exit 1
          fi

          if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
            echo "ERROR: release tag must match vMAJOR.MINOR.PATCH with optional prerelease suffix: ${TAG}" >&2
            exit 1
          fi

          if [ -z "${TRUSTED_FINGERPRINTS}" ]; then
            echo "ERROR: MKIT_RELEASE_GPG_FINGERPRINTS repo/org variable is required" >&2
            exit 1
          fi
          TRUSTED_FINGERPRINTS="$(printf '%s' "${TRUSTED_FINGERPRINTS}" | tr '[:lower:]' '[:upper:]')"

          git fetch --force --tags origin
          git fetch --force origin main:refs/remotes/origin/main

          # Import each trusted key from a keyserver. We gate on the key
          # actually LANDING IN THE KEYRING, not on `gpg --recv-keys`' exit
          # code: keys.openpgp.org serves a key with NO user IDs until its
          # owner verifies their email, and GnuPG SKIPS importing a UID-less
          # key ("contains no user ID - skipped") while STILL exiting 0 — so a
          # bare `recv || recv` fallback never fires and the next step dies
          # with NO_PUBKEY. (This broke v0.2.0's first release run.)
          #
          # keyserver.ubuntu.com serves user IDs WITHOUT email verification, so
          # it's tried first; keys.openpgp.org is a fallback. After each fetch
          # we check the key is present (UID-bearing) before trusting it.
          KEYSERVERS="hkps://keyserver.ubuntu.com hkps://keys.openpgp.org"
          for TRUSTED_FINGERPRINT in ${TRUSTED_FINGERPRINTS}; do
            if [[ ! "${TRUSTED_FINGERPRINT}" =~ ^[A-Fa-f0-9]{40}$ ]]; then
              echo "ERROR: invalid GPG fingerprint in MKIT_RELEASE_GPG_FINGERPRINTS: ${TRUSTED_FINGERPRINT}" >&2
              exit 1
            fi
            IMPORTED=0
            for KS in ${KEYSERVERS}; do
              gpg --batch --keyserver "${KS}" --recv-keys "${TRUSTED_FINGERPRINT}" || true
              if gpg --list-keys "${TRUSTED_FINGERPRINT}" >/dev/null 2>&1; then
                echo "Imported ${TRUSTED_FINGERPRINT} from ${KS}"
                IMPORTED=1
                break
              fi
              echo "WARN: ${KS} did not yield a usable (UID-bearing) key for ${TRUSTED_FINGERPRINT}; trying next" >&2
            done
            if [ "${IMPORTED}" -ne 1 ]; then
              echo "ERROR: could not import GPG key ${TRUSTED_FINGERPRINT} from any keyserver (${KEYSERVERS})." >&2
              echo "       If the key lives only on keys.openpgp.org, verify its email there so it serves user IDs." >&2
              exit 1
            fi
          done

          REF="refs/tags/${TAG}"
          if ! git show-ref --verify --quiet "${REF}"; then
            echo "ERROR: tag ${TAG} does not exist" >&2
            exit 1
          fi

          if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${GITHUB_REF}" != "${REF}" ]; then
            echo "ERROR: manual releases must run from ${REF}, not ${GITHUB_REF}" >&2
            echo "Select the release tag in the Actions UI's 'Use workflow from' picker and keep the tag input identical." >&2
            exit 1
          fi

          if [ "$(git cat-file -t "${REF}")" != "tag" ]; then
            echo "ERROR: ${TAG} must be an annotated signed tag, not a lightweight tag" >&2
            exit 1
          fi

          VERIFY_LOG="$(mktemp)"
          if ! git verify-tag --raw -v "${REF}" >"${VERIFY_LOG}" 2>&1; then
            cat "${VERIFY_LOG}" >&2
            echo "ERROR: ${TAG} does not have a valid GPG signature" >&2
            exit 1
          fi

          FINGERPRINT="$(awk '/^\[GNUPG:\] VALIDSIG / { print $3; exit }' "${VERIFY_LOG}" | tr '[:lower:]' '[:upper:]')"
          if [ -z "${FINGERPRINT}" ]; then
            cat "${VERIFY_LOG}" >&2
            echo "ERROR: could not extract tag signing fingerprint for ${TAG}" >&2
            exit 1
          fi

          case " ${TRUSTED_FINGERPRINTS} " in
            *" ${FINGERPRINT} "*) ;;
            *)
              echo "ERROR: tag signer ${FINGERPRINT} is not allowlisted in MKIT_RELEASE_GPG_FINGERPRINTS" >&2
              exit 1
              ;;
          esac

          TAG_OBJECT="$(git rev-parse "${REF}^{tag}")"
          TAG_COMMIT="$(git rev-parse "${REF}^{commit}")"
          if ! git merge-base --is-ancestor "${TAG_COMMIT}" origin/main; then
            echo "ERROR: ${TAG} target commit ${TAG_COMMIT} is not reachable from origin/main" >&2
            exit 1
          fi

          VERSION="${TAG#v}"

          {
            echo "tag=${TAG}"
            echo "ref=${REF}"
            echo "tag_object=${TAG_OBJECT}"
            echo "commit=${TAG_COMMIT}"
            echo "version=${VERSION}"
          } >> "$GITHUB_OUTPUT"

  build:
    name: Build ${{ matrix.target }}
    runs-on: ${{ matrix.runner }}
    needs: validate-release-tag
    permissions:
      contents: read
      id-token: write       # cosign keyless OIDC
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: aarch64-apple-darwin
            runner: macos-14
            archive_ext: tar.gz
          # x86_64 macOS built on the arm64 host via rustup cross-compile.
          - target: x86_64-apple-darwin
            runner: macos-14
            archive_ext: tar.gz
          # Pin both Linux runners to keep the linked glibc requirement stable.
          - target: x86_64-unknown-linux-gnu
            runner: ubuntu-24.04
            archive_ext: tar.gz
          - target: aarch64-unknown-linux-gnu
            runner: ubuntu-24.04-arm
            archive_ext: tar.gz
    outputs:
      version: ${{ steps.meta.outputs.version }}
    steps:
      - name: Resolve release metadata
        id: meta
        shell: bash
        run: |
          TAG="${{ needs.validate-release-tag.outputs.tag }}"
          REF="${{ needs.validate-release-tag.outputs.ref }}"
          COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
          VERSION="${{ needs.validate-release-tag.outputs.version }}"
          echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
          echo "ref=${REF}" >> "$GITHUB_OUTPUT"
          echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
          echo "archive=${{ env.BINARY_NAME }}-${VERSION}-${{ matrix.target }}.${{ matrix.archive_ext }}" >> "$GITHUB_OUTPUT"
          echo "stage=${{ env.BINARY_NAME }}-${VERSION}-${{ matrix.target }}" >> "$GITHUB_OUTPUT"

      - name: Checkout (at resolved tag)
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          ref: ${{ steps.meta.outputs.commit }}
          fetch-depth: 0

      - name: Install Rust ${{ env.RUST_VERSION }} + target
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8  # stable
        with:
          toolchain: ${{ env.RUST_VERSION }}
          targets: ${{ matrix.target }}
          components: rustfmt,clippy

      - name: Cache cargo artifacts
        uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16  # v2
        with:
          workspaces: rust -> rust/target
          # Partition release caches away from PR-event workflow caches —
          # see header note on cache poisoning.
          prefix-key: "rust-release-${{ matrix.target }}"
          key: release-${{ matrix.target }}
          cache-on-failure: false

      - name: Install protoc
        uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b  # v3.0.0
        with:
          version: '31.0'
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      # Select only mkit-cli with -p to avoid workspace feature unification (D31).
      - name: Build release mkit binary
        shell: bash
        working-directory: rust
        env:
          # codegen-units=1 + strip = reproducible-build friendly; mirrors
          # [profile.release] in rust/Cargo.toml.
          RUSTFLAGS: "-C codegen-units=1 -C strip=symbols"
        run: |
          cargo build \
            --release \
            --locked \
            --target ${{ matrix.target }} \
            -p mkit-cli \
            --bin ${{ env.BINARY_NAME }} \
            --message-format=json-render-diagnostics > "${RUNNER_TEMP}/mkit-build.jsonl"

      # Checks what the mkit build really compiled (its compiler-artifact
      # messages) and scans the stripped binary: only packages in
      # scripts/release/mkit-packages.golden, no server-only package, no
      # hyper `server`, no connectrpc `server`/`axum`, tower-http only with
      # reqwest's features, no SQLite, no test seam. release-artifact-check.yml
      # runs the same builds and check on PRs to main for one Linux target.
      - name: Check release mkit artifact
        shell: bash
        run: |
          bash scripts/check-release-artifact-features.sh \
            "${RUNNER_TEMP}/mkit-build.jsonl" \
            "rust/target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"

      - name: Smoke-test release mkit binary
        shell: bash
        working-directory: rust
        run: |
          set -euo pipefail
          HOST="$(rustc -vV | sed -n 's/^host: //p')"
          if [ "${HOST}" != "${{ matrix.target }}" ]; then
            echo "Cross-built for ${{ matrix.target }} on ${HOST}; skipping the run check."
            exit 0
          fi
          VERSION="${{ steps.meta.outputs.version }}"
          BIN_DIR="target/${{ matrix.target }}/release"
          test "$("${BIN_DIR}/${{ env.BINARY_NAME }}" version)" = "mkit ${VERSION}"
      - name: Stage archive contents
        shell: bash
        run: |
          STAGE="${{ steps.meta.outputs.stage }}"
          mkdir -p "${STAGE}"
          BIN_SRC="rust/target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
          if [ -f "${BIN_SRC}" ]; then
            cp "${BIN_SRC}" "${STAGE}/"
          else
            echo "ERROR: ${{ env.BINARY_NAME }} binary not found at ${BIN_SRC}" >&2
            ls -la "rust/target/${{ matrix.target }}/release/" || true
            exit 1
          fi
          cp LICENSE-MIT LICENSE-APACHE README.md "${STAGE}/"
          # Optional artifacts — included when present.
          [ -f CHANGELOG.md ] && cp CHANGELOG.md "${STAGE}/" || true
          mkdir -p "${STAGE}/share/man/man1" "${STAGE}/share/completions"
          cp man/mkit.1 "${STAGE}/share/man/man1/mkit.1"
          cp completions/mkit.bash "${STAGE}/share/completions/mkit.bash"
          cp completions/mkit.zsh "${STAGE}/share/completions/_mkit"
          cp completions/mkit.fish "${STAGE}/share/completions/mkit.fish"

      - name: Compute per-archive SHA256SUMS
        shell: bash
        run: |
          for STAGE in "${{ steps.meta.outputs.stage }}"; do
            (
              cd "${STAGE}"
              if command -v sha256sum >/dev/null 2>&1; then
                find . -type f ! -name SHA256SUMS -exec sha256sum {} \; | sort -k 2 > SHA256SUMS
              else
                find . -type f ! -name SHA256SUMS -exec shasum -a 256 {} \; | sort -k 2 > SHA256SUMS
              fi
              cat SHA256SUMS
            )
          done

      - name: Create archives (tar.gz)
        shell: bash
        run: |
          tar -czf "${{ steps.meta.outputs.archive }}" "${{ steps.meta.outputs.stage }}"
          ls -la "${{ steps.meta.outputs.archive }}"

      - name: Compute archive-level SHA256
        shell: bash
        run: |
          for ARCHIVE in "${{ steps.meta.outputs.archive }}"; do
            if command -v sha256sum >/dev/null 2>&1; then
              sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256"
            else
              shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256"
            fi
            cat "${ARCHIVE}.sha256"
          done

      - name: Install cosign
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6  # v4.1.2
        with:
          cosign-release: v2.4.1

      - name: Sign archives with cosign (keyless OIDC)
        shell: bash
        env:
          COSIGN_EXPERIMENTAL: "1"
          COSIGN_YES: "true"
        run: |
          for ARCHIVE in "${{ steps.meta.outputs.archive }}"; do
            cosign sign-blob --yes \
              --bundle "${ARCHIVE}.cosign.bundle" \
              --output-signature "${ARCHIVE}.sig" \
              --output-certificate "${ARCHIVE}.crt" \
              "${ARCHIVE}"
            echo "Signed ${ARCHIVE}; transparency log entries visible at https://search.sigstore.dev/"
          done

      # NOTE: macOS binaries are deliberately not codesigned or notarized.
      # We rely on cosign keyless signatures (signed above) for trust.
      # If/when an Apple Developer ID is provisioned, add codesign +
      # `xcrun notarytool submit … --wait` + `xcrun stapler staple` here
      # and re-tar the archive AFTER stapling so the ticket is embedded.

      - name: Upload build artifacts
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9  # v7
        with:
          name: build-${{ matrix.target }}
          path: |
            ${{ steps.meta.outputs.archive }}
            ${{ steps.meta.outputs.archive }}.sha256
            ${{ steps.meta.outputs.archive }}.sig
            ${{ steps.meta.outputs.archive }}.crt
            ${{ steps.meta.outputs.archive }}.cosign.bundle
          if-no-files-found: error
          retention-days: 7

  sbom:
    name: Generate SBOM
    runs-on: ubuntu-latest
    needs: [validate-release-tag, build]
    permissions:
      contents: read
    steps:
      - name: Resolve release metadata
        id: meta
        shell: bash
        run: |
          TAG="${{ needs.validate-release-tag.outputs.tag }}"
          REF="${{ needs.validate-release-tag.outputs.ref }}"
          COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
          VERSION="${{ needs.validate-release-tag.outputs.version }}"
          echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
          echo "ref=${REF}" >> "$GITHUB_OUTPUT"
          echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"

      - name: Checkout (at resolved tag)
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          ref: ${{ steps.meta.outputs.commit }}
          fetch-depth: 0

      # Scans the checked-out tree, so it reads rust/Cargo.lock: one graph
      # for the whole workspace, including library and test dependencies.
      - name: Generate CycloneDX SBOM
        uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c  # v0.24.3
        with:
          path: .
          format: cyclonedx-json
          output-file: sbom.cdx.json
          artifact-name: sbom.cdx.json
          upload-artifact: false

      # Schema-validates the generated SBOM (plus a non-empty-components
      # sanity check) before it ships as a real release artifact — see
      # scripts/validate-sbom.sh for why. cyclonedx-cli ships as a single
      # self-contained binary per release (no package-manager action for
      # it in common use), so fetch it directly rather than adding a new
      # third-party GitHub Action to this trust boundary.
      - name: Install cyclonedx-cli
        run: |
          set -euo pipefail
          curl -sSfL -o /usr/local/bin/cyclonedx \
            https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.32.0/cyclonedx-linux-x64
          chmod +x /usr/local/bin/cyclonedx
          cyclonedx --version

      - name: Validate SBOM
        run: bash scripts/validate-sbom.sh sbom.cdx.json

      - name: Upload SBOM artifact
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9  # v7
        with:
          name: sbom
          path: sbom.cdx.json
          if-no-files-found: error
          retention-days: 7

  third-party-notices:
    name: Generate THIRD-PARTY-NOTICES
    runs-on: ubuntu-latest
    needs: [validate-release-tag, build]
    permissions:
      contents: read
    steps:
      - name: Resolve release metadata
        id: meta
        shell: bash
        run: |
          COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
          echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"

      - name: Checkout (at resolved tag)
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          ref: ${{ steps.meta.outputs.commit }}
          fetch-depth: 0

      # cargo-about has no cargo-nextest-style default binary — the crates.io
      # crate gates its CLI behind the `cli` feature (see rust/about.toml's
      # header comment). taiki-e/install-action fetches a prebuilt binary the
      # same way the cargo-nextest/cargo-semver-checks installs above do, so
      # this job never compiles cargo-about from source.
      - name: Install cargo-about
        uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe  # v2
        with:
          tool: cargo-about@0.9.1

      # Scoped to the whole `rust` workspace's resolved dependency graph
      # (--all-features, so no optional/feature-gated dependency's license is
      # silently dropped from attribution just because this release's default
      # build didn't enable it) — matches the SBOM job's whole-workspace scope
      # above. rust/about.toml carries the accepted-license policy in lockstep
      # with rust/deny.toml's [licenses] allow list; cargo-about fails this
      # step outright on any dependency whose license isn't in that list, and
      # that failure is a real release blocker (see rust/about.toml).
      - name: Generate THIRD-PARTY-NOTICES
        working-directory: rust
        run: |
          cargo about generate --all-features about.hbs -o THIRD-PARTY-NOTICES

      - name: Upload THIRD-PARTY-NOTICES artifact
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9  # v7
        with:
          name: third-party-notices
          path: rust/THIRD-PARTY-NOTICES
          if-no-files-found: error
          retention-days: 7

  release:
    name: Publish GitHub Release
    runs-on: ubuntu-latest
    needs: [validate-release-tag, build, sbom, third-party-notices]
    if: >-
      ${{ !cancelled()
      && needs.validate-release-tag.result == 'success'
      && needs.build.result == 'success'
      && needs.sbom.result == 'success'
      && needs.third-party-notices.result == 'success' }}
    permissions:
      contents: write       # create the GitHub Release + upload assets
      id-token: write       # cosign keyless OIDC + SLSA provenance OIDC
      attestations: write   # actions/attest-build-provenance
    steps:
      - name: Resolve version
        id: meta
        shell: bash
        run: |
          TAG="${{ needs.validate-release-tag.outputs.tag }}"
          REF="${{ needs.validate-release-tag.outputs.ref }}"
          TAG_OBJECT="${{ needs.validate-release-tag.outputs.tag_object }}"
          COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
          VERSION="${{ needs.validate-release-tag.outputs.version }}"
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
          echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
          echo "ref=${REF}" >> "$GITHUB_OUTPUT"
          echo "tag_object=${TAG_OBJECT}" >> "$GITHUB_OUTPUT"
          echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"

      - name: Checkout (at resolved tag)
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          ref: ${{ steps.meta.outputs.commit }}
          fetch-depth: 0

      - name: Verify tag still points at validated object
        shell: bash
        run: |
          set -euo pipefail
          TAG="${{ steps.meta.outputs.tag }}"
          REF="${{ steps.meta.outputs.ref }}"
          EXPECTED_TAG_OBJECT="${{ steps.meta.outputs.tag_object }}"
          EXPECTED_COMMIT="${{ steps.meta.outputs.commit }}"
          git fetch --force origin "${REF}:${REF}"
          ACTUAL_TAG_OBJECT="$(git rev-parse "${REF}^{tag}")"
          ACTUAL_COMMIT="$(git rev-parse "${REF}^{commit}")"
          if [ "${ACTUAL_TAG_OBJECT}" != "${EXPECTED_TAG_OBJECT}" ] || [ "${ACTUAL_COMMIT}" != "${EXPECTED_COMMIT}" ]; then
            echo "ERROR: tag ${TAG} moved after validation" >&2
            exit 1
          fi

      - name: Download all artifacts
        uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333  # v8
        with:
          path: artifacts

      - name: Flatten artifacts into dist/
        shell: bash
        run: |
          mkdir -p dist
          find artifacts -type f -exec cp {} dist/ \;
          ls -la dist/

      # ---- SLSA build provenance (GitHub/Sigstore-native) -----------------
      # A standard SLSA provenance attestation over the release archives,
      # generated by GitHub's own actions/attest-build-provenance action and
      # recorded in the Sigstore public-good transparency log. This is
      # additive to the cosign signatures: it gives downstream tooling
      # (`gh attestation verify`, `slsa-verifier`) a recognized, off-the-shelf
      # provenance format to check against.
      # Requires `attestations: write` (granted above) and `id-token: write`.
      - name: Generate SLSA build provenance
        id: attest-provenance
        uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8  # v4.2.2
        with:
          subject-path: |
            dist/*.tar.gz

      - name: Stage SLSA provenance attestation for release upload
        shell: bash
        run: |
          cp "${{ steps.attest-provenance.outputs.bundle-path }}" \
            "dist/mkit-${{ steps.meta.outputs.version }}.provenance.jsonl"

      - name: Compute top-level SHA256SUMS
        shell: bash
        working-directory: dist
        run: |
          FILES=(*.tar.gz *.provenance.jsonl sbom.cdx.json THIRD-PARTY-NOTICES)
          sha256sum "${FILES[@]}" > SHA256SUMS || \
            shasum -a 256 "${FILES[@]}" > SHA256SUMS
          cat SHA256SUMS

      - name: Install cosign
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6  # v4.1.2
        with:
          cosign-release: v2.4.1

      - name: Sign aggregated SHA256SUMS
        shell: bash
        working-directory: dist
        env:
          COSIGN_EXPERIMENTAL: "1"
          COSIGN_YES: "true"
        run: |
          cosign sign-blob --yes \
            --bundle SHA256SUMS.cosign.bundle \
            --output-signature SHA256SUMS.sig \
            --output-certificate SHA256SUMS.crt \
            SHA256SUMS

      - name: Create GitHub Release
        uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64  # v3
        with:
          tag_name: ${{ steps.meta.outputs.tag }}
          name: mkit ${{ steps.meta.outputs.version }}
          generate_release_notes: true
          draft: false
          prerelease: false
          files: |
            dist/*.tar.gz
            dist/*.tar.gz.sha256
            dist/*.tar.gz.sig
            dist/*.tar.gz.crt
            dist/*.tar.gz.cosign.bundle
            dist/*.provenance.jsonl
            dist/sbom.cdx.json
            dist/THIRD-PARTY-NOTICES
            dist/SHA256SUMS
            dist/SHA256SUMS.sig
            dist/SHA256SUMS.crt
            dist/SHA256SUMS.cosign.bundle
          body: |
            ## mkit ${{ steps.meta.outputs.version }}

            Content-addressed VCS for creative work.

            ### Verifying downloads

            Every archive is signed with **cosign keyless** via GitHub OIDC; the
            signing certificate embeds the workflow identity and a Rekor
            transparency log entry is public. See `docs/RELEASE.md`
            in the repo for full verification steps.

            The verifier regex below is anchored: it pins the exact workflow
            path and accepts only strict semver tags (with optional
            pre-release suffix like `-rc.1`).

            ```sh
            cosign verify-blob \
              --certificate-identity-regexp '^https://github\.com/officialunofficial/mkit/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
              --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
              --bundle <archive>.cosign.bundle \
              <archive>
            ```

            ### SLSA build provenance

            Every archive also carries a standard SLSA build provenance
            attestation, generated by GitHub's
            [`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance)
            and recorded in the Sigstore public-good transparency log. Verify
            it with the GitHub CLI (no extra tooling required):

            ```sh
            gh attestation verify <archive> --repo officialunofficial/mkit
            ```

            or with [`slsa-verifier`](https://github.com/slsa-framework/slsa-verifier):

            ```sh
            slsa-verifier verify-artifact <archive> \
              --provenance-path mkit-${{ steps.meta.outputs.version }}.provenance.jsonl \
              --source-uri github.com/officialunofficial/mkit \
              --source-tag ${{ steps.meta.outputs.tag }}
            ```

            This is additive to the cosign signature above, not a
            replacement for it.

            ### Platform notes

            macOS binaries are **not codesigned or notarized**. Trust is
            established via the cosign signature above. Running from a
            terminal works; double-clicking in Finder hits Gatekeeper.

            Windows is not a supported target. Windows users should run
            mkit under WSL, which uses the Linux binary.

            ### SBOM

            A CycloneDX SBOM (`sbom.cdx.json`) is attached. Reflects the
            Rust crate graph of the whole workspace, including library and
            test dependencies, plus toolchain and OS libraries.

            ### npm package

            The WASM bindings are also published to npm as
            [`@officialunofficial/mkit-wasm`](https://www.npmjs.com/package/@officialunofficial/mkit-wasm) at the
            same version, with Sigstore-backed provenance attached to this
            workflow run.

  publish-wasm:
    name: Publish mkit-wasm to npm
    runs-on: ubuntu-latest
    # npm provenance (`npm publish --provenance`, below) requires a PUBLIC
    # source repository. While the repo is private/internal this job is
    # SKIPPED (not failed) so the CLI binary release still goes green; it
    # self-activates the next time a tag is pushed after the repo is made
    # public. The job body keeps its own explicit public-repo guard as a
    # defense-in-depth check.
    #
    if: ${{ !cancelled() && needs.release.result == 'success' && github.event.repository.visibility == 'public' }}
    needs: [validate-release-tag, build, release]
    permissions:
      contents: write       # attach assets to the GitHub Release
      id-token: write       # npm provenance via OIDC
    steps:
      - name: Resolve tag
        id: meta
        shell: bash
        run: |
          TAG="${{ needs.validate-release-tag.outputs.tag }}"
          REF="${{ needs.validate-release-tag.outputs.ref }}"
          COMMIT="${{ needs.validate-release-tag.outputs.commit }}"
          VERSION="${{ needs.validate-release-tag.outputs.version }}"
          echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
          echo "ref=${REF}" >> "$GITHUB_OUTPUT"
          echo "commit=${COMMIT}" >> "$GITHUB_OUTPUT"
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"

      - name: Checkout (at resolved tag)
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          ref: ${{ steps.meta.outputs.commit }}
          fetch-depth: 0

      - name: Install Rust ${{ env.RUST_VERSION }} + wasm32 target
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8  # stable
        with:
          toolchain: ${{ env.RUST_VERSION }}
          targets: wasm32-unknown-unknown

      - name: Cache cargo artifacts
        uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16  # v2
        with:
          workspaces: rust -> rust/target
          prefix-key: "rust-release-wasm32"
          key: release-wasm32
          cache-on-failure: false

      - name: Install protoc
        uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b  # v3.0.0
        with:
          version: '31.0'
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Setup Node.js (npm registry)
        uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1  # v7.1.0
        with:
          node-version: '22'
          registry-url: 'https://registry.npmjs.org'

      # Trusted Publishing (OIDC) needs npm >= 11.5.1; Node 22's bundled npm
      # on GitHub-hosted runners drifts with each LTS patch, so pin
      # explicitly rather than trust whatever ships that day.
      - name: Ensure npm >= 11.5.1 (Trusted Publishing requirement)
        run: npm install -g npm@^11.5.1

      - name: Install wasm-pack
        run: cargo install wasm-pack --locked --version 0.13.1

      - name: Build mkit-wasm (target=bundler)
        working-directory: rust
        run: |
          wasm-pack build crates/mkit-wasm \
            --release \
            --target bundler \
            --out-dir pkg

      - name: Patch bundler glue for Bun / Workers compat (issue #90)
        # wasm-pack 0.13's auto-init `__wbg_set_wasm(wasm)` call breaks
        # under Bun + Cloudflare Workers (their bundler resolution stores
        # a WebAssembly.Module / path-shim, not the wasm-bindgen exports
        # namespace). The patcher additively exports `mkit_wasm_init(module)`
        # so consumers on broken bundlers can recover with a one-liner.
        # Strictly additive — esbuild/webpack/vite consumers are untouched.
        run: node scripts/patch-wasm-bundler-glue.mjs rust/crates/mkit-wasm/pkg

      - name: Set npm package name + version
        working-directory: rust/crates/mkit-wasm/pkg
        env:
          VERSION: ${{ steps.meta.outputs.version }}
        run: |
          # Override the npm package name to the scoped form. The Rust
          # crate stays unscoped (`mkit-wasm`); the npm distribution name
          # is `@officialunofficial/mkit-wasm`, matching the GitHub org
          # this repo lives under. See docs/RELEASE.md for rationale.
          npm pkg set name=@officialunofficial/mkit-wasm
          # Force-align the package.json version to the git tag.
          npm version --no-git-tag-version --allow-same-version "$VERSION"
          jq '{name, version, license, repository}' package.json

      - name: Require public repository for npm provenance
        env:
          REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}
        run: |
          if [ "${REPOSITORY_VISIBILITY}" != "public" ]; then
            echo "::error::npm provenance requires a public GitHub repository; refusing to publish without provenance." >&2
            exit 1
          fi

      # Trusted Publishing (OIDC): npm CLI auto-detects this job's GitHub
      # Actions OIDC token (id-token: write, above) and authenticates
      # without any stored npm token, PROVIDED the @officialunofficial/
      # mkit-wasm package's Trusted Publisher is configured on npmjs.com to
      # trust org=officialunofficial, repo=mkit, workflow=release.yml — see
      # docs/RELEASE.md. No NODE_AUTH_TOKEN: if OIDC isn't wired up yet on
      # the npm side, this fails closed instead of silently falling back to
      # a stale/absent token. Provenance is automatic under Trusted
      # Publishing — the explicit --provenance flag is kept for older-CLI
      # defense in depth.
      - name: Publish to npm
        working-directory: rust/crates/mkit-wasm/pkg
        run: npm publish --access public --provenance

      - name: Stage WASM bundle for GitHub Release
        shell: bash
        run: |
          STAGE="mkit-wasm-${{ steps.meta.outputs.version }}-npm"
          mkdir -p "${STAGE}"
          cp -R rust/crates/mkit-wasm/pkg/. "${STAGE}/"
          tar -czf "${STAGE}.tar.gz" "${STAGE}"
          if command -v sha256sum >/dev/null 2>&1; then
            sha256sum "${STAGE}.tar.gz" > "${STAGE}.tar.gz.sha256"
          else
            shasum -a 256 "${STAGE}.tar.gz" > "${STAGE}.tar.gz.sha256"
          fi
          ls -la "${STAGE}.tar.gz" "${STAGE}.tar.gz.sha256"

      - name: Attach WASM bundle to GitHub Release
        uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64  # v3
        with:
          tag_name: ${{ steps.meta.outputs.tag }}
          files: |
            mkit-wasm-${{ steps.meta.outputs.version }}-npm.tar.gz
            mkit-wasm-${{ steps.meta.outputs.version }}-npm.tar.gz.sha256

Files