name: Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
permissions:
contents: write
env:
CARGO_PROFILE_RELEASE_STRIP: false
HAS_GPG_SECRET: ${{ secrets.GPG_PRIVATE_KEY != '' }}
HAS_PUBLIC_KEY: ${{ vars.GPG_PUBLIC_KEY != '' }}
jobs:
build-matrix:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
use_cross: false
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
use_cross: true
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
use_cross: true
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
use_cross: true
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
use_cross: true
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
use_cross: true
- os: macos-latest
target: x86_64-apple-darwin
use_cross: false
- os: macos-14 target: aarch64-apple-darwin
use_cross: false
- os: windows-latest
target: x86_64-pc-windows-msvc
use_cross: false
- os: windows-latest
target: aarch64-pc-windows-msvc
use_cross: false
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
target: ${{ matrix.target }}
cache: 'cargo'
- name: Get Binary Name
id: bin_name
shell: bash
run: |
# Parses Cargo.toml metadata to find the name of the first "bin" target.
# This handles cases where package.name != binary.name
BIN_NAME=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name' | head -n 1)
if [ -z "$BIN_NAME" ]; then
echo "Error: Could not determine binary name from Cargo.toml"
exit 1
fi
echo "Detected binary: $BIN_NAME"
echo "BINARY_NAME=$BIN_NAME" >> $GITHUB_ENV
- name: Install Cross
if: matrix.use_cross
uses: taiki-e/install-action@v2
with:
tool: cross
- name: Build Binary
shell: bash
run: |
CMD="cargo"
if [[ "${{ matrix.use_cross }}" == "true" ]]; then CMD="cross"; fi
$CMD build --release --target ${{ matrix.target }} --verbose
- name: Import GPG Key
if: env.HAS_GPG_SECRET == 'true'
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Package and Sign
shell: bash
env:
TARGET: ${{ matrix.target }}
REF_NAME: ${{ github.ref_name }}
HAS_GPG: ${{ secrets.GPG_PRIVATE_KEY != '' }}
run: |
# --- Setup Variables ---
# Ensure version always has 'v' prefix for folder naming (e.g., v1.0.0).
CLEAN_VERSION="${REF_NAME#v}"
VERSION="v${CLEAN_VERSION}"
# Normalize binary name (Rust replaces hyphens with underscores).
BIN_FILE="${BINARY_NAME//-/_}"
# Determine extensions based on OS.
BIN_EXT=""
ARCHIVE_EXT="tar.gz"
if [[ "$TARGET" == *"windows"* ]]; then
BIN_EXT=".exe"
ARCHIVE_EXT="zip"
elif [[ "$TARGET" == *"apple"* ]]; then
ARCHIVE_EXT="zip"
fi
# Locate the built binary.
BUILD_DIR="target/$TARGET/release"
BIN_PATH="$BUILD_DIR/$BIN_FILE$BIN_EXT"
if [[ ! -f "$BIN_PATH" ]]; then
echo "Error: Binary not found at $BIN_PATH"
exit 1
fi
# --- Prepare Directories (binstall compliance) ---
# Structure: {name}-{target}-{version}/
DIR_NAME="${BINARY_NAME}-${TARGET}-${VERSION}"
FULL_DIR_NAME="${BINARY_NAME}-${TARGET}-${VERSION}-full"
mkdir -p "$DIR_NAME" "$FULL_DIR_NAME"
# --- Populate "Full" Directory (Unstripped + PDB) ---
cp "$BIN_PATH" "$FULL_DIR_NAME/$BINARY_NAME$BIN_EXT"
cp LICENSE README.md "$FULL_DIR_NAME/"
# On Windows, include the PDB debug symbols.
if [[ "$TARGET" == *"windows"* ]]; then
cp "$BUILD_DIR/$BIN_FILE.pdb" "$FULL_DIR_NAME/$BINARY_NAME.pdb" || true
fi
# --- Populate "Standard" Directory (Stripped) ---
cp "$BIN_PATH" "$DIR_NAME/$BINARY_NAME$BIN_EXT"
cp LICENSE README.md "$DIR_NAME/"
# Manually strip the binary for the standard release to reduce size.
if [[ "$TARGET" != *"windows"* ]]; then
if [[ "$TARGET" == *"apple"* ]]; then
strip -x "$DIR_NAME/$BINARY_NAME$BIN_EXT" || true
else
strip "$DIR_NAME/$BINARY_NAME$BIN_EXT" || true
fi
fi
# --- Create Archives ---
FULL_ARCHIVE="${FULL_DIR_NAME}.${ARCHIVE_EXT}"
STD_ARCHIVE="${DIR_NAME}.${ARCHIVE_EXT}"
if [[ "$ARCHIVE_EXT" == "zip" ]]; then
if command -v zip &> /dev/null; then
zip -r "$FULL_ARCHIVE" "$FULL_DIR_NAME"
zip -r "$STD_ARCHIVE" "$DIR_NAME"
elif command -v 7z &> /dev/null; then
7z a "$FULL_ARCHIVE" "$FULL_DIR_NAME"
7z a "$STD_ARCHIVE" "$DIR_NAME"
else
echo "Error: Neither 'zip' nor '7z' found."
exit 1
fi
else
tar -czf "$FULL_ARCHIVE" "$FULL_DIR_NAME"
tar -czf "$STD_ARCHIVE" "$DIR_NAME"
fi
# --- Sign Archives (Optional) ---
if [[ "$HAS_GPG" == "true" ]]; then
gpg --batch --yes --detach-sign --armor --output "${FULL_ARCHIVE}.sig" "$FULL_ARCHIVE"
gpg --batch --yes --detach-sign --armor --output "${STD_ARCHIVE}.sig" "$STD_ARCHIVE"
fi
# --- Organize for Upload ---
mkdir dist
mv "${FULL_ARCHIVE}" "${STD_ARCHIVE}" dist/
if [[ "$HAS_GPG" == "true" ]]; then
mv "${FULL_ARCHIVE}.sig" "${STD_ARCHIVE}.sig" dist/
fi
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: artifacts-${{ matrix.target }}
path: dist/*
build-mac-universal:
name: Build universal-apple-darwin
needs: build-matrix
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
cache: 'cargo'
- name: Get Binary Name
id: bin_name
shell: bash
run: |
BIN_NAME=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name' | head -n 1)
echo "Detected binary: $BIN_NAME"
echo "BINARY_NAME=$BIN_NAME" >> $GITHUB_ENV
- name: Download x86_64 Artifacts
uses: actions/download-artifact@v4
with:
name: artifacts-x86_64-apple-darwin
path: artifacts-x86
- name: Download arm64 Artifacts
uses: actions/download-artifact@v4
with:
name: artifacts-aarch64-apple-darwin
path: artifacts-arm
- name: Create Universal Binary (Lipo)
shell: bash
run: |
# Extract the "Full" (unstripped) zips to get the raw binaries.
unzip -o artifacts-x86/*x86_64-apple-darwin*-full.zip -d x86_extract
unzip -o artifacts-arm/*aarch64-apple-darwin*-full.zip -d arm_extract
# Find the binary files (ignoring folder structure).
BIN_X86=$(find x86_extract -type f -name "${BINARY_NAME}" | head -n 1)
BIN_ARM=$(find arm_extract -type f -name "${BINARY_NAME}" | head -n 1)
if [[ -z "$BIN_X86" || -z "$BIN_ARM" ]]; then
echo "Error: Could not locate extracted binaries for lipo."
exit 1
fi
# Merge architectures.
mkdir -p output
lipo -create -output output/${BINARY_NAME} "$BIN_X86" "$BIN_ARM"
- name: Import GPG Key
if: env.HAS_GPG_SECRET == 'true'
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Package and Sign
shell: bash
env:
HAS_GPG: ${{ secrets.GPG_PRIVATE_KEY != '' }}
REF_NAME: ${{ github.ref_name }}
run: |
CLEAN_VERSION="${REF_NAME#v}"
VERSION="v${CLEAN_VERSION}"
TARGET="universal-apple-darwin"
BIN_PATH="output/${BINARY_NAME}"
# --- Prepare Directories ---
DIR_NAME="${BINARY_NAME}-${TARGET}-${VERSION}"
FULL_DIR_NAME="${BINARY_NAME}-${TARGET}-${VERSION}-full"
mkdir -p "$DIR_NAME" "$FULL_DIR_NAME"
# Full (Unstripped)
cp "$BIN_PATH" "$FULL_DIR_NAME/$BINARY_NAME"
cp LICENSE README.md "$FULL_DIR_NAME/"
# Standard (Stripped)
cp "$BIN_PATH" "$DIR_NAME/$BINARY_NAME"
cp LICENSE README.md "$DIR_NAME/"
strip -x "$DIR_NAME/$BINARY_NAME"
# --- Create Archives ---
FULL_ARCHIVE="${FULL_DIR_NAME}.zip"
STD_ARCHIVE="${DIR_NAME}.zip"
zip -r "$FULL_ARCHIVE" "$FULL_DIR_NAME"
zip -r "$STD_ARCHIVE" "$DIR_NAME"
# --- Sign Archives ---
if [[ "$HAS_GPG" == "true" ]]; then
gpg --batch --yes --detach-sign --armor --output "${FULL_ARCHIVE}.sig" "$FULL_ARCHIVE"
gpg --batch --yes --detach-sign --armor --output "${STD_ARCHIVE}.sig" "$STD_ARCHIVE"
fi
# --- Organize for Upload ---
mkdir dist
mv "${FULL_ARCHIVE}" "${STD_ARCHIVE}" dist/
if [[ "$HAS_GPG" == "true" ]]; then
mv "${FULL_ARCHIVE}.sig" "${STD_ARCHIVE}.sig" dist/
fi
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: artifacts-universal
path: dist/*
create-release:
name: Create GitHub Release
needs: [build-matrix, build-mac-universal]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download All Artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
merge-multiple: true
- name: Include Public Key
if: env.HAS_PUBLIC_KEY == 'true'
run: |
# Write the variable content to a file in the assets folder
echo "${{ vars.GPG_PUBLIC_KEY }}" > release-assets/public.key
- name: Install parse-changelog
uses: taiki-e/install-action@v2
with:
tool: parse-changelog
- name: Generate Release Notes
id: notes
shell: bash
run: |
# Try to parse the changelog.
# 2>/dev/null suppresses error messages if not found.
if parse-changelog CHANGELOG.md "${{ github.ref_name }}" > release_notes.md 2>/dev/null; then
echo "✅ Found entry in CHANGELOG.md"
# --- Append Compare Link (Only if using custom changelog) ---
CURRENT_TAG="${{ github.ref_name }}"
PREV_TAG=$(git describe --abbrev=0 --tags "${CURRENT_TAG}^" 2>/dev/null || true)
if [ -n "$PREV_TAG" ]; then
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
echo -e "\n\n---" >> release_notes.md
echo "🆚 [Compare changes](${REPO_URL}/compare/${PREV_TAG}...${CURRENT_TAG})" >> release_notes.md
fi
# Set output to true
echo "use_changelog=true" >> $GITHUB_OUTPUT
else
echo "⚠️ No entry found in CHANGELOG.md for ${{ github.ref_name }}"
echo "Falling back to GitHub auto-generated notes."
# Set output to false
echo "use_changelog=false" >> $GITHUB_OUTPUT
fi
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
body_path: ${{ steps.notes.outputs.use_changelog == 'true' && 'release_notes.md' || '' }}
generate_release_notes: ${{ steps.notes.outputs.use_changelog != 'true' }}
files: release-assets/*
draft: true
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}