spherenet-admin 0.3.1

Command-line tool for SphereNet governance and network administration
name: Release

# Manual, one-button release. You own the version: bump `version` in Cargo.toml
# in a normal (reviewed) PR and merge it FIRST. This workflow NEVER edits code,
# commits, or pushes to a branch — it builds both target binaries, publishes the
# crate to crates.io, and cuts a GitHub Release. The only ref it creates is the
# release tag (vX.Y.Z), pointing at the commit you released — not a code change.
#
#   1. preflight       - fmt / clippy / test / `cargo publish --dry-run`, and refuse
#                        if the Cargo.toml version is already on crates.io (forgot to bump)
#   2. build           - cross-build + package both binaries (Linux x86_64, macOS arm64).
#                        Doubles as the compile gate: publish waits on it.
#   3. publish         - `cargo publish` to crates.io  (irreversible; only after 1 & 2)
#   4. github-release  - tag vX.Y.Z at the released commit + attach binaries + checksums
#
# Version is sourced entirely from Cargo.toml — there is no bump input and no
# cargo-release. Prerequisite: repo secret CARGO_REGISTRY_TOKEN (a crates.io API token).
on:
  workflow_dispatch:
    inputs:
      dry_run:
        description: "Rehearse only - build + verify, no publish, no tag, no GitHub Release"
        type: boolean
        default: false

permissions:
  contents: read

# Never let two releases run at once.
concurrency:
  group: release
  cancel-in-progress: false

env:
  CARGO_TERM_COLOR: always

jobs:
  preflight:
    name: Preflight
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.version }}
      tag: ${{ steps.version.outputs.tag }}
    steps:
      # Ship gate, not a style gate: fmt/clippy are enforced by ci.yml on every
      # push to main (the exact commit being released), so preflight verifies only
      # what's release-specific — it tests, dry-run-publishes, and (with `build`)
      # proves both targets compile before the irreversible publish.
      - uses: actions/checkout@v4
      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Test
        run: cargo test --locked
      - name: Verify the crate publishes
        run: cargo publish --dry-run --locked
      - name: Read version from Cargo.toml + guard against re-publish
        id: version
        run: |
          set -euo pipefail
          VERSION="$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')"
          echo "Cargo.toml version: $VERSION"
          # Fail early if this version is already on crates.io — the "I forgot to
          # bump Cargo.toml" guard, before anything irreversible runs.
          code=$(curl -sS -o /dev/null -w '%{http_code}' \
            -A "spherenet-admin-release (github-actions)" \
            "https://crates.io/api/v1/crates/spherenet-admin/${VERSION}")
          if [ "$code" = "200" ]; then
            echo "::error::spherenet-admin ${VERSION} is already published on crates.io — bump the version in Cargo.toml first."
            exit 1
          fi
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
          echo "tag=v${VERSION}"    >> "$GITHUB_OUTPUT"

  build:
    name: Build ${{ matrix.target }}
    needs: preflight
    # Builds the real release artifacts AND serves as the cross-platform compile
    # gate — `publish` needs this, so a break on either target stops the release
    # before crates.io is touched. No version bump happens anywhere, so what's
    # built here is exactly what ships (correct version embedded from Cargo.toml).
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
          - target: aarch64-apple-darwin
            os: macos-latest
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.target }}
      - name: Build
        run: cargo build --release --locked --target ${{ matrix.target }}
      - name: Package
        shell: bash
        run: |
          set -euo pipefail
          bin="spherenet-admin"
          dist="${bin}-${{ needs.preflight.outputs.version }}-${{ matrix.target }}"
          mkdir -p "staging/${dist}"
          cp "target/${{ matrix.target }}/release/${bin}" "staging/${dist}/"
          cp README.md LICENSE "staging/${dist}/"
          mkdir -p dist
          tar -czf "dist/${dist}.tar.gz" -C staging "${dist}"
          ( cd dist && shasum -a 256 "${dist}.tar.gz" > "${dist}.tar.gz.sha256" )
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.target }}
          path: |
            dist/*.tar.gz
            dist/*.sha256
          if-no-files-found: error

  publish:
    name: Publish to crates.io
    needs: [preflight, build]
    if: ${{ ! inputs.dry_run }}
    runs-on: ubuntu-latest
    # Gates the irreversible publish behind the `release` environment: its secret
    # (CARGO_REGISTRY_TOKEN) is only readable here, and any environment protection
    # rules (required reviewers, branch limits) must pass before this job runs.
    environment: release
    env:
      CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Publish
        run: cargo publish --locked

  github-release:
    name: GitHub Release
    needs: [preflight, build, publish]
    if: ${{ ! inputs.dry_run }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Download artifacts
        uses: actions/download-artifact@v4
        with:
          path: dist
          merge-multiple: true
      - name: Publish GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ needs.preflight.outputs.tag }}
          name: ${{ needs.preflight.outputs.tag }}
          target_commitish: ${{ github.sha }}
          generate_release_notes: true
          fail_on_unmatched_files: true
          files: |
            dist/*.tar.gz
            dist/*.sha256