ebman 0.30.0

k9s-style TUI for AWS Elastic Beanstalk
name: release

on:
  push:
    tags:
      - 'v*'
  # `release: published` fires when the maintainer flips the draft
  # release (created by the tag-push run below) to published. The
  # crates_io job below is gated on this event so cargo publish only
  # runs after the maintainer has sanity-checked the GitHub Release
  # artefacts — once published, crates.io can't be unpublished, so the
  # human gate matters.
  release:
    types: [published]
  # Manual re-run, e.g. when CARGO_REGISTRY_TOKEN was missing on the
  # first `release.published` fire and the crates_io job no-op'd.
  # Run from `main` (which has this trigger), passing the tag to
  # publish as an input:
  #   gh workflow run release.yml -f tag=v0.7.0
  # Build + publish-to-GH-release stay gated on `push` (artefacts
  # already exist for the tag); only the crates_io job re-fires.
  workflow_dispatch:
    inputs:
      tag:
        description: 'Tag to publish to crates.io (e.g. v0.7.0)'
        required: true
        type: string

# When a new tag like `v0.2.0` is pushed:
#   1. Build a release binary on each target platform.
#   2. Tarball it together with README + LICENSE files.
#   3. Attach the tarballs to a GitHub Release for the tag.
# Homebrew can then point at the macOS aarch64 / x86_64 tarballs by URL +
# SHA-256. The workflow uses softprops/action-gh-release which is the
# de-facto standard for this pattern and re-uses GITHUB_TOKEN.
#
# Once the maintainer publishes the draft release, the `crates_io` job
# runs `cargo publish` so crates.io is kept in sync with the GitHub
# Release without a manual `cargo publish` step (which was forgotten
# for 0.4.0 / 0.4.1 — see BACKLOG).

permissions:
  contents: write

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: short

jobs:
  build:
    name: build ${{ matrix.target }}
    # Only run on tag push — the release.published event reruns the
    # whole workflow but the build matrix already produced the artefacts
    # on the original tag-push run. Skipping here saves ~10 min of CI
    # time per published release.
    if: github.event_name == 'push'
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            runner: ubuntu-latest
            archive_ext: tar.gz
          # Native ARM64 Linux runner (GA for public repos) — avoids the
          # cross-compile/linker pain of building aarch64 on an x86 host.
          - target: aarch64-unknown-linux-gnu
            runner: ubuntu-24.04-arm
            archive_ext: tar.gz
          - target: aarch64-apple-darwin
            runner: macos-latest
            archive_ext: tar.gz
          - target: x86_64-apple-darwin
            runner: macos-latest
            archive_ext: tar.gz
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - uses: Swatinem/rust-cache@v2
      - name: Build release binary
        run: cargo build --locked --release --target ${{ matrix.target }}
      - name: Stage binary + docs
        run: |
          set -euo pipefail
          stage="ebman-${GITHUB_REF_NAME}-${{ matrix.target }}"
          mkdir "$stage"
          cp "target/${{ matrix.target }}/release/ebman" "$stage/"
          cp README.md LICENSE-MIT LICENSE-APACHE "$stage/"
          tar -czf "$stage.tar.gz" "$stage"
          shasum -a 256 "$stage.tar.gz" | tee "$stage.tar.gz.sha256"
      - name: Upload as workflow artifact
        uses: actions/upload-artifact@v4
        with:
          name: ebman-${{ matrix.target }}
          path: |
            ebman-*.tar.gz
            ebman-*.tar.gz.sha256

  publish:
    name: publish release
    needs: build
    # Tag-push only — the draft release attach is part of the build
    # phase. The release.published event doesn't need to re-run this.
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          path: dist
          pattern: ebman-*
          merge-multiple: true
      - name: Attach artefacts to release
        uses: softprops/action-gh-release@v2
        with:
          # GITHUB_REF_NAME is the tag, e.g. v0.2.0.
          tag_name: ${{ github.ref_name }}
          name: ebman ${{ github.ref_name }}
          files: |
            dist/ebman-*.tar.gz
            dist/ebman-*.tar.gz.sha256
          # Don't auto-publish a draft — the maintainer can flip the toggle
          # once they've sanity-checked the artefacts.
          draft: true
          generate_release_notes: true

  crates_io:
    # Publish to crates.io once the maintainer has reviewed the draft
    # GitHub Release and flipped it to published. Gated on the
    # `CARGO_REGISTRY_TOKEN` repository secret — when the secret isn't
    # configured the step is skipped (rather than failing), so forks /
    # scratch installations that don't own the crate don't fail loudly.
    #
    # Two earlier releases (0.4.0 / 0.4.1) shipped as GitHub Releases
    # only because the manual `cargo publish` step was forgotten; the
    # in-app update-check polls crates.io, so the gap surfaced as a
    # stale "latest" reported back to users. Automating closes that
    # gap. The release.published trigger keeps the maintainer in the
    # loop — crates.io can't be unpublished, so the human gate matters.
    name: publish to crates.io
    if: (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && !github.event.repository.fork
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # The release event payload's `tag_name` is what was published;
          # workflow_dispatch passes the tag via `inputs.tag`. Either
          # way we check out exactly that ref so cargo publishes the
          # released code, not whatever HEAD happens to be at workflow-
          # fire time.
          ref: ${{ github.event.release.tag_name || inputs.tag }}
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: cargo publish
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then
            echo "CARGO_REGISTRY_TOKEN not set — skipping crates.io publish"
            exit 0
          fi
          # ebman is the only crate published from this repo now — its
          # shared `tb-tui-common` dependency lives in its own repo
          # (tombaldwin/tb-tui-common) and publishes from there, so it's
          # already on crates.io by the time ebman resolves it.
          #
          # `--locked` matches the build matrix's lockfile pinning so a
          # workflow that built clean from Cargo.lock publishes the same
          # resolved dependency graph.
          cargo publish --locked

  mcp_registry:
    # Publish server.json to the official MCP Registry (preview) so agents
    # and directories can discover ebman. Runs after crates_io because the
    # registry verifies package ownership by fetching the crate's README
    # from crates.io — the visible `mcp-name:` marker in README.md — so the
    # crate carrying that marker must be live first. GitHub OIDC proves the
    # io.github.tombaldwin namespace, so no secret is needed. Isolated from
    # the build/publish jobs: a registry hiccup (it's preview) can't affect
    # the already-shipped GitHub Release or crates.io publish.
    name: publish to MCP Registry
    needs: crates_io
    if: (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && !github.event.repository.fork
    runs-on: ubuntu-latest
    permissions:
      id-token: write # GitHub OIDC → registry namespace auth
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.release.tag_name || inputs.tag }}
      - name: Sync server.json version to the release tag
        run: |
          set -euo pipefail
          TAG="${{ github.event.release.tag_name || inputs.tag }}"
          VERSION="${TAG#v}"
          jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp
          mv server.tmp server.json
      - name: Install mcp-publisher
        run: |
          curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
      - name: Authenticate (GitHub OIDC)
        run: ./mcp-publisher login github-oidc
      - name: Publish server.json
        # crates.io's rendered README — which the registry reads to verify
        # the mcp-name marker — can lag a cargo publish by up to a minute,
        # so retry rather than fail a fresh release on propagation timing.
        run: |
          set -eu
          for attempt in 1 2 3 4 5; do
            if ./mcp-publisher publish; then
              echo "published to MCP Registry"; exit 0
            fi
            echo "attempt $attempt failed (crate README may still be propagating) — waiting 30s"
            sleep 30
          done
          echo "MCP Registry publish failed after retries"; exit 1