metamorphic-crypto 0.10.5

Zero-knowledge end-to-end encryption with post-quantum hybrid KEM (ML-KEM + X25519) and an opt-in CNSA 2.0 suite axis (matched-strength hybrid + pure ML-KEM-1024 / ML-DSA-87 / AES-256-GCM)
Documentation
name: Release WASM

on:
  push:
    tags: ["v*"]

permissions:
  contents: write
  attestations: write
  id-token: write

# Prevent two simultaneous tag pushes from racing the publish steps.
concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

env:
  CARGO_TERM_COLOR: always
  # Toolchain used to BUILD and PUBLISH the release. This is intentionally newer
  # than the crate's declared MSRV (`rust-version = "1.85"` in Cargo.toml) so the
  # release tooling (cargo-audit, cargo-cyclonedx) installs cleanly — those tools
  # raise their own MSRV over time. MSRV compatibility is still verified in CI.
  RUST_TOOLCHAIN: "1.90"
  WASM_PACK_VERSION: "0.14.0"

jobs:
  release:
    runs-on: ubuntu-latest
    # Scopes the crates.io and npm OIDC trusted-publishing credentials to a
    # dedicated, protectable environment (configure under repo Settings →
    # Environments). The trusted-publisher config on crates.io/npmjs must use
    # this same environment name.
    environment: release
    steps:
      # Third-party actions are pinned to a full commit SHA (SLSA L3 / federal
      # supply-chain requirement). Version comments are kept for Dependabot,
      # which can bump both the SHA and the comment.
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
        with:
          toolchain: ${{ env.RUST_TOOLCHAIN }}
          targets: wasm32-unknown-unknown
          components: clippy, rustfmt

      - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2

      # --- Tag/version guard ---
      # The release is triggered by a `v*` tag, but `cargo publish` reads the
      # version from Cargo.toml. If they disagree (e.g. the tag was pushed
      # before bumping Cargo.toml), `cargo publish` fails deep into the job with
      # "crate already exists", AFTER the crates.io step but BEFORE npm — leaving
      # a half-published, inconsistent release. Fail fast here instead.
      - name: Verify Cargo.toml version matches tag
        run: |
          tag="${GITHUB_REF_NAME#v}"
          crate="$(cargo metadata --no-deps --format-version 1 \
            | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])')"
          echo "tag=${tag} Cargo.toml=${crate}"
          if [ "${tag}" != "${crate}" ]; then
            echo "::error::Tag v${tag} does not match Cargo.toml version ${crate}. Bump Cargo.toml (and Cargo.lock) before tagging."
            exit 1
          fi

      # --- Quality gates ---
      - name: Format check
        run: cargo fmt --check

      - name: Clippy
        run: cargo clippy --all-targets -- -D warnings

      - name: Tests
        run: cargo test

      # Re-check RustSec advisories at release time (CI already runs this on
      # every push/PR; gating the release too prevents shipping a tag built
      # from a commit that predates a newly disclosed advisory).
      - name: Install cargo-audit
        run: cargo install cargo-audit --locked

      - name: Audit dependencies
        run: cargo audit

      # --- Publish to crates.io ---
      # Trusted publishing (OIDC): exchanges the workflow's short-lived GitHub
      # OIDC token for a temporary crates.io token. No long-lived
      # CARGO_REGISTRY_TOKEN secret is stored anywhere.
      # One-time setup: crates.io → crate Settings → Trusted Publishing.
      - name: Authenticate to crates.io (OIDC)
        uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
        id: crates-auth

      - name: Publish to crates.io
        # No --no-verify: re-build inside the publish step so crates.io ships
        # exactly what the tests above ran against.
        #
        # Idempotent by design: crates.io publishes are immutable and permanent,
        # so a resumed/re-run release (e.g. a transient npm or GitHub Release
        # failure downstream) must not hard-fail here just because the version is
        # already on the index. We detect that one specific, safe condition and
        # continue so the remaining steps (npm publish, GitHub Release) can finish
        # a partially-completed release. Any other publish error still fails fast.
        run: |
          set +e
          out="$(cargo publish 2>&1)"; code=$?
          echo "${out}"
          if [ "${code}" -ne 0 ]; then
            if echo "${out}" | grep -q "already exists on crates.io"; then
              echo "::warning::metamorphic-crypto ${GITHUB_REF_NAME#v} is already on crates.io; skipping publish and resuming the release (npm + GitHub Release)."
            else
              exit "${code}"
            fi
          fi
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }}

      # --- WASM build ---
      - name: Install wasm-pack
        # Build from the locked crates.io source (Cargo.lock verifies each
        # dependency's checksum) instead of curl|tar of an unverified release
        # tarball. Slower, but the supply chain is verifiable end to end.
        run: |
          cargo install wasm-pack --version "${WASM_PACK_VERSION}" --locked
          wasm-pack --version

      - name: Build WASM (--target web)
        run: wasm-pack build --target web --release

      # --- SBOM (CycloneDX) ---
      # CISA/NIST SSDF/EO 14028 reference CycloneDX. Generated from Cargo.lock
      # so it reflects the exact dependency tree the artifacts were built from.
      - name: Install cargo-cyclonedx
        run: cargo install cargo-cyclonedx --locked

      - name: Generate SBOM
        run: cargo cyclonedx --format json --override-filename sbom

      - name: Stage SBOM alongside artifacts
        run: cp sbom.json pkg/sbom.json

      # --- Checksums ---
      - name: Compute SHA-512 checksums
        working-directory: pkg
        run: sha512sum metamorphic_crypto.js metamorphic_crypto_bg.wasm sbom.json > SHA512SUMS

      - name: Display checksums
        run: cat pkg/SHA512SUMS

      # --- Cosign (keyless signing via GitHub OIDC) ---
      - name: Install cosign
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

      - name: Sign artifacts with cosign
        run: |
          cosign sign-blob --yes --bundle pkg/metamorphic_crypto.js.cosign.bundle pkg/metamorphic_crypto.js
          cosign sign-blob --yes --bundle pkg/metamorphic_crypto_bg.wasm.cosign.bundle pkg/metamorphic_crypto_bg.wasm

      # --- Attestation ---
      - name: Attest WASM artifacts
        uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
        with:
          subject-path: |
            pkg/metamorphic_crypto.js
            pkg/metamorphic_crypto_bg.wasm
            pkg/sbom.json

      # --- npm publish ---
      # npm trusted publishing (OIDC) requires npm >= 11.5.1. Node 24 already
      # bundles a complete npm >= 11.5.1, so we use it directly instead of the
      # in-place `npm install -g npm@latest` self-upgrade: that upgrade path is
      # currently broken (it leaves npm unable to resolve its bundled `sigstore`
      # module — "Cannot find module 'sigstore'" — during provenance
      # generation). setup-node's bundled npm is a clean, complete install.
      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "24"
          registry-url: "https://registry.npmjs.org"

      - name: Verify npm is new enough for trusted publishing
        run: |
          npm --version
          node -e 'const [a,b,c]=process.argv[1].split(".").map(Number); const ok=a>11||(a===11&&(b>5||(b===5&&c>=1))); if(!ok){console.error("::error::npm "+process.argv[1]+" < 11.5.1; trusted publishing (OIDC) requires >= 11.5.1");process.exit(1)}' "$(npm --version)"

      - name: Scope package for npm
        working-directory: pkg
        run: |
          npm pkg set name="@f0rest8/metamorphic-crypto"
          cp ../npm-README.md README.md

      - name: Publish to npm
        working-directory: pkg
        # Trusted publishing (OIDC): no NODE_AUTH_TOKEN. npm authenticates via
        # the workflow's OIDC identity and automatically attaches provenance
        # (the verified badge on npmjs.com). id-token: write is already granted.
        # One-time setup: npmjs.com → package Settings → Trusted Publisher.
        #
        # Idempotent for the same reason as the crates.io step above: a resumed
        # release must not hard-fail if this version is already on the registry.
        run: |
          set +e
          out="$(npm publish --access public 2>&1)"; code=$?
          echo "${out}"
          if [ "${code}" -ne 0 ]; then
            if echo "${out}" | grep -qiE "cannot publish over|previously published|403"; then
              echo "::warning::@f0rest8/metamorphic-crypto ${GITHUB_REF_NAME#v} is already on npm; skipping publish and resuming the release."
            else
              exit "${code}"
            fi
          fi

      # --- GitHub Release ---
      - name: Attach artifacts to GitHub Release
        uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
        with:
          files: |
            pkg/metamorphic_crypto.js
            pkg/metamorphic_crypto_bg.wasm
            pkg/sbom.json
            pkg/SHA512SUMS
            pkg/metamorphic_crypto.js.cosign.bundle
            pkg/metamorphic_crypto_bg.wasm.cosign.bundle
          fail_on_unmatched_files: true