netspeed-cli 0.10.3

Command-line interface for testing internet bandwidth using speedtest.net
Documentation
name: Release

on:
  workflow_dispatch:
    inputs:
      version:
        description: "SemVer version to release, without a leading v"
        required: true
        type: string
  push:
    tags:
      - 'v*'

env:
  CARGO_TERM_COLOR: always
  HOMEBREW_TAP_REPO: mapleDevJS/homebrew-netspeed-cli

permissions:
  contents: read

jobs:
  release-context:
    name: Prepare Release Context
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.context.outputs.version }}
      tag: ${{ steps.context.outputs.tag }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }}
          fetch-depth: 0
          persist-credentials: false

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - name: Install git-cliff
        if: github.event_name == 'workflow_dispatch'
        run: cargo install git-cliff --locked

      - name: Install cargo-deny
        if: github.event_name == 'workflow_dispatch'
        uses: taiki-e/install-action@bad5eddc21a2ff90971c4477b7ff723d0d0b0db6 # cargo-deny

      - name: Validate existing tag release
        if: github.event_name == 'push'
        run: |
          set -euo pipefail
          if ! git branch -r --contains "${{ github.ref_name }}" | grep -q 'origin/main'; then
            echo "::error::Tag ${{ github.ref_name }} is not on the 'main' branch."
            echo "::error::Releases must be created from main. See RELEASE.md."
            exit 1
          fi

      - name: Prepare manual release
        if: github.event_name == 'workflow_dispatch'
        env:
          VERSION: ${{ inputs.version }}
          GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
        run: |
          set -euo pipefail

          if [[ -z "${GH_TOKEN}" ]]; then
            echo "::error::RELEASE_TOKEN is required to create release commits, tags, and GitHub Releases."
            exit 1
          fi

          if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "::error::Invalid version '${VERSION}'. Expected X.Y.Z."
            exit 1
          fi

          git fetch origin main --tags
          git checkout -B main origin/main

          if [[ -n "$(git status --porcelain)" ]]; then
            echo "::error::Working tree is not clean before release prep."
            git status --short
            exit 1
          fi

          if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
            echo "::error::Tag v${VERSION} already exists."
            exit 1
          fi

          if gh release view "v${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
            echo "::error::GitHub release v${VERSION} already exists."
            exit 1
          fi

          CRATES_VERSION="$(cargo search netspeed-cli --limit 1 | sed -n 's/^netspeed-cli = "\([^"]*\)".*/\1/p' | head -1)"
          if [[ "${CRATES_VERSION}" == "${VERSION}" ]]; then
            echo "::error::netspeed-cli ${VERSION} already exists on crates.io."
            exit 1
          fi

          LATEST_TAG="$(git tag -l 'v[0-9]*' --sort=-v:refname | head -1 | sed 's/^v//')"
          if [[ -n "${LATEST_TAG}" && "$(printf '%s\n%s\n' "${LATEST_TAG}" "${VERSION}" | sort -V | tail -1)" != "${VERSION}" ]]; then
            echo "::error::Version ${VERSION} must be greater than latest tag ${LATEST_TAG}."
            exit 1
          fi

          sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml
          cargo check
          cargo build --quiet
          git-cliff --config .cliff.toml --tag "v${VERSION}" --output CHANGELOG.md

          cargo fmt --all -- --check
          cargo clippy --all-targets --all-features -- -D warnings
          cargo test --verbose
          cargo test --doc
          RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace
          cargo test --test mock_network_test -- --ignored --nocapture
          cargo test --test integration_upload_fetch_test -- --ignored --nocapture
          cargo test --test e2e_test -- --ignored --nocapture
          cargo package --locked --allow-dirty
          cargo deny check
          cargo publish --dry-run --locked --allow-dirty

          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add Cargo.toml Cargo.lock CHANGELOG.md completions netspeed-cli.1
          git commit -m "chore(release): bump to v${VERSION}"
          git tag -a "v${VERSION}" -m "Release v${VERSION}"
          git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
          git push origin main
          git push origin "v${VERSION}"

      - name: Set release context
        id: context
        env:
          INPUT_VERSION: ${{ inputs.version }}
        run: |
          set -euo pipefail
          if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
            VERSION="${INPUT_VERSION}"
            TAG="v${VERSION}"
          else
            TAG="${{ github.ref_name }}"
            VERSION="${TAG#v}"
          fi
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
          echo "tag=${TAG}" >> "$GITHUB_OUTPUT"

  build-binaries:
    name: Build Binary (${{ matrix.target }})
    runs-on: ${{ matrix.os }}
    needs: [release-context]
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
            artifact_name: netspeed-cli
            asset_name: netspeed-cli-x86_64-linux-gnu
          - os: ubuntu-latest
            target: aarch64-unknown-linux-gnu
            artifact_name: netspeed-cli
            asset_name: netspeed-cli-aarch64-linux-gnu
          - os: ubuntu-latest
            target: x86_64-unknown-linux-musl
            artifact_name: netspeed-cli
            asset_name: netspeed-cli-x86_64-linux-musl
          - os: ubuntu-latest
            target: aarch64-unknown-linux-musl
            artifact_name: netspeed-cli
            asset_name: netspeed-cli-aarch64-linux-musl
          - os: macos-latest
            target: x86_64-apple-darwin
            artifact_name: netspeed-cli
            asset_name: netspeed-cli-x86_64-macos
          - os: macos-latest
            target: aarch64-apple-darwin
            artifact_name: netspeed-cli
            asset_name: netspeed-cli-aarch64-macos
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            artifact_name: netspeed-cli.exe
            asset_name: netspeed-cli-x86_64-windows.exe
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          ref: ${{ needs.release-context.outputs.tag }}

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

      - name: Cache dependencies
        uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2

      - name: Install cross-compilation tools (aarch64 Linux GNU)
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu

      - name: Configure aarch64 linker
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: |
          mkdir -p .cargo
          cat > .cargo/config.toml << 'EOF'
          [target.aarch64-unknown-linux-gnu]
          linker = "aarch64-linux-gnu-gcc"
          EOF

      - name: Install musl tools (x86_64)
        if: matrix.target == 'x86_64-unknown-linux-musl'
        run: |
          sudo apt-get update
          sudo apt-get install -y musl-tools

      - name: Install cross (aarch64 musl)
        if: matrix.target == 'aarch64-unknown-linux-musl'
        run: cargo install cross --locked

      - name: Build release (cross)
        if: matrix.target == 'aarch64-unknown-linux-musl'
        run: cross build --release --target ${{ matrix.target }}

      - name: Build release (native)
        if: matrix.target != 'aarch64-unknown-linux-musl'
        run: cargo build --release --target ${{ matrix.target }}

      - name: Prepare artifact
        shell: bash
        run: |
          mkdir -p dist
          cp "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}" "dist/${{ matrix.asset_name }}"

      - name: Upload artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: ${{ matrix.asset_name }}
          path: dist/${{ matrix.asset_name }}

  publish-github-release:
    name: Create GitHub Release
    runs-on: ubuntu-latest
    needs: [release-context, build-binaries]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          ref: ${{ needs.release-context.outputs.tag }}
          fetch-depth: 0

      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
        with:
          path: release-assets
          merge-multiple: true

      - name: Generate SHA256 checksums
        run: |
          cd release-assets
          sha256sum * > SHA256SUMS.txt
          cat SHA256SUMS.txt

      - name: Generate SBOM
        uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
        with:
          path: .
          artifact-name: sbom.spdx.json
          output-file: release-assets/sbom.spdx.json
          format: spdx-json

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
          TAG: ${{ needs.release-context.outputs.tag }}
        run: |
          set -euo pipefail
          if [[ -z "${GH_TOKEN}" ]]; then
            echo "::error::RELEASE_TOKEN is required to create GitHub Releases."
            exit 1
          fi
          gh release create "${TAG}" \
            --title "${TAG}" \
            --notes-file CHANGELOG.md \
            --repo "${{ github.repository }}"
          gh release upload "${TAG}" release-assets/* --repo "${{ github.repository }}" --clobber

  update-local-homebrew-formula:
    name: Update Local Homebrew Formula
    runs-on: ubuntu-latest
    needs: [release-context, publish-github-release]
    if: github.event_name == 'workflow_dispatch'
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          ref: main
          fetch-depth: 0
          token: ${{ secrets.RELEASE_TOKEN }}

      - name: Render and commit formula
        env:
          GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
          VERSION: ${{ needs.release-context.outputs.version }}
        run: |
          set -euo pipefail

          if [[ -z "${GH_TOKEN}" ]]; then
            echo "::error::RELEASE_TOKEN is required to update the local Homebrew formula."
            exit 1
          fi

          git pull --ff-only origin main
          scripts/render-homebrew-formula.sh "${VERSION}"

          if git diff --quiet -- netspeed-cli.rb; then
            echo "Local Homebrew formula is already at v${VERSION}."
            exit 0
          fi

          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add netspeed-cli.rb
          git commit -m "chore(release): update Homebrew formula for v${VERSION}"
          git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
          git push origin main

  publish-crates-io:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    needs: [release-context, publish-github-release]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          ref: ${{ needs.release-context.outputs.tag }}

      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

      - name: Cache dependencies
        uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2

      - name: Verify package
        run: |
          cargo test --verbose
          cargo test --doc
          cargo package --locked

      - name: Publish to crates.io
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: cargo publish --locked

  homebrew-tap-pr:
    name: Open Homebrew Tap PR
    runs-on: ubuntu-latest
    needs: [release-context, publish-crates-io]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          ref: ${{ needs.release-context.outputs.tag }}

      - name: Create or update tap PR
        env:
          GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
          TAP_REPO: ${{ env.HOMEBREW_TAP_REPO }}
          VERSION: ${{ needs.release-context.outputs.version }}
        run: |
          set -euo pipefail

          if [[ -z "${GH_TOKEN}" ]]; then
            echo "::error::HOMEBREW_TAP_TOKEN is required to open the tap PR."
            exit 1
          fi

          git clone "https://x-access-token:${GH_TOKEN}@github.com/${TAP_REPO}.git" homebrew-tap
          scripts/render-homebrew-formula.sh "${VERSION}" homebrew-tap/netspeed-cli.rb

          cd homebrew-tap
          BRANCH="release/netspeed-cli-v${VERSION}"
          git checkout -B "${BRANCH}"

          if git diff --quiet -- netspeed-cli.rb; then
            echo "Homebrew tap formula is already at v${VERSION}."
            exit 0
          fi

          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add netspeed-cli.rb
          git commit -m "netspeed-cli v${VERSION}"
          git push --force-with-lease origin "${BRANCH}"

          if gh pr view "${BRANCH}" --repo "${TAP_REPO}" >/dev/null 2>&1; then
            echo "Homebrew tap PR already exists for ${BRANCH}."
          else
            gh pr create \
              --repo "${TAP_REPO}" \
              --base main \
              --head "${BRANCH}" \
              --title "netspeed-cli v${VERSION}" \
              --body "Update netspeed-cli formula to v${VERSION}."
          fi