name: Release
permissions:
contents: read
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
version:
description: Version to release (e.g. 1.0.0)
required: true
type: string
source_ref:
description: Git ref to build from
required: false
type: string
prerelease:
description: Is this a pre-release?
required: false
type: boolean
default: false
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
RUST_VERSION: 1.97.1
BINARY_NAME: ${{ vars.RUST_TEMPLATE_BINARY_NAME || 'github-actions-maintainer' }}
BINARY_PACKAGE: ${{ vars.RUST_TEMPLATE_BINARY_PACKAGE || '' }}
SBOM_MANIFEST_PATH: ${{ vars.RUST_TEMPLATE_SBOM_MANIFEST_PATH || 'Cargo.toml' }}
PUBLISH_PACKAGES: ${{ vars.RUST_TEMPLATE_PUBLISH_PACKAGES || '' }}
SEMVER_PATTERN: '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?$'
jobs:
prepare:
name: Prepare Release
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.version }}
source_ref: ${{ steps.release_ref.outputs.source_ref }}
steps:
- name: Determine source ref
id: source_ref
env:
EVENT_NAME: ${{ github.event_name }}
REQUESTED_SOURCE_REF: ${{ github.event.inputs.source_ref }}
run: |
if [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${REQUESTED_SOURCE_REF}" ]]; then
SOURCE_REF="${REQUESTED_SOURCE_REF}"
else
SOURCE_REF="${GITHUB_REF}"
fi
if ! git check-ref-format --branch "${SOURCE_REF}" >/dev/null; then
echo "Invalid source ref: ${SOURCE_REF}" >&2
exit 1
fi
echo "source_ref=${SOURCE_REF}" >> "$GITHUB_OUTPUT"
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
ref: ${{ steps.source_ref.outputs.source_ref }}
- name: Determine version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
REQUESTED_VERSION: ${{ github.event.inputs.version }}
run: |
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
VERSION="${REQUESTED_VERSION}"
else
VERSION=${GITHUB_REF#refs/tags/v}
fi
if ! [[ "${VERSION}" =~ ${SEMVER_PATTERN} ]]; then
echo "Invalid version format: ${VERSION}" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate version format
run: |
VERSION="${{ steps.version.outputs.version }}"
if ! [[ "$VERSION" =~ ${SEMVER_PATTERN} ]]; then
echo "Invalid version format: $VERSION"
exit 1
fi
- name: Validate manifest version
run: |
VERSION="${{ steps.version.outputs.version }}"
CURRENT_VERSION="$(python3 scripts/release_version.py current)"
if [[ "${CURRENT_VERSION}" != "${VERSION}" ]]; then
echo "Cargo manifest version ${CURRENT_VERSION} does not match release version ${VERSION}"
exit 1
fi
- name: Ensure release tag
id: release_ref
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
TAG="v${VERSION}"
TARGET_REF="${{ steps.source_ref.outputs.source_ref }}"
TARGET_SHA="$(git rev-parse "${TARGET_REF}^{commit}")"
if git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null; then
TAG_SHA="$(git rev-parse "${TAG}^{commit}")"
if [[ "${TAG_SHA}" != "${TARGET_SHA}" ]]; then
echo "Existing tag ${TAG} points to ${TAG_SHA}, expected ${TARGET_SHA}"
exit 1
fi
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
git tag -a "${TAG}" "${TARGET_SHA}" -m "Release ${TAG}"
git push origin "refs/tags/${TAG}"
else
echo "Missing expected release tag ${TAG}"
exit 1
fi
echo "source_ref=refs/tags/${TAG}" >> "$GITHUB_OUTPUT"
build:
name: Build (${{ matrix.target }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- os: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
target: x86_64-unknown-linux-gnu
artifact: linux-amd64
- os: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
target: x86_64-unknown-linux-musl
artifact: linux-musl-amd64
use_cross: true
- os: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
target: aarch64-unknown-linux-gnu
artifact: linux-arm64
use_cross: true
- os: ${{ vars.RUST_TEMPLATE_RUNNER_MACOS_ARM64 || 'macos-15' }}
target: aarch64-apple-darwin
artifact: macos-arm64
- os: ${{ vars.RUST_TEMPLATE_RUNNER_MACOS_X64 || 'macos-15-intel' }}
target: x86_64-apple-darwin
artifact: macos-amd64
- os: ${{ vars.RUST_TEMPLATE_RUNNER_WINDOWS || 'windows-latest' }}
target: x86_64-pc-windows-msvc
artifact: windows-amd64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.prepare.outputs.source_ref }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu)
if: contains(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libssl-dev pkg-config
- name: Install cross
if: matrix.use_cross
run: cargo install cross
- name: Build (cross)
if: matrix.use_cross
run: |
if [[ -n "${BINARY_PACKAGE}" ]]; then
cross build --release --target ${{ matrix.target }} -p "${BINARY_PACKAGE}" --bin "${BINARY_NAME}" --all-features
else
cross build --release --target ${{ matrix.target }} --bin "${BINARY_NAME}" --all-features || cross build --release --target ${{ matrix.target }} --all-features
fi
- name: Build (native)
if: "!matrix.use_cross"
shell: bash
run: |
if [[ -n "${BINARY_PACKAGE}" ]]; then
cargo build --release --target ${{ matrix.target }} -p "${BINARY_PACKAGE}" --bin "${BINARY_NAME}" --all-features
else
cargo build --release --target ${{ matrix.target }} --bin "${BINARY_NAME}" --all-features || cargo build --release --target ${{ matrix.target }} --all-features
fi
- name: Package (Unix)
if: runner.os != 'Windows'
run: |
mkdir -p dist
cp target/${{ matrix.target }}/release/${BINARY_NAME} dist/
cd dist
tar czf ../${BINARY_NAME}-${{ matrix.artifact }}.tar.gz *
cd ..
shasum -a 256 ${BINARY_NAME}-${{ matrix.artifact }}.tar.gz > ${BINARY_NAME}-${{ matrix.artifact }}.tar.gz.sha256
- name: Package (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
mkdir dist
Copy-Item "target/${{ matrix.target }}/release/${env:BINARY_NAME}.exe" dist/
$archive = "${env:BINARY_NAME}-${{ matrix.artifact }}.zip"
Compress-Archive -Path dist/* -DestinationPath $archive
$hash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLower()
"$hash $archive" | Out-File -Encoding ascii "$archive.sha256"
- name: Upload artifacts (Unix)
if: runner.os != 'Windows'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-${{ matrix.artifact }}
path: "*.tar.gz*"
retention-days: 5
- name: Upload artifacts (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-${{ matrix.artifact }}
path: "*.zip*"
retention-days: 5
sbom:
name: Generate Release SBOMs
needs: prepare
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.prepare.outputs.source_ref }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with:
toolchain: ${{ env.RUST_VERSION }}
- name: Install cargo-cyclonedx
uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad with:
tool: cargo-cyclonedx@0.5.8
- name: Generate SBOM files
run: |
make sbom BINARY_NAME="${BINARY_NAME}" SBOM_MANIFEST_PATH="${SBOM_MANIFEST_PATH}"
VERSION="${{ needs.prepare.outputs.version }}"
mv "sbom/${BINARY_NAME}-sbom.json" "sbom/${BINARY_NAME}-v${VERSION}.cdx.json"
- name: Upload SBOM artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-sbom
path: sbom/*.json
retention-days: 5
upload-assets:
name: Upload Release Assets
needs: [prepare, build, sbom]
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.prepare.outputs.source_ref }}
persist-credentials: false
- name: Generate release notes
run: |
VERSION="${{ needs.prepare.outputs.version }}"
echo "## Release v$VERSION" > release_notes.md
echo "" >> release_notes.md
if [ -f CHANGELOG.md ]; then
awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '1d;$d' >> release_notes.md 2>/dev/null || echo "See CHANGELOG.md for details" >> release_notes.md
fi
- name: Create or update GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.prepare.outputs.version }}"
TAG="v${VERSION}"
TITLE="Release v${VERSION}"
IS_PRERELEASE="false"
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.prerelease && 'true' || 'false' }}" == "true" ]]; then
IS_PRERELEASE="true"
fi
if [[ "${VERSION}" == *-* ]]; then
IS_PRERELEASE="true"
fi
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TITLE" --notes-file release_notes.md
else
if [[ "${IS_PRERELEASE}" == "true" ]]; then
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TITLE" --notes-file release_notes.md --prerelease
else
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TITLE" --notes-file release_notes.md
fi
fi
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
path: artifacts
pattern: release-*
- name: Upload to release
run: |
for file in artifacts/**/*; do
if [ -f "$file" ]; then
gh release upload "v${{ needs.prepare.outputs.version }}" "$file" --clobber
fi
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish:
name: Publish to crates.io
needs: [prepare, build]
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
if: "!contains(needs.prepare.outputs.version, '-')"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.prepare.outputs.source_ref }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with:
toolchain: ${{ env.RUST_VERSION }}
- name: Check crates.io token
id: check_token
run: |
TOKEN="${{ secrets.CRATES_IO_TOKEN || secrets.CARGO_REGISTRY_TOKEN }}"
if [ -n "$TOKEN" ]; then
echo "has_token=true" >> "$GITHUB_OUTPUT"
else
echo "has_token=false" >> "$GITHUB_OUTPUT"
echo "Skipping crates.io publish - token not configured"
fi
- name: Publish to crates.io
if: steps.check_token.outputs.has_token == 'true'
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN || secrets.CARGO_REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ needs.prepare.outputs.version }}"
# Wait until crates.io serves the published version so dependent
# packages in PUBLISH_PACKAGES can resolve it.
version_visible() {
curl -fsSL "https://crates.io/api/v1/crates/$1" \
| python3 -c 'import json, sys; payload = json.load(sys.stdin); raise SystemExit(0 if any(v["num"] == sys.argv[1] for v in payload.get("versions", [])) else 1)' "$VERSION"
}
if [[ -n "${PUBLISH_PACKAGES}" ]]; then
for package in ${PUBLISH_PACKAGES}; do
cargo publish -p "${package}" --locked
for _ in $(seq 1 20); do
if version_visible "${package}"; then
break
fi
sleep 15
done
done
else
cargo publish --locked
fi