name: Release
on:
push:
tags:
- 'v[0-9]*.[0-9]*.[0-9]*'
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run mode (skip actual publishing)'
required: false
default: true
type: boolean
skip_crates_io:
description: 'Skip publishing to crates.io (if already published)'
required: false
default: false
type: boolean
skip_pypi:
description: 'Skip publishing to PyPI (if already published)'
required: false
default: false
type: boolean
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
test:
name: Run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Verify Cargo.lock is up to date
run: cargo check --locked
- name: Run tests
run: make test
build:
name: Build ${{ matrix.target }}
needs: test
timeout-minutes: 30
if: github.event_name != 'pull_request'
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: [self-hosted, Linux, ARM64]
target: aarch64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: [self-hosted, macOS, ARM64]
target: x86_64-apple-darwin
- os: [self-hosted, macOS, ARM64]
target: aarch64-apple-darwin
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
clean: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- 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 dist
shell: bash
run: |
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
docker run --rm -v "${GITHUB_WORKSPACE}:/io" alpine rm -rf /io/dist /io/target/wheels
else
rm -rf dist target/wheels
fi
- name: Install cross-compilation tools
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@v8.2.0
- name: Install maturin and zig
if: matrix.target != 'aarch64-unknown-linux-gnu'
shell: bash
run: |
uv venv "${RUNNER_TEMP}/build-venv"
VENV_BIN="${RUNNER_TEMP}/build-venv/bin"
[ -d "$VENV_BIN" ] || VENV_BIN="${RUNNER_TEMP}/build-venv/Scripts"
VIRTUAL_ENV="${RUNNER_TEMP}/build-venv" uv pip install maturin ziglang
echo "$VENV_BIN" >> "$GITHUB_PATH"
- name: Build wheel (arm64 linux via maturin-action)
if: matrix.target == 'aarch64-unknown-linux-gnu'
uses: PyO3/maturin-action@v1
with:
target: aarch64-unknown-linux-gnu
args: --release --out dist
manylinux: manylinux_2_28
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 }} --compatibility manylinux_2_28 --zig --out dist
;;
*)
maturin build --release --target ${{ matrix.target }} --out dist
;;
esac
- name: Build binary (arm64 linux via cargo-zigbuild)
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'
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
run: cargo build --release --target ${{ matrix.target }}
- name: Verify binary
if: ${{ !contains(matrix.target, 'aarch64-unknown-linux') }}
shell: bash
run: |
if [[ "${{ runner.os }}" == "Windows" ]]; then
./target/${{ matrix.target }}/release/unifi-cli.exe --version
else
./target/${{ matrix.target }}/release/unifi-cli --version
fi
- name: Create release archive
shell: bash
run: |
VERSION=${GITHUB_REF#refs/tags/}
ARCHIVE_NAME="unifi-cli-${VERSION}-${{ matrix.target }}"
mkdir -p release-package
if [[ "${{ runner.os }}" == "Windows" ]]; then
cp "target/${{ matrix.target }}/release/unifi-cli.exe" release-package/unifi-cli.exe
cd release-package
powershell -command "Compress-Archive -Path unifi-cli.exe -DestinationPath ../${ARCHIVE_NAME}.zip"
cd ..
powershell -command "Get-FileHash -Path '${ARCHIVE_NAME}.zip' -Algorithm SHA256 | Select-Object -ExpandProperty Hash" > "${ARCHIVE_NAME}.zip.sha256"
else
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
BIN_PATH="target-host/aarch64-unknown-linux-gnu/release/unifi-cli"
else
BIN_PATH="target/${{ matrix.target }}/release/unifi-cli"
fi
cp "$BIN_PATH" release-package/unifi-cli
tar -czf "${ARCHIVE_NAME}.tar.gz" -C release-package unifi-cli
if [[ "${{ runner.os }}" == "macOS" ]]; then
shasum -a 256 "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
else
sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
fi
fi
rm -rf release-package
- name: Upload wheel
uses: actions/upload-artifact@v7
with:
path: dist/*.whl
name: wheel-${{ matrix.target }}
- name: Upload release archives
uses: actions/upload-artifact@v7
with:
path: |
unifi-cli-*-${{ matrix.target }}.tar.gz*
unifi-cli-*-${{ matrix.target }}.zip*
name: release-${{ matrix.target }}
sdist:
name: Build source distribution
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.2.0
- name: Install maturin
shell: bash
run: |
uv venv "${RUNNER_TEMP}/build-venv"
VENV_BIN="${RUNNER_TEMP}/build-venv/bin"
[ -d "$VENV_BIN" ] || VENV_BIN="${RUNNER_TEMP}/build-venv/Scripts"
VIRTUAL_ENV="${RUNNER_TEMP}/build-venv" uv pip install maturin
echo "$VENV_BIN" >> "$GITHUB_PATH"
- name: Build sdist
run: maturin sdist
- uses: actions/upload-artifact@v7
with:
path: target/wheels/*.tar.gz
name: sdist
release:
runs-on: ubuntu-latest
needs: [build, sdist]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Install uv
uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: false
- name: Publish to crates.io
if: ${{ inputs.dry_run != true && inputs.skip_crates_io != true }}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish --locked
- 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: Download all artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Publish to PyPI
if: ${{ inputs.dry_run != true && inputs.skip_pypi != true }}
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
uv tool run twine upload artifacts/wheel-*/*.whl artifacts/sdist/*.tar.gz
- name: Test PyPI upload (dry run)
if: ${{ inputs.dry_run == true && inputs.skip_pypi != true }}
run: |
echo "DRY RUN: Would upload to PyPI:"
find artifacts/wheel-* -name "*.whl" -type f | sort
find artifacts/sdist -name "*.tar.gz" -type f | sort
uv tool run twine check artifacts/wheel-*/*.whl artifacts/sdist/*.tar.gz
- name: Skip PyPI publishing
if: ${{ inputs.skip_pypi == true }}
run: echo "Skipping PyPI publishing as requested"
- name: Create Release
if: ${{ inputs.dry_run != true }}
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
files: |
artifacts/release-*/unifi-cli-*.tar.gz
artifacts/release-*/unifi-cli-*.tar.gz.sha256
artifacts/release-*/unifi-cli-*.zip
artifacts/release-*/unifi-cli-*.zip.sha256
- name: Dry Run Summary
if: ${{ inputs.dry_run == true }}
run: |
echo "Dry run complete. Artifacts built but nothing published."
echo "Archives:"
find artifacts/release-* -type f | sort
update-homebrew:
needs: release
if: ${{ !inputs.dry_run }}
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}/unifi-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/unifi-cli.rb << 'FORMULA'
class UnifiCli < Formula
desc "CLI for UniFi Network controllers"
homepage "https://github.com/rvben/unifi-cli"
version "VERSION_NUM"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/rvben/unifi-cli/releases/download/VERSION/unifi-cli-VERSION-aarch64-apple-darwin.tar.gz"
sha256 "SHA_AARCH64_APPLE_DARWIN"
else
url "https://github.com/rvben/unifi-cli/releases/download/VERSION/unifi-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/unifi-cli/releases/download/VERSION/unifi-cli-VERSION-aarch64-unknown-linux-gnu.tar.gz"
sha256 "SHA_AARCH64_UNKNOWN_LINUX_GNU"
else
url "https://github.com/rvben/unifi-cli/releases/download/VERSION/unifi-cli-VERSION-x86_64-unknown-linux-gnu.tar.gz"
sha256 "SHA_X86_64_UNKNOWN_LINUX_GNU"
end
end
def install
bin.install "unifi"
end
test do
system "#{bin}/unifi", "--version"
end
end
FORMULA
sed -i "s/VERSION_NUM/${VERSION_NUM}/g" /tmp/unifi-cli.rb
sed -i "s/VERSION/${VERSION}/g" /tmp/unifi-cli.rb
sed -i "s/SHA_AARCH64_APPLE_DARWIN/${{ steps.hashes.outputs.aarch64_apple_darwin }}/g" /tmp/unifi-cli.rb
sed -i "s/SHA_X86_64_APPLE_DARWIN/${{ steps.hashes.outputs.x86_64_apple_darwin }}/g" /tmp/unifi-cli.rb
sed -i "s/SHA_AARCH64_UNKNOWN_LINUX_GNU/${{ steps.hashes.outputs.aarch64_unknown_linux_gnu }}/g" /tmp/unifi-cli.rb
sed -i "s/SHA_X86_64_UNKNOWN_LINUX_GNU/${{ steps.hashes.outputs.x86_64_unknown_linux_gnu }}/g" /tmp/unifi-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/unifi-cli.rb /tmp/tap/Formula/unifi-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/unifi-cli.rb
git diff --cached --quiet || git commit -m "Update unifi-cli to ${VERSION}"
git push