spm-cli 0.3.0

Skill package manager — declare AI skills in ai.json, materialize them for Claude/Copilot without polluting your repo.
name: release

# Cross-compiles static binaries for every target, attaches them to the GitHub
# Release, and publishes them to npm as `@camunda8/spm` (+ per-platform packages)
# when a v* tag is pushed. See RELEASE.md for the full release procedure.
on:
  push:
    tags: ["v*"]
  workflow_dispatch:
    inputs:
      dry_run:
        description: "Build + package npm without publishing (npm publish --dry-run)"
        type: boolean
        default: true
      notes_tag:
        description: "Only (re)generate the GitHub Release notes for this tag, then stop (e.g. v0.1.1)"
        type: string
        default: ""

permissions:
  contents: write

jobs:
  build:
    name: ${{ matrix.target }}
    # Skip the (expensive) cross-compile + npm path when the run is only a
    # manual "regenerate the release notes for this tag" request.
    if: github.event_name == 'push' || inputs.notes_tag == ''
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest,  target: x86_64-unknown-linux-gnu,   ext: "" }
          - { os: ubuntu-latest,  target: aarch64-unknown-linux-gnu,  ext: "" }
          - { os: macos-latest,   target: x86_64-apple-darwin,        ext: "" }
          - { os: macos-latest,   target: aarch64-apple-darwin,       ext: "" }
          - { os: windows-latest, target: x86_64-pc-windows-msvc,     ext: ".exe" }
    steps:
      - uses: actions/checkout@v5
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - uses: Swatinem/rust-cache@v2
      - name: Install cross-linker (aarch64 linux)
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu
          echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
      - name: Build
        run: cargo build --release --target ${{ matrix.target }}
      # Upload the raw binary for the npm job to package, laid out as
      # <target>/spm[.exe] so npm/build.mjs can find it by rust target.
      - name: Upload binary artifact
        uses: actions/upload-artifact@v5
        with:
          name: ${{ matrix.target }}
          path: target/${{ matrix.target }}/release/spm${{ matrix.ext }}
          if-no-files-found: error
      - name: Package
        shell: bash
        run: |
          bin="target/${{ matrix.target }}/release/spm${{ matrix.ext }}"
          out="spm-${{ matrix.target }}${{ matrix.ext }}"
          cp "$bin" "$out"
          echo "ASSET=$out" >> "$GITHUB_ENV"
      - name: Attach to release
        uses: softprops/action-gh-release@v2
        if: startsWith(github.ref, 'refs/tags/')
        with:
          files: ${{ env.ASSET }}

  # Publishes the root `@camunda8/spm` launcher package plus the five
  # per-platform binary packages to npm using npm Trusted Publishing (OIDC) — no
  # long-lived NPM_TOKEN once the trusted publishers are configured. A temporary
  # NPM_TOKEN secret, if set, is preferred for the one-time bootstrap publish
  # (mirroring publish.yml's crates.io bootstrap). See RELEASE.md.
  npm:
    name: npm publish
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      id-token: write # required for npm Trusted Publishing (OIDC)
    env:
      DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: "24"
          registry-url: "https://registry.npmjs.org"
      # npm Trusted Publishing (OIDC) needs npm >= 11.5.1. Pinned (not @latest)
      # for reproducible, supply-chain-safe releases.
      - name: Update npm for OIDC Trusted Publishing
        run: npm install -g npm@11.12.1
      - name: Download binary artifacts
        uses: actions/download-artifact@v5
        with:
          path: artifacts
      # Never publish vX.Y.Z npm packages from a manifest still on another version.
      - name: Verify tag matches crate version
        if: startsWith(github.ref, 'refs/tags/v')
        run: |
          set -euo pipefail
          # Read the version only from the [package] section so unrelated
          # `version =` keys elsewhere in Cargo.toml can't be picked up.
          crate_version="$(awk '/^\[package\]/{p=1;next} /^\[/{p=0} p && /^version[[:space:]]*=/{gsub(/.*=[[:space:]]*"|".*/, ""); print; exit}' Cargo.toml)"
          tag_version="${GITHUB_REF_NAME#v}"
          echo "tag=$tag_version crate=$crate_version"
          if [ "$tag_version" != "$crate_version" ]; then
            echo "::error::Tag $GITHUB_REF_NAME does not match crate version $crate_version"
            exit 1
          fi
      - name: Generate npm packages
        run: node npm/build.mjs --bin-dir artifacts
      - name: Publish
        env:
          # Prefer a bootstrap token if present, else OIDC Trusted Publishing.
          # npm uses OIDC automatically when NODE_AUTH_TOKEN is empty and a
          # trusted publisher is configured for the package.
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          set -euo pipefail
          publish() {
            if [ "${DRY_RUN}" = "true" ]; then
              npm publish "$1" --access public --dry-run
            else
              npm publish "$1" --access public
            fi
          }
          # Platform packages first, then the root that depends on them.
          for dir in npm/dist/@camunda8/spm-*; do
            echo "::group::publish $dir"; publish "$dir"; echo "::endgroup::"
          done
          echo "::group::publish npm/dist/@camunda8/spm"
          publish "npm/dist/@camunda8/spm"
          echo "::endgroup::"

  # Regenerates the GitHub Release description ("What's Changed") from the PRs
  # that make up the release, using GitHub's own generate-notes engine (see
  # scripts/update-release-notes.sh). Runs after `build` so the release —
  # created by the build job when it attaches binaries — already exists, then
  # overwrites its body. Also reachable via workflow_dispatch with `notes_tag`
  # to backfill the notes for an already-published tag without rebuilding.
  release-notes:
    name: release notes
    needs: [build]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: write # edit the GitHub Release
    # Run after a successful tag build (so the release exists and its binaries
    # are already attached — no race to create it), OR standalone for a
    # notes-only dispatch, where `build` is skipped.
    if: |
      always() &&
      ((github.event_name == 'push' && needs.build.result == 'success') ||
       (github.event_name == 'workflow_dispatch' && inputs.notes_tag != ''))
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0 # full history + tags so --include-commits can list non-PR commits
      - name: Update release notes
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAG: ${{ (github.event_name == 'push' && github.ref_name) || inputs.notes_tag }}
        run: scripts/update-release-notes.sh "$TAG" --repo "$GITHUB_REPOSITORY" --include-commits