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 with:
ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }}
fetch-depth: 0
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
- 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
- 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 with:
ref: ${{ needs.release-context.outputs.tag }}
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with:
targets: ${{ matrix.target }}
- name: Cache dependencies
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae
- 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 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 with:
ref: ${{ needs.release-context.outputs.tag }}
fetch-depth: 0
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c 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 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 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 with:
ref: ${{ needs.release-context.outputs.tag }}
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
- name: Cache dependencies
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae
- 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 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