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
dry_run:
description: >-
Rehearse the release from source_ref (or the dispatch ref): build, SBOM, and cargo publish --dry-run, without creating a tag or
GitHub Release, attaching release assets, or publishing. Build and SBOM workflow artifacts are kept for inspection. version must match the source's Cargo.toml. Prerelease versions are never
published to crates.io, so their rehearsal skips the crates.io jobs as a real run does.
required: false
type: boolean
default: false
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'release-dry-run' || format('release-{0}', github.ref) }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
RUST_VERSION: 1.100.0
RUSTUP_TOOLCHAIN: 1.100.0
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 || '' }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }}
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 }}
release_revision: ${{ steps.vetted_source.outputs.sha }}
steps:
- name: Checkout trusted release tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
ref: ${{ github.sha }}
persist-credentials: false
- 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: Require a source in main history
id: vetted_source
env:
SOURCE_REF: ${{ steps.source_ref.outputs.source_ref }}
run: |
set -euo pipefail
git fetch --no-recurse-submodules origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*'
revision="$(python3 scripts/check_release_ref.py "${SOURCE_REF}")"
echo "sha=${revision}" >> "$GITHUB_OUTPUT"
- name: Checkout verified release source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
ref: ${{ steps.vetted_source.outputs.sha }}
persist-credentials: false
- 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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
TAG="v${VERSION}"
TARGET_SHA="${{ steps.vetted_source.outputs.sha }}"
if [[ "${DRY_RUN}" == "true" ]]; then
echo "Dry run: building ${TARGET_SHA} as ${TAG} without creating or checking the tag"
echo "source_ref=${TARGET_SHA}" >> "$GITHUB_OUTPUT"
exit 0
fi
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
tag_object="$(gh api --method POST "repos/${GITHUB_REPOSITORY}/git/tags" \
-f tag="${TAG}" -f message="Release ${TAG}" -f object="${TARGET_SHA}" \
-f type=commit --jq .sha)"
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/tags/${TAG}" -f sha="${tag_object}"
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.release_revision }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.target }}
- name: Verify selected compiler
run: |
rustup show active-toolchain
rustc --version --verbose
cargo --version
- 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 --locked --version 0.2.5
- name: Build (cross)
if: matrix.use_cross
run: |
if [[ -n "${BINARY_PACKAGE}" ]]; then
cross build --locked --release --target ${{ matrix.target }} -p "${BINARY_PACKAGE}" --bin "${BINARY_NAME}" --all-features
else
cross build --locked --release --target ${{ matrix.target }} --bin "${BINARY_NAME}" --all-features
fi
- name: Build (native)
if: "!matrix.use_cross"
shell: bash
run: |
if [[ -n "${BINARY_PACKAGE}" ]]; then
cargo build --locked --release --target ${{ matrix.target }} -p "${BINARY_PACKAGE}" --bin "${BINARY_NAME}" --all-features
else
cargo build --locked --release --target ${{ matrix.target }} --bin "${BINARY_NAME}" --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.ToLowerInvariant()
# The "<hash> <file>" line shasum writes, with an LF ending, so
# `shasum -a 256 -c` and `sha256sum -c` verify it on any platform.
# (Out-File ends the line with CRLF, and the stray CR makes those
# tools look for a file named "<archive>\r".)
[System.IO.File]::WriteAllText((Join-Path $PWD "$archive.sha256"), "$hash $archive`n")
Get-Content "$archive.sha256"
- name: Upload artifacts (Unix)
if: runner.os != 'Windows'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with:
name: release-${{ matrix.artifact }}
path: "*.tar.gz*"
retention-days: 5
- name: Upload artifacts (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 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.release_revision }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 with:
toolchain: ${{ env.RUST_VERSION }}
- name: Verify selected compiler
run: |
rustup show active-toolchain
rustc --version --verbose
cargo --version
- name: Install cargo-cyclonedx
run: cargo install --locked --version 0.5.10 cargo-cyclonedx
- 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with:
name: release-sbom
path: sbom/*.json
retention-days: 5
upload-assets:
name: Upload Release Assets
needs: [prepare, build, sbom]
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.prepare.outputs.release_revision }}
persist-credentials: false
- name: Generate release notes
id: notes
run: |
VERSION="${{ needs.prepare.outputs.version }}"
echo "## Release v$VERSION" > release_notes.md
echo "" >> release_notes.md
# The section runs from this version's "## [X.Y.Z]" heading to the
# next "## [" heading. (A sed/awk range on "## [" ended on the start
# line itself, so it never extracted anything.)
section=""
for changelog in CHANGELOG.md docs/CHANGELOG.md; do
if [ -f "$changelog" ]; then
section="$(awk -v heading="## [$VERSION]" 'index($0, heading) == 1 { found = 1; next } found && /^## \[/ { exit } found { print }' "$changelog")"
if [[ -n "${section//[[:space:]]/}" ]]; then
break
fi
fi
done
printf '%s\n' "$section" >> release_notes.md
# Auto Release publishes the Release with notes generated from the
# conventional commits. Only replace them when the changelog has a
# section for this version; otherwise editing would leave the bare
# heading above, as it did for v0.7.5 and v0.7.6.
if [[ -n "${section//[[:space:]]/}" ]]; then
echo "has-changelog=true" >> "$GITHUB_OUTPUT"
else
echo "has-changelog=false" >> "$GITHUB_OUTPUT"
fi
- name: Create or update GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HAS_CHANGELOG: ${{ steps.notes.outputs.has-changelog }}
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
if [[ "${HAS_CHANGELOG}" == "true" ]]; then
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TITLE" --notes-file release_notes.md
else
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TITLE"
fi
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 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 }}
verify-crates:
name: Verify crates for crates.io
needs: [prepare, build]
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
if: ${{ !contains(needs.prepare.outputs.version, '-') && vars.RUST_TEMPLATE_PUBLISH_CRATES != 'false' }}
permissions:
contents: read
outputs:
pending: ${{ steps.packages.outputs.pending }}
env:
VERSION: ${{ needs.prepare.outputs.version }}
CRATES_IO_USER_AGENT: ThreatFlux release (https://github.com/${{ github.repository }})
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.prepare.outputs.release_revision }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 with:
toolchain: ${{ env.RUST_VERSION }}
- name: Verify selected compiler
run: |
rustup show active-toolchain
rustc --version --verbose
cargo --version
- name: Find the packages to publish
id: packages
run: |
set -euo pipefail
packages="${PUBLISH_PACKAGES}"
if [[ -z "${packages}" ]]; then
packages="$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["package"]["name"])')"
fi
pending=""
for package in ${packages}; do
status="$(curl -sS -o /dev/null -w '%{http_code}' -A "${CRATES_IO_USER_AGENT}" \
"https://crates.io/api/v1/crates/${package}/${VERSION}")"
case "${status}" in
200) echo "${package} ${VERSION} is already on crates.io; skipping it" ;;
404) pending="${pending:+${pending} }${package}" ;;
*) echo "crates.io answered HTTP ${status} for ${package} ${VERSION}" >&2; exit 1 ;;
esac
done
{
echo "packages=${packages}"
echo "pending=${pending}"
} >> "$GITHUB_OUTPUT"
echo "packages to publish: ${pending:-none}"
- name: Package and verify for crates.io
if: env.DRY_RUN == 'true' || steps.packages.outputs.pending != ''
env:
SELECTED: ${{ env.DRY_RUN == 'true' && steps.packages.outputs.packages || steps.packages.outputs.pending }}
run: |
set -euo pipefail
if [[ -n "${PUBLISH_PACKAGES}" ]]; then
selection=()
for package in ${SELECTED}; do
selection+=(-p "${package}")
done
cargo publish --dry-run --locked "${selection[@]}"
else
cargo publish --dry-run --locked
fi
if [[ "${DRY_RUN}" == "true" ]]; then
echo "Dry run: nothing was published; a real run authenticates with crates.io trusted publishing"
fi
publish:
name: Publish to crates.io
needs: [prepare, verify-crates]
runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && needs.verify-crates.outputs.pending != '' }}
environment:
name: crates-io
permissions:
contents: read
id-token: write env:
VERSION: ${{ needs.prepare.outputs.version }}
CRATES_IO_USER_AGENT: ThreatFlux release (https://github.com/${{ github.repository }})
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.prepare.outputs.release_revision }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 with:
toolchain: ${{ env.RUST_VERSION }}
- name: Authenticate with crates.io (trusted publishing)
id: crates-io
uses: rust-lang/crates-io-auth-action@3268eb413133e2960fa4ae0e963ca76380c54cd9
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-io.outputs.token }}
PENDING: ${{ needs.verify-crates.outputs.pending }}
run: |
set -euo pipefail
version_visible() {
[[ "$(curl -sS -o /dev/null -w '%{http_code}' -A "${CRATES_IO_USER_AGENT}" \
"https://crates.io/api/v1/crates/$1/${VERSION}")" == 200 ]]
}
for package in ${PENDING}; do
if [[ -n "${PUBLISH_PACKAGES}" ]]; then
cargo publish --locked --no-verify -p "${package}"
else
cargo publish --locked --no-verify
fi
# Later packages in PUBLISH_PACKAGES may depend on this one, so
# wait until crates.io serves it.
for _ in $(seq 1 20); do
if version_visible "${package}"; then
echo "${package} ${VERSION} is live on crates.io"
continue 2
fi
sleep 15
done
echo "${package} ${VERSION} was published but crates.io did not serve it within 5 minutes" >&2
exit 1
done