name: Release
on:
push:
tags:
- v*
concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
id-token: write
attestations: write
env:
CARGO_TERM_COLOR: always
jobs:
meta:
name: Resolve Release Metadata
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
tag: ${{ steps.meta.outputs.tag }}
is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Parse tag and validate
id: meta
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "tag $TAG does not match supported format vX.Y.Z or vX.Y.Z-rc.N" >&2
exit 1
fi
VERSION="${TAG#v}"
CARGO_VERSION="$(awk -F'"' '/^\[package\]/{p=1} p && /^version = /{print $2; exit}' Cargo.toml)"
if [[ "$VERSION" != "$CARGO_VERSION" ]]; then
echo "tag version $VERSION must match Cargo.toml version $CARGO_VERSION" >&2
exit 1
fi
IS_PRERELEASE=false
if [[ "$VERSION" == *-* ]]; then
IS_PRERELEASE=true
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
build-artifacts:
name: Build ${{ matrix.target }}
needs: meta
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
target: aarch64-apple-darwin
build_tool: cargo
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
build_tool: cargo
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
build_tool: cargo
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
build_tool: cargo
- os: ubuntu-latest
target: x86_64-pc-windows-msvc
build_tool: xwin
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Install cross-compilation toolchain (aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
- name: Install cross-compilation toolchain (musl)
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Install cargo-xwin and LLVM
if: matrix.build_tool == 'xwin'
shell: bash
run: |
sudo apt-get update && sudo apt-get install -y llvm
cargo install cargo-xwin --locked
- name: Build and package
shell: bash
run: |
scripts/release/build-artifact.sh \
--target "${{ matrix.target }}" \
--version "${{ needs.meta.outputs.version }}" \
--build-tool "${{ matrix.build_tool }}" \
--output-dir dist
- name: Upload artifact archive
uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.target }}
path: dist/*
release-assets:
name: Assemble Release Assets
needs:
- meta
- build-artifacts
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download binary archives
uses: actions/download-artifact@v4
with:
pattern: build-*
path: dist
merge-multiple: true
- name: Generate checksums
shell: bash
run: scripts/release/generate-checksums.sh dist
- name: Upload complete release assets
uses: actions/upload-artifact@v4
with:
name: release-assets
path: dist/*
create-release:
name: Publish GitHub Release
needs:
- meta
- release-assets
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download release assets
uses: actions/download-artifact@v4
with:
name: release-assets
path: dist
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.meta.outputs.tag }}
name: ${{ needs.meta.outputs.tag }}
generate_release_notes: true
prerelease: ${{ needs.meta.outputs.is_prerelease == 'true' }}
files: dist/*
fail_on_unmatched_files: true
make_latest: ${{ needs.meta.outputs.is_prerelease == 'false' }}
- name: Trigger release notes workflow
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
gh workflow run release-notes.yml \
--field tag="${{ needs.meta.outputs.tag }}"
attest-provenance:
name: Attest Build Provenance
needs:
- create-release
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
contents: read
steps:
- name: Download release assets
uses: actions/download-artifact@v4
with:
name: release-assets
path: dist
- name: Attest release assets
uses: actions/attest-build-provenance@v2
with:
subject-path: |
dist/*
publish-crate:
name: Publish to crates.io
needs:
- meta
- create-release
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Authenticate with crates.io
uses: rust-lang/crates-io-auth-action@v1
id: crates-io-auth
- name: Publish workspace crates
shell: bash
env:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }}
run: |
set -euo pipefail
# Publish in dependency order: earl-core first, then protocol
# crates, and finally the root earl crate.
CRATES=(
earl-core
earl-protocol-http
earl-protocol-grpc
earl-protocol-bash
earl-protocol-sql
earl-protocol-browser
earl
)
for crate in "${CRATES[@]}"; do
echo "Publishing $crate..."
output=$(cargo publish --locked -p "$crate" 2>&1) && echo "$output" || {
if echo "$output" | grep -q "already exists on crates.io index"; then
echo "$crate already published, skipping."
else
echo "$output" >&2
exit 1
fi
}
# Wait for crates.io index to update before publishing
# dependents (skip delay after the last crate).
if [[ "$crate" != "earl" ]]; then
sleep 30
fi
done