name: Release

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

# Least-privilege default for the auto-injected GITHUB_TOKEN. The publish jobs
# (cargo/python/node) push to external registries via their own secrets
# (CARGO_REGISTRY_TOKEN / PYPI_API_TOKEN / NPM_TOKEN), not the GITHUB_TOKEN, so
# they need no repo write. The jobs that genuinely write through the
# GITHUB_TOKEN — github-release (contents: write), node-/wasm-publish and
# attestations (id-token / attestations: write) — declare those rights in their
# own job-level permissions blocks, which override this default (OpenSSF
# Scorecard: Token-Permissions).
permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  # Network-flake resilience: retry transient registry/DNS failures at the tool
  # level so a blip fetching crates.io / npm inside any build or publish step
  # (cargo, napi, maturin, wasm-pack, npm) retries automatically instead of
  # failing the job. Cargo treats "couldn't resolve host" / connect / timeout as
  # spurious and retries with backoff; 10 attempts ride out a transient DNS blip.
  CARGO_NET_RETRY: "10"
  CARGO_NET_GIT_FETCH_WITH_CLI: "true"
  npm_config_fetch_retries: "5"
  npm_config_fetch_retry_maxtimeout: "120000"
  PIP_RETRIES: "5"
  PIP_DEFAULT_TIMEOUT: "120"

jobs:
  # --------------------------------------------------------------------------
  # crates.io: three crates in dependency order
  # --------------------------------------------------------------------------
  cargo-publish:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    # The publish jobs run with long-lived registry tokens. Binding them to a
    # protected GitHub environment lets the org require a reviewer to approve
    # each release and restrict which tags/branches may deploy, so the secrets
    # are not reachable from an arbitrary workflow run. The `release`
    # environment and its protection rules are configured under repo
    # Settings -> Environments.
    environment: release
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
      - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      # Idempotent publishing: if the version is already on crates.io we
      # treat that as success so re-runs of the workflow don't fail.
      - name: Publish wickra-core (idempotent)
        run: |
          out=$(cargo publish -p wickra-core --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
            || (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
                || (echo "$out"; exit 1))
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

      - name: Wait for crates.io index to refresh
        run: sleep 45

      - name: Publish wickra-data (idempotent)
        run: |
          out=$(cargo publish -p wickra-data --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
            || (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
                || (echo "$out"; exit 1))
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

      - name: Wait for crates.io index to refresh
        run: sleep 45

      - name: Publish wickra (idempotent)
        run: |
          out=$(cargo publish -p wickra --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
            || (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
                || (echo "$out"; exit 1))
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

      # Produce .crate files for the GitHub Release attachments. `cargo package`
      # writes them to target/package/<name>-<version>.crate.
      #
      # No --allow-dirty: `actions/checkout` gives a clean tree and nothing
      # above mutates it. No --no-verify: every crate was just published to
      # crates.io in the steps above, so the verification build resolves its
      # workspace dependencies from the registry and confirms each .crate
      # actually builds before it is attached to the release.
      - name: Build .crate files for release attachment
        run: |
          cargo package -p wickra-core
          cargo package -p wickra-data
          cargo package -p wickra

      - name: Upload .crate files
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: crate-files
          path: target/package/*.crate

      # CycloneDX SBOM per published crate. Attached to the GitHub Release
      # alongside the .crate / .whl / .tgz artefacts so downstream
      # consumers can audit the published dependency tree without
      # re-resolving Cargo.lock.
      - name: Install cargo-cyclonedx
        uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: cargo-cyclonedx

      - name: Generate CycloneDX SBOMs
        run: |
          # cargo-cyclonedx walks the whole workspace in a single pass and
          # writes a <package>.cdx.json next to each member's Cargo.toml; it
          # has no -p/--package selector. Collect the three crates.io crates
          # (the .crate files published by this job) into the upload dir.
          cargo cyclonedx --format json --top-level
          mkdir -p sboms
          cp crates/wickra-core/wickra-core.cdx.json sboms/
          cp crates/wickra-data/wickra-data.cdx.json sboms/
          cp crates/wickra/wickra.cdx.json sboms/
          ls -lh sboms/

      - name: Upload SBOMs
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: sboms
          path: sboms/*.cdx.json

  # --------------------------------------------------------------------------
  # PyPI: cross-platform wheels + sdist
  # --------------------------------------------------------------------------
  python-wheels:
    name: Build wheels (${{ matrix.target }}/${{ matrix.manylinux }} on ${{ matrix.os }})
    strategy:
      fail-fast: false
      matrix:
        include:
          # glibc Linux (manylinux)
          - { os: ubuntu-latest, target: x86_64, manylinux: auto }
          - { os: ubuntu-latest, target: aarch64, manylinux: auto }
          # musl Linux (Alpine and other musl distros)
          - { os: ubuntu-latest, target: x86_64, manylinux: musllinux_1_2 }
          - { os: ubuntu-latest, target: aarch64, manylinux: musllinux_1_2 }
          # macOS
          - { os: macos-latest, target: x86_64, manylinux: auto }
          - { os: macos-latest, target: aarch64, manylinux: auto }
          # Windows
          - { os: windows-latest, target: x64, manylinux: auto }
          - { os: windows-11-arm, target: aarch64, manylinux: auto }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: setup_python
        continue-on-error: true
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.11"
      - name: Wait before Python retry
        if: steps.setup_python.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
          sleep 30
      - name: Set up Python (retry)
        if: steps.setup_python.outcome == 'failure'
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.11"
      - name: Sync root README into bindings/python so it ships with the wheel
        shell: bash
        run: cp README.md bindings/python/README.md
      - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          working-directory: bindings/python
          target: ${{ matrix.target }}
          args: --release --strip --out dist
          manylinux: ${{ matrix.manylinux }}
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          # Include manylinux in the name so the glibc and musl x86_64/aarch64
          # builds do not collide on the same artifact name.
          name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux }}
          path: bindings/python/dist/*

  python-sdist:
    name: Build Python sdist
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Sync root README into bindings/python so it ships in the sdist
        run: cp README.md bindings/python/README.md
      - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          working-directory: bindings/python
          command: sdist
          args: --out dist
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: wheels-sdist
          path: bindings/python/dist/*

  python-publish:
    name: Publish to PyPI
    needs: [python-wheels, python-sdist]
    runs-on: ubuntu-latest
    environment: release
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: dist
          pattern: wheels-*
          merge-multiple: true

      - name: Upload to PyPI
        uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          command: upload
          args: --skip-existing dist/*
        env:
          MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}

  # --------------------------------------------------------------------------
  # npm: per-platform native binaries + main meta-package
  # --------------------------------------------------------------------------
  node-build:
    name: Node build (${{ matrix.target }})
    strategy:
      fail-fast: false
      matrix:
        include:
          - { host: ubuntu-latest, target: x86_64-unknown-linux-gnu }
          - { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
          - { host: macos-latest, target: x86_64-apple-darwin }
          - { host: macos-latest, target: aarch64-apple-darwin }
          - { host: windows-latest, target: x86_64-pc-windows-msvc }
          - { host: windows-11-arm, target: aarch64-pc-windows-msvc }
    runs-on: ${{ matrix.host }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
        with:
          targets: ${{ matrix.target }}

      - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Install Node deps
        working-directory: bindings/node
        run: npm ci

      - name: Build native module
        working-directory: bindings/node
        run: npx napi build --platform --release --target ${{ matrix.target }}

      - name: Upload artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: bindings-${{ matrix.target }}
          path: bindings/node/wickra.*.node
          if-no-files-found: error

  node-publish:
    name: Publish to npm
    needs: node-build
    runs-on: ubuntu-latest
    environment: release
    # `id-token: write` lets npm publish embed a Sigstore provenance
    # attestation generated from the GitHub Actions OIDC token. The npm
    # registry then shows a "Verified provenance" badge and lets
    # consumers verify the package was built from this exact workflow
    # run.
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"

      - name: Install Node deps
        working-directory: bindings/node
        run: npm ci

      - name: Download all platform binaries
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: bindings/node/artifacts
          pattern: bindings-*

      # The npm/<platform>/package.json templates are committed to the
      # repo (see bindings/node/npm/), so `napi artifacts` only needs to
      # drop the freshly built .node binary into the matching directory.
      - name: Move binaries into platform package layout
        working-directory: bindings/node
        run: npx napi artifacts --dir artifacts

      # Publish each platform package individually. Skip versions that are
      # already on npm. A first-attempt 403 from npm's spam filter is
      # tolerated for a single 30-second retry — that historically clears
      # rate-limit-driven false positives. Anything that still fails after
      # the retry is a *real* failure (the platform binary will be missing
      # from `optionalDependencies` and Windows-style installs will break,
      # exactly the regression that produced audit finding R20) — fail the
      # job loudly so the release does not silently land in a half-published
      # state. Previously this loop swallowed the second-attempt failure with
      # a `::warning::` and `return 0`; that mask is removed.
      - name: Publish platform packages (idempotent)
        working-directory: bindings/node
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          set +e
          version=$(node -p "require('./package.json').version")
          fail=0
          publish_dir() {
            local dir=$1
            local pkg=$(basename "$dir")
            local pkgname="wickra-$pkg"
            local existing
            existing=$(npm view "$pkgname@$version" version 2>/dev/null)
            if [ "$existing" = "$version" ]; then
              echo "::notice::skip $pkgname@$version (already on npm)"
              return 0
            fi
            echo "::group::publish $pkgname@$version"
            # --ignore-scripts: a per-platform package must never run lifecycle
            # scripts during publish (npm runs prepublishOnly/prepare/etc. from
            # the package being published — a malicious or stray script would
            # execute with the npm token in the environment).
            (cd "$dir" && npm publish --access public --ignore-scripts --provenance)
            local rc=$?
            echo "::endgroup::"
            if [ "$rc" -ne 0 ]; then
              echo "::warning::first attempt of $pkgname failed (rc=$rc); retrying after 30s"
              sleep 30
              (cd "$dir" && npm publish --access public --ignore-scripts --provenance)
              rc=$?
            fi
            if [ "$rc" -ne 0 ]; then
              echo "::error::$pkgname could not be published — the release would land with a missing platform binary; failing the job."
              return 1
            fi
            return 0
          }
          for dir in npm/*/; do
            publish_dir "$dir" || fail=1
          done
          exit $fail

      - name: Sync root README into bindings/node so it ships with the npm tarball
        # npm reads README.md from the package directory at publish time. Copy
        # the canonical root README in just before the publish so every
        # registry shows the same project page.
        shell: bash
        run: cp ../../README.md README.md
        working-directory: bindings/node

      - name: Publish main package to npm (idempotent)
        working-directory: bindings/node
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        shell: bash
        run: |
          # GitHub Actions defaults to `bash -e`. `npm view` on a not-yet-
          # published package returns 1, which would kill the step before
          # we got to publish. Force a graceful empty result instead.
          set +e
          version=$(node -p "require('./package.json').version")
          existing=$(npm view "wickra@$version" version 2>/dev/null)
          if [ "$existing" = "$version" ]; then
            echo "::notice::skip wickra@$version (already on npm)"
            exit 0
          fi
          # --ignore-scripts so any leftover prepublish hooks (which would
          # otherwise try to republish the already-published platform
          # subpackages) can't sabotage the main publish.
          npm publish --access public --ignore-scripts --provenance
          rc=$?
          if [ "$rc" -ne 0 ]; then
            echo "::warning::first attempt failed (rc=$rc); retrying after 30s"
            sleep 30
            npm publish --access public --ignore-scripts --provenance
            rc=$?
          fi
          exit $rc

      - name: Pack node tarballs for release attachment
        working-directory: bindings/node
        run: |
          # Main package
          npm pack --ignore-scripts
          # Each per-platform package (the binaries were already moved in by
          # napi artifacts). --ignore-scripts for the same reason as publish:
          # packing must not execute lifecycle scripts from the packed dir.
          for d in npm/*/; do
            (cd "$d" && npm pack --ignore-scripts)
          done

      - name: Upload Node tarballs
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: node-tarballs
          path: |
            bindings/node/*.tgz
            bindings/node/npm/*/*.tgz

  # --------------------------------------------------------------------------
  # WASM: wasm-pack build + npm publish (as `wickra-wasm`)
  # --------------------------------------------------------------------------
  # Note: this job's npm publish call uses `--provenance` (see below),
  # which requires the `id-token: write` permission set at the job level.
  wasm-publish:
    name: Publish wickra-wasm to npm
    runs-on: ubuntu-latest
    environment: release
    # `id-token: write` lets npm publish embed a Sigstore provenance
    # attestation generated from the GitHub Actions OIDC token (same
    # mechanism as the node-publish job above).
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
        with:
          targets: wasm32-unknown-unknown

      - name: Install wasm-pack (latest, via prebuilt binary)
        # See the matching note in ci.yml: jetli's default installs an old
        # 0.10.x wasm-pack whose build subcommand rejects --features.
        uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: wasm-pack

      - name: Sync root README into bindings/wasm so wasm-pack ships it in pkg/
        # wasm-pack copies the crate's README.md into the generated pkg/
        # directory it then publishes. Refresh it from the canonical root
        # README right before the build.
        run: cp README.md bindings/wasm/README.md

      - name: Build WASM package (bundler target)
        run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook

      - name: Enrich generated package.json with author + repo metadata
        working-directory: bindings/wasm/pkg
        run: |
          node -e "
            const fs = require('fs');
            const pkg = JSON.parse(fs.readFileSync('package.json'));
            pkg.author = 'kingchenc <support@wickra.org>';
            pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
            pkg.homepage = 'https://github.com/wickra-lib/wickra';
            pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
            pkg.license = 'MIT OR Apache-2.0';
            fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
          "

      - name: Pack wickra-wasm for release attachment
        working-directory: bindings/wasm/pkg
        run: npm pack

      - name: Upload WASM tarball
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: wasm-tarball
          path: bindings/wasm/pkg/wickra-wasm-*.tgz

      - name: Publish wickra-wasm to npm (idempotent)
        working-directory: bindings/wasm/pkg
        run: |
          out=$(npm publish --access public --provenance 2>&1) && echo "$out" \
            || (echo "$out" | grep -q "You cannot publish over" && echo "skip: version already on npm" \
                || (echo "$out"; exit 1))
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

  # --------------------------------------------------------------------------
  # GitHub Release: attach every built artefact to the tag's release page.
  #
  # The release is created as a DRAFT here and only flipped to published by the
  # downstream publish-release job, after the provenance bundle is attached. That
  # ordering (draft -> attach everything -> publish) makes the pipeline compatible
  # with GitHub release immutability, which locks assets at publish time (P24):
  # the old "publish, then upload provenance" order would have the provenance
  # upload rejected once immutability is enabled.
  # --------------------------------------------------------------------------
  github-release:
    name: Attach assets to the draft GitHub Release
    needs: [cargo-publish, python-publish, node-publish, wasm-publish]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    # Expose the resolved tag so the attestations job can attach the provenance
    # bundle to this same release without re-resolving it.
    outputs:
      tag: ${{ steps.tag.outputs.tag }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
          fetch-depth: 0

      - name: Resolve target tag
        id: tag
        # Pass the (potentially attacker-influenceable on a tag push) ref context
        # through the environment instead of interpolating it into the shell
        # script, so a crafted tag name cannot inject commands (zizmor:
        # template-injection).
        env:
          EVENT_NAME: ${{ github.event_name }}
          REF: ${{ github.ref }}
          REF_NAME: ${{ github.ref_name }}
        run: |
          if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/* ]]; then
            tag="$REF_NAME"
          else
            # workflow_dispatch / non-tag push: attach to the latest v* tag.
            tag=$(git tag --list 'v*' --sort=-v:refname | head -n1)
          fi
          if [ -z "$tag" ]; then
            echo "::error::no v* tag found to attach assets to"
            exit 1
          fi
          echo "tag=$tag" >> "$GITHUB_OUTPUT"
          echo "::notice::attaching assets to release $tag"

      - name: Download all build artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: artifacts

      - name: Stage release assets
        run: |
          set -e
          mkdir -p release-assets
          # Python wheels + sdist (5 wheel artifacts + 1 sdist artifact).
          find artifacts -type f -name "*.whl" -exec cp {} release-assets/ \;
          find artifacts -type f -name "*.tar.gz" -exec cp {} release-assets/ \;
          # Native Node binaries (one per platform).
          find artifacts -type f -name "*.node" -exec cp {} release-assets/ \;
          # Node npm-pack tarballs (main + per-platform).
          find artifacts -type f -name "wickra-*.tgz" -exec cp {} release-assets/ \;
          # Cargo .crate files (one per workspace member).
          find artifacts -type f -name "*.crate" -exec cp {} release-assets/ \;
          # CycloneDX SBOMs (one per published crate).
          find artifacts -type f -name "*.cdx.json" -exec cp {} release-assets/ \;
          ls -lh release-assets/
          echo "asset-count=$(ls release-assets/ | wc -l)"

      - name: Create / update the draft GitHub Release with assets
        uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
        with:
          tag_name: ${{ steps.tag.outputs.tag }}
          name: Wickra ${{ steps.tag.outputs.tag }}
          files: release-assets/*
          generate_release_notes: true
          fail_on_unmatched_files: false
          # Created as a draft; publish-release flips it to published + latest once
          # the provenance bundle is attached (P24, immutability-ready).
          draft: true
          body: |
            Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.

            ### Install

            ```bash
            cargo add wickra
            pip install wickra
            npm install wickra
            npm install wickra-wasm
            ```

            ### Attached assets

            Pre-built artefacts for every supported platform — the same files that
            were uploaded to crates.io, PyPI, and npm by this workflow run.

            - `*.whl` / `wickra-*.tar.gz` — Python wheels + sdist (5 platforms, ABI3 ≥ 3.9)
            - `wickra.*.node` — native Node bindings (linux-x64-gnu, darwin-x64,
              darwin-arm64, win32-x64-msvc)
            - `wickra-*.tgz` — npm-pack tarballs (main package + per-platform subpackages + WASM)
            - `*.crate` — cargo source crates (wickra-core, wickra-data, wickra)

            ### Auto-generated changelog

            See below; GitHub computes it from the commits since the previous tag.

  # --------------------------------------------------------------------------
  # Build provenance attestations (findings P13.2)
  # --------------------------------------------------------------------------
  attestations:
    name: Attest build provenance
    needs: [cargo-publish, python-wheels, python-sdist, github-release]
    runs-on: ubuntu-latest
    # Signed SLSA build-provenance attestations for the published crates and
    # Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
    # from `npm publish --provenance`, so they are covered there.
    #
    # The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
    # of github-release, so a Sigstore hiccup here can never block or corrupt a
    # publish (the isolation the SBOM step lacked before #79). It additionally
    # `needs: github-release` so the (still-draft) GitHub Release already exists
    # when it attaches the provenance bundle as a release asset (P21.1e) — OpenSSF
    # Scorecard's Signed-Releases check scans release *assets* (*.intoto.jsonl),
    # not GitHub's separate attestations store, so the bundle has to live on the
    # release. The release is published afterwards by the publish-release job
    # whether or not this attestation succeeds (P24), so a failure here still only
    # costs the provenance asset, never the release.
    permissions:
      id-token: write # OIDC for keyless Sigstore signing
      attestations: write # write the attestations to this repo
      contents: write # upload the provenance bundle as a release asset
    steps:
      - name: Download crate files
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: crate-files
          path: artifacts/crates
      - name: Download wheels + sdist
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          pattern: wheels-*
          path: artifacts/python
          merge-multiple: true
      - name: Attest build provenance
        id: attest
        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
        with:
          subject-path: |
            artifacts/crates/*.crate
            artifacts/python/*.whl
            artifacts/python/*.tar.gz

      # Attach the Sigstore provenance bundle to the GitHub Release as a
      # `*.intoto.jsonl` asset so OpenSSF Scorecard's Signed-Releases check finds
      # signed provenance on the release itself (P21.1e). attest-build-provenance
      # writes a single JSONL bundle covering every subject above; copy it to a
      # `.intoto.jsonl`-suffixed name and upload with --clobber so re-runs are
      # idempotent. github.token has contents: write here, which is all gh needs.
      - name: Attach provenance bundle to the GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ needs.github-release.outputs.tag }}
          BUNDLE: ${{ steps.attest.outputs.bundle-path }}
        run: |
          if [ -z "$TAG" ]; then
            echo "::error::no tag resolved from github-release; cannot attach provenance."
            exit 1
          fi
          if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE" ]; then
            echo "::error::attestation bundle not found at '$BUNDLE'."
            exit 1
          fi
          dest="wickra-${TAG}.provenance.intoto.jsonl"
          cp "$BUNDLE" "$dest"
          echo "Uploading $dest to release $TAG"
          gh release upload "$TAG" "$dest" --clobber --repo "${{ github.repository }}"

  # --------------------------------------------------------------------------
  # Publish the drafted release LAST (P24 — immutability-ready).
  #
  # github-release creates the release as a draft and attestations attaches the
  # provenance bundle to it; only now, with every asset in place, is it flipped to
  # published + latest. With GitHub release immutability enabled, assets lock at
  # this publish step — so the provenance bundle and every build artefact are
  # already present and never need a (rejected) post-publish upload.
  #
  # `if: always() && needs.github-release.result == 'success'` preserves the old
  # robustness: the release is published whenever the draft was created, even if
  # the attestations job hit a Sigstore hiccup — that only costs the provenance
  # asset, exactly as before. If github-release was skipped (a publish job failed)
  # there is no draft, so this is skipped too and no release is published.
  # --------------------------------------------------------------------------
  publish-release:
    name: Publish the GitHub Release
    needs: [github-release, attestations]
    if: always() && needs.github-release.result == 'success'
    runs-on: ubuntu-latest
    permissions:
      contents: write # flip the draft release to published
    steps:
      - name: Flip the draft release to published (latest)
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ needs.github-release.outputs.tag }}
        run: |
          if [ -z "$TAG" ]; then
            echo "::error::no tag resolved from github-release; cannot publish."
            exit 1
          fi
          echo "::notice::publishing release $TAG (draft -> published, latest)"
          gh release edit "$TAG" --draft=false --latest=true --repo "${{ github.repository }}"