# Per-repo release workflow — tagged releases only
# Generated by machines/clean-room/deploy-workflows.sh — do not edit manually.
# Spec: docs/specifications/release-system.md
#
# Flow: tag push → create draft release → unified gate → verify → publish
# → cross-compiled binaries (4 Linux targets) → finalize release
#
# Tag formats:
# v1.0.0 — single-crate repos
# v-<crate>-1.0.0 — workspace repos (e.g. v-apr-cli-0.4.0)
#
# IMPORTANT: Tags must be pushed with a PAT or deploy key (not GITHUB_TOKEN),
# otherwise this workflow will not trigger (GitHub anti-recursion measure).
#
# IMPORTANT: All actions pinned to commit SHAs (CVE-2025-30066 mitigation).
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write # create GitHub Release + upload assets
id-token: write # OIDC for crates.io Trusted Publishing
# One release at a time per repo
concurrency:
group: release-${{ github.repository }}
cancel-in-progress: false
jobs:
# ── Create draft release (runs first, before matrix) ───
# Ripgrep pattern: create release once, then matrix jobs upload to it.
# Draft prevents users from seeing incomplete releases.
create-release:
runs-on: [self-hosted, clean-room]
outputs:
version: ${{ steps.parse.outputs.version }}
crate_name: ${{ steps.parse.outputs.crate_name }}
has_binaries: ${{ steps.bincheck.outputs.has_binaries }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Parse tag and verify version
id: parse
run: |
TAG="${GITHUB_REF_NAME}"
# Parse tag format: v1.0.0 or v-cratename-1.0.0
if [[ "$TAG" =~ ^v-([a-z][a-z0-9_-]*)-([0-9]+\..+)$ ]]; then
CRATE_NAME="${BASH_REMATCH[1]}"
TAG_VER="${BASH_REMATCH[2]}"
echo "Workspace release: crate=$CRATE_NAME version=$TAG_VER"
elif [[ "$TAG" =~ ^v([0-9]+\..+)$ ]]; then
CRATE_NAME=""
TAG_VER="${BASH_REMATCH[1]}"
echo "Single-crate release: version=$TAG_VER"
else
echo "::error::Tag '$TAG' does not match expected format (v1.0.0 or v-crate-1.0.0)"
exit 1
fi
# Use cargo metadata for reliable version extraction
if [ -n "$CRATE_NAME" ]; then
CARGO_VER=$(cargo metadata --format-version 1 --no-deps \
| jq -r ".packages[] | select(.name == \"$CRATE_NAME\") | .version")
if [ -z "$CARGO_VER" ] || [ "$CARGO_VER" = "null" ]; then
echo "::error::Crate '$CRATE_NAME' not found in workspace"
exit 1
fi
else
CARGO_VER=$(cargo metadata --format-version 1 --no-deps \
| jq -r '.packages[0].version')
fi
if [ "$TAG_VER" != "$CARGO_VER" ]; then
echo "::error::Tag version $TAG_VER != Cargo.toml version $CARGO_VER"
exit 1
fi
echo "crate_name=$CRATE_NAME" >> "$GITHUB_OUTPUT"
echo "version=$TAG_VER" >> "$GITHUB_OUTPUT"
echo "Version verified: $TAG_VER"
- name: Detect binary targets
id: bincheck
run: |
HAS_BINS=$(cargo metadata --format-version 1 --no-deps \
| jq '[.packages[].targets[] | select(.kind[] == "bin")] | length')
echo "has_binaries=$( [ "$HAS_BINS" -gt 0 ] && echo true || echo false )" >> "$GITHUB_OUTPUT"
echo "Binary targets found: $HAS_BINS"
- name: Create draft GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" \
--draft \
--verify-tag \
--title "$GITHUB_REF_NAME" \
--generate-notes
# ── Gate: unified CI must pass before publish ──────────
gate:
needs: create-release
uses: paiml/.github/.github/workflows/unified-gate.yml@main
with:
repo: ${{ github.event.repository.name }}
pr_sha: ${{ github.sha }}
secrets: inherit
# ── Verify: package tarball integrity ──────────────────
verify:
needs: [create-release, gate]
runs-on: [self-hosted, clean-room]
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Verify package tarball
run: |
CRATE="${{ needs.create-release.outputs.crate_name }}"
# Verification is default; modern cargo removed --verify (only --no-verify exists).
if [ -n "$CRATE" ]; then
cargo package -p "$CRATE"
else
cargo package
fi
# ── Publish: OIDC trusted publishing to crates.io ──────
publish:
needs: [create-release, verify]
runs-on: [self-hosted, clean-room]
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Authenticate to crates.io (OIDC)
uses: rust-lang/crates-io-auth-action@b7e9a28eded4986ec6b1fa40eeee8f8f165559ec # v1.0.3
- name: Publish
run: |
CRATE="${{ needs.create-release.outputs.crate_name }}"
if [ -n "$CRATE" ]; then
cargo publish -p "$CRATE"
else
cargo publish
fi
# ── Build cross-compiled binaries (4 Linux targets) ────
# Best-effort: binary build failures do NOT fail the release.
# crates.io publish is the source of truth.
build-binaries:
needs: [create-release, publish]
if: needs.create-release.outputs.has_binaries == 'true'
runs-on: [self-hosted, clean-room]
strategy:
fail-fast: false
matrix:
target:
- x86_64-unknown-linux-gnu
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-gnu
- aarch64-unknown-linux-musl
env:
CARGO: cargo
# Pin cross version — new releases have broken CI in the past (ripgrep lesson)
CROSS_VERSION: v0.2.5
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install target prerequisites
run: |
case "${{ matrix.target }}" in
x86_64-*-musl)
sudo apt-get update -qq
sudo apt-get install -y -qq musl-tools >/dev/null
rustup target add "${{ matrix.target }}"
;;
aarch64-*)
# Use pinned cross binary for ARM targets (no cargo install — too slow)
dir="$RUNNER_TEMP/cross-download"
mkdir -p "$dir"
echo "$dir" >> "$GITHUB_PATH"
curl -sL "https://github.com/cross-rs/cross/releases/download/$CROSS_VERSION/cross-x86_64-unknown-linux-musl.tar.gz" \
| tar xz -C "$dir"
echo "CARGO=cross" >> "$GITHUB_ENV"
;;
esac
- name: Discover binary names and features
id: bins
run: |
BINS=$(cargo metadata --format-version 1 --no-deps \
| jq -r '[.packages[].targets[] | select(.kind[] == "bin") | .name] | join(" ")')
echo "names=$BINS" >> "$GITHUB_OUTPUT"
echo "Binaries to build: $BINS"
# Detect features needed for binary builds:
# 1. .release.toml (explicit override) takes precedence
# 2. Otherwise, auto-detect required-features from bin targets
if [ -f .release.toml ] && grep -q 'features' .release.toml; then
FEATURES=$(grep '^features' .release.toml | sed 's/.*\[//;s/\].*//;s/"//g;s/ //g')
else
FEATURES=$(cargo metadata --format-version 1 --no-deps \
| jq -r '[.packages[].targets[] | select(.kind[] == "bin") | (."required-features" // [])[] ] | unique | join(",")')
fi
if [ -n "$FEATURES" ]; then
echo "feature_flags=--features $FEATURES" >> "$GITHUB_OUTPUT"
echo "Build features: $FEATURES"
else
echo "feature_flags=" >> "$GITHUB_OUTPUT"
echo "Build features: (default)"
fi
- name: Build release binaries (release-lto profile)
run: |
$CARGO build --profile release-lto --target "${{ matrix.target }}" ${{ steps.bins.outputs.feature_flags }}
- name: Strip binaries
run: |
TARGET="${{ matrix.target }}"
for BIN in ${{ steps.bins.outputs.names }}; do
BIN_PATH="target/${TARGET}/release-lto/$BIN"
case "$TARGET" in
aarch64-*)
# Strip via cross Docker image with target-appropriate strip tool
docker run --rm -v "$PWD/target:/target:Z" \
"ghcr.io/cross-rs/${TARGET}:main" \
aarch64-linux-gnu-strip "/$BIN_PATH"
;;
*)
strip "$BIN_PATH"
;;
esac
done
- name: Package archives
run: |
VERSION="${{ needs.create-release.outputs.version }}"
TARGET="${{ matrix.target }}"
for BIN in ${{ steps.bins.outputs.names }}; do
ARCHIVE="${BIN}-${VERSION}-${TARGET}"
mkdir -p "$ARCHIVE"
cp "target/${TARGET}/release-lto/$BIN" "$ARCHIVE/"
# Include license and readme if present
for f in README.md LICENSE LICENSE-MIT COPYING UNLICENSE; do
[ -f "$f" ] && cp "$f" "$ARCHIVE/"
done
# tar.gz archive + per-archive sha256 (ripgrep pattern)
tar czf "${ARCHIVE}.tar.gz" "$ARCHIVE"
shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256"
echo "Packaged: ${ARCHIVE}.tar.gz ($(du -h "${ARCHIVE}.tar.gz" | cut -f1))"
done
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$GITHUB_REF_NAME" \
*.tar.gz *.sha256 \
--clobber
# ── Finalize: mark release as non-draft ────────────────
# Only runs after publish succeeds. Binary build failures
# do not block finalization — crates.io is the source of truth.
publish-release:
needs: [create-release, publish, build-binaries]
if: always() && needs.publish.result == 'success'
runs-on: [self-hosted, clean-room]
steps:
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release edit "$GITHUB_REF_NAME" --draft=false
- name: Write summary
if: always()
run: |
VERSION="${{ needs.create-release.outputs.version }}"
echo "### Release $GITHUB_REF_NAME" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- crates.io: **published** ($VERSION)" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ needs.build-binaries.result }}" = "success" ]; then
echo "- Binaries: **4/4 targets uploaded**" >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ needs.build-binaries.result }}" = "skipped" ]; then
echo "- Binaries: skipped (library-only crate)" >> "$GITHUB_STEP_SUMMARY"
else
echo "- Binaries: **partial** (some targets failed, best-effort)" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- GitHub Release: **published**" >> "$GITHUB_STEP_SUMMARY"