jira-cli 0.4.6

Agent-friendly Jira CLI with JSON output, structured exit codes, and schema introspection
Documentation
name: Release

on:
  push:
    tags:
      - "v[0-9]*.[0-9]*.[0-9]*"
  workflow_dispatch:
    inputs:
      dry_run:
        description: 'Dry run (skip publishing)'
        required: false
        default: true
        type: boolean
      skip_crates_io:
        description: 'Skip crates.io publish'
        required: false
        default: false
        type: boolean
      skip_pypi:
        description: 'Skip PyPI publish'
        required: false
        default: false
        type: boolean

permissions:
  contents: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: taiki-e/install-action@v2.85.11
        with:
          tool: nextest
      - run: cargo check --locked
      - run: make test

  build:
    needs: test
    timeout-minutes: 30
    # Guard: self-hosted runners only execute on trusted events (tag push,
    # workflow_dispatch). Fork PRs must never dispatch jobs to self-hosted.
    if: github.event_name != 'pull_request'
    strategy:
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
          - target: aarch64-unknown-linux-gnu
            os: [self-hosted, Linux, ARM64]
          - target: x86_64-apple-darwin
            os: [self-hosted, macOS, ARM64]
          - target: aarch64-apple-darwin
            os: [self-hosted, macOS, ARM64]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
        with:
          # Preserve target/ between runs on self-hosted runners for cargo
          # incremental rebuild. GitHub-hosted runners start clean each job
          # regardless of this setting, so this only affects self-hosted.
          clean: false
      - name: Isolate Cargo cache
        shell: bash
        run: echo "CARGO_HOME=${RUNNER_TEMP}/cargo-home-${GITHUB_RUN_ID}-${{ matrix.target }}" >> "$GITHUB_ENV"
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - name: Install cargo-zigbuild
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        uses: taiki-e/install-action@v2.85.11
        with:
          tool: cargo-zigbuild
      - name: Prepare cargo cache dirs (arm64 linux)
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: mkdir -p "${RUNNER_TOOL_CACHE}/cargo-cache/registry" "${RUNNER_TOOL_CACHE}/cargo-cache/git"
      - name: Clean stale build outputs
        # Wipe every path the upload-artifact glob below can pick up, so prior
        # runs cannot leak tarballs or wheels into this run's artifact. Both
        # macOS targets share one self-hosted runner, so cross-target leakage
        # is possible too. Self-hosted runners do not clean automatically.
        # On the arm64 linux runner, maturin-action runs as root inside the
        # manylinux container and leaves root-owned files behind; clean via
        # docker so the unprivileged runner user can proceed.
        shell: bash
        run: |
          if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
            docker run --rm -v "${GITHUB_WORKSPACE}:/io" alpine sh -c \
              'rm -rf /io/dist /io/target/wheels && rm -f /io/jira-cli-*.tar.gz /io/jira-cli-*.tar.gz.sha256'
          else
            rm -rf dist target/wheels
            rm -f jira-cli-*.tar.gz jira-cli-*.tar.gz.sha256
          fi
      - name: Install cross-compilation tools
        # Only needed on GitHub-hosted linux where the arm64 toolchain must
        # be cross-installed. The self-hosted arm64 runner is native.
        if: matrix.target == 'aarch64-unknown-linux-gnu' && runner.arch != 'ARM64'
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu
      - name: Install uv
        if: matrix.target != 'aarch64-unknown-linux-gnu'
        uses: astral-sh/setup-uv@v9.0.0
      - name: Install maturin and zig
        # maturin-action provides its own maturin inside the manylinux
        # container for the arm64 linux build, so skip the host install
        # on the self-hosted runner. Install both into a shared venv so
        # maturin's `python3 -m ziglang` fallback finds ziglang.
        if: matrix.target != 'aarch64-unknown-linux-gnu'
        shell: bash
        run: |
          uv venv "${RUNNER_TEMP}/build-venv"
          uv pip install --python "${RUNNER_TEMP}/build-venv/bin/python" maturin ziglang
          echo "${RUNNER_TEMP}/build-venv/bin" >> "$GITHUB_PATH"
      - name: Build wheel (arm64 linux via maturin-action)
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        uses: PyO3/maturin-action@v1.51.0
        with:
          target: aarch64-unknown-linux-gnu
          args: --release --out dist
          manylinux: manylinux_2_28
          # Mount persistent cargo cache into the manylinux container so
          # dependency downloads survive between jobs on the self-hosted
          # runner.
          docker-options: -v ${{ runner.tool_cache }}/cargo-cache/registry:/root/.cargo/registry -v ${{ runner.tool_cache }}/cargo-cache/git:/root/.cargo/git
      - name: Build wheel
        if: matrix.target != 'aarch64-unknown-linux-gnu'
        shell: bash
        run: |
          case "${{ matrix.target }}" in
            *-gnu)
              maturin build --release --target ${{ matrix.target }} --zig --out dist
              ;;
            *)
              maturin build --release --target ${{ matrix.target }} --out dist
              ;;
          esac
      - name: Build binary (arm64 linux via cargo-zigbuild)
        # On the Pi runner build natively with cargo-zigbuild targeting
        # glibc 2.28 for wide distro compatibility. Use a separate target
        # directory so host cargo does not conflict with root-owned files
        # left behind by the maturin-action container build in target/.
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        env:
          CARGO_TARGET_DIR: target-host
        run: cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.28
      - name: Build binary
        if: matrix.target != 'aarch64-unknown-linux-gnu'
        run: cargo build --release --target ${{ matrix.target }}
        env:
          CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
      - name: Verify binary
        if: ${{ !contains(matrix.target, 'aarch64-unknown-linux') }}
        run: ./target/${{ matrix.target }}/release/jira --version
      - name: Package binary
        shell: bash
        run: |
          ARCHIVE_NAME="jira-cli-${{ github.ref_name }}-${{ matrix.target }}"
          if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
            BIN_PATH="target-host/aarch64-unknown-linux-gnu/release/jira"
          else
            BIN_PATH="target/${{ matrix.target }}/release/jira"
          fi
          mkdir -p "${ARCHIVE_NAME}"
          cp "${BIN_PATH}" "${ARCHIVE_NAME}/"
          tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
          shasum -a 256 "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
      - uses: actions/upload-artifact@v7
        with:
          name: release-${{ matrix.target }}
          path: |
            jira-cli-*.tar.gz
            jira-cli-*.tar.gz.sha256
            dist/*.whl

  sdist:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v9.0.0
      - name: Install maturin
        shell: bash
        run: |
          uv venv "${RUNNER_TEMP}/build-venv"
          uv pip install --python "${RUNNER_TEMP}/build-venv/bin/python" maturin
          echo "${RUNNER_TEMP}/build-venv/bin" >> "$GITHUB_PATH"
      - name: Build sdist
        run: maturin sdist
      - uses: actions/upload-artifact@v7
        with:
          name: sdist
          path: target/wheels/*.tar.gz

  release:
    needs: [build, sdist]
    runs-on: ubuntu-latest
    # Spelled out because a job-level block replaces the workflow-level one:
    # contents: write is what `gh release create` below needs.
    permissions:
      contents: write
      id-token: write   # crates.io Trusted Publishing
    steps:
      - uses: actions/checkout@v7
      - uses: actions/download-artifact@v8
        with:
          path: /tmp/artifacts
      - uses: dtolnay/rust-toolchain@stable

      # Exchange this job's GitHub OIDC identity for a short-lived crates.io
      # token, so releasing stops depending on a long-lived CARGO_REGISTRY_TOKEN
      # secret. v0.3.15 failed here with "403 Forbidden: authentication failed"
      # because that secret had gone stale, and it only surfaced after the tag
      # was pushed and every artifact was built. The action's post step revokes
      # the token when the job ends.
      #
      # Pinned to an exact tag deliberately: rust-lang/crates-io-auth-action
      # publishes only v1.0.x tags and has no floating `v1`, so the `@v1` its
      # README shows does not resolve and fails the release at this step.
      #
      # continue-on-error covers the window before the trusted publisher is
      # registered on crates.io: the publish step then falls back to the secret
      # and says so in the log. Once a release has published over OIDC, drop the
      # fallback here and delete the secret.
      - name: Authenticate to crates.io via Trusted Publishing
        id: crates_auth
        if: ${{ !inputs.dry_run && !inputs.skip_crates_io }}
        continue-on-error: true
        uses: rust-lang/crates-io-auth-action@v1.0.5

      - name: Publish to crates.io
        if: ${{ !inputs.dry_run && !inputs.skip_crates_io }}
        env:
          OIDC_TOKEN: ${{ steps.crates_auth.outputs.token }}
          SECRET_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          if [ -n "$OIDC_TOKEN" ]; then
            echo "Authenticated to crates.io via Trusted Publishing."
            export CARGO_REGISTRY_TOKEN="$OIDC_TOKEN"
          elif [ -n "$SECRET_TOKEN" ]; then
            echo "::warning::Trusted Publishing produced no token; using the CARGO_REGISTRY_TOKEN secret. Register the trusted publisher for this crate on crates.io, then remove this fallback."
            export CARGO_REGISTRY_TOKEN="$SECRET_TOKEN"
          else
            echo "::error::No crates.io credential: Trusted Publishing produced no token and CARGO_REGISTRY_TOKEN is unset."
            exit 1
          fi
          if ! output=$(cargo publish --locked 2>&1); then
            # crates.io rejects a re-upload with "crate version `X` is already
            # uploaded", so a re-run after a failure in a later step does not
            # die on a crate this workflow already published.
            if echo "$output" | grep -q "already uploaded"; then
              echo "Crate already published to crates.io, skipping"
            else
              echo "$output"
              exit 1
            fi
          fi

      - name: Test crates.io publish (dry run)
        if: ${{ inputs.dry_run == true && inputs.skip_crates_io != true }}
        run: |
          echo "DRY RUN: Would publish to crates.io"
          cargo publish --dry-run --locked

      - name: Skip crates.io publishing
        if: ${{ inputs.skip_crates_io == true }}
        run: echo "Skipping crates.io publishing as requested"

      - name: Install uv
        uses: astral-sh/setup-uv@v9.0.0

      - name: Publish to PyPI
        # actions/upload-artifact preserves the `dist/` prefix from the build
        # job, so wheels live at release-*/dist/*.whl, not release-*/*.whl.
        if: ${{ !inputs.dry_run && !inputs.skip_pypi }}
        run: uv publish /tmp/artifacts/release-*/dist/*.whl /tmp/artifacts/sdist/*.tar.gz
        env:
          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}

      - name: Create GitHub Release
        if: ${{ !inputs.dry_run }}
        run: |
          gh release create ${{ github.ref_name }} \
            --title "${{ github.ref_name }}" \
            --generate-notes \
            /tmp/artifacts/release-*/*.tar.gz \
            /tmp/artifacts/release-*/*.sha256
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Dry Run Summary
        if: ${{ inputs.dry_run == true }}
        run: |
          echo "Dry run complete. Artifacts built but nothing published."
          echo "Archives:"
          find /tmp/artifacts/release-* -type f | sort

  update-homebrew:
    needs: release
    if: ${{ inputs.dry_run != true }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v8
        with:
          path: /tmp/artifacts

      - name: Compute SHA256 hashes
        id: hashes
        run: |
          for target in x86_64-apple-darwin aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
            sha=$(cat /tmp/artifacts/release-${target}/jira-cli-${{ github.ref_name }}-${target}.tar.gz.sha256 | awk '{print $1}')
            key=$(echo "${target}" | tr '-' '_')
            echo "${key}=${sha}" >> "$GITHUB_OUTPUT"
          done

      - name: Update Homebrew formula
        env:
          HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
        run: |
          VERSION="${{ github.ref_name }}"
          VERSION_NUM="${VERSION#v}"

          cat > /tmp/jira-cli.rb << 'FORMULA'
          class JiraCli < Formula
            desc "CLI for Jira"
            homepage "https://github.com/rvben/jira-cli"
            version "VERSION_NUM"
            license "MIT"

            on_macos do
              if Hardware::CPU.arm?
                url "https://github.com/rvben/jira-cli/releases/download/VERSION/jira-cli-VERSION-aarch64-apple-darwin.tar.gz"
                sha256 "SHA_AARCH64_APPLE_DARWIN"
              else
                url "https://github.com/rvben/jira-cli/releases/download/VERSION/jira-cli-VERSION-x86_64-apple-darwin.tar.gz"
                sha256 "SHA_X86_64_APPLE_DARWIN"
              end
            end

            on_linux do
              if Hardware::CPU.arm?
                url "https://github.com/rvben/jira-cli/releases/download/VERSION/jira-cli-VERSION-aarch64-unknown-linux-gnu.tar.gz"
                sha256 "SHA_AARCH64_UNKNOWN_LINUX_GNU"
              else
                url "https://github.com/rvben/jira-cli/releases/download/VERSION/jira-cli-VERSION-x86_64-unknown-linux-gnu.tar.gz"
                sha256 "SHA_X86_64_UNKNOWN_LINUX_GNU"
              end
            end

            def install
              bin.install "jira"
            end

            test do
              system "#{bin}/jira", "--version"
            end
          end
          FORMULA

          sed -i "s/VERSION_NUM/${VERSION_NUM}/g" /tmp/jira-cli.rb
          sed -i "s/VERSION/${VERSION}/g" /tmp/jira-cli.rb
          sed -i "s/SHA_AARCH64_APPLE_DARWIN/${{ steps.hashes.outputs.aarch64_apple_darwin }}/g" /tmp/jira-cli.rb
          sed -i "s/SHA_X86_64_APPLE_DARWIN/${{ steps.hashes.outputs.x86_64_apple_darwin }}/g" /tmp/jira-cli.rb
          sed -i "s/SHA_AARCH64_UNKNOWN_LINUX_GNU/${{ steps.hashes.outputs.aarch64_unknown_linux_gnu }}/g" /tmp/jira-cli.rb
          sed -i "s/SHA_X86_64_UNKNOWN_LINUX_GNU/${{ steps.hashes.outputs.x86_64_unknown_linux_gnu }}/g" /tmp/jira-cli.rb

          # Authenticate to the tap with a write deploy key scoped to
          # rvben/homebrew-tap. The key does not expire, so cross-repo pushes
          # keep working without periodic token rotation.
          mkdir -p ~/.ssh
          echo "$HOMEBREW_TAP_DEPLOY_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null

          git clone git@github.com:rvben/homebrew-tap.git /tmp/tap
          mkdir -p /tmp/tap/Formula
          cp /tmp/jira-cli.rb /tmp/tap/Formula/jira-cli.rb
          cd /tmp/tap
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add Formula/jira-cli.rb
          git diff --cached --quiet || git commit -m "Update jira-cli to ${VERSION}"
          git push