name: Release
on:
push:
tags:
- "v*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
permissions:
contents: read
jobs:
meta:
name: Resolve Release Metadata
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
tag: ${{ steps.meta.outputs.tag }}
is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Parse tag and validate
id: meta
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
CARGO_VERSION="$(awk -F'"' '/^\[package\]/{p=1} p && /^version = /{print $2; exit}' Cargo.toml)"
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
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"
echo "version=$VERSION"
echo "is_prerelease=$IS_PRERELEASE"
} >> "$GITHUB_OUTPUT"
build-and-test:
name: Build and test
needs: meta
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Install system dependencies
run: sudo apt-get update -y && sudo apt-get install -y libdbus-1-dev bubblewrap
- name: Allow bwrap unprivileged user namespaces
run: |
echo 'abi <abi/4.0>,' | sudo tee /etc/apparmor.d/bwrap
echo 'include <tunables/global>' | sudo tee -a /etc/apparmor.d/bwrap
echo 'profile bwrap /usr/bin/bwrap flags=(unconfined) {' | sudo tee -a /etc/apparmor.d/bwrap
echo ' userns,' | sudo tee -a /etc/apparmor.d/bwrap
echo '}' | sudo tee -a /etc/apparmor.d/bwrap
sudo apparmor_parser -r /etc/apparmor.d/bwrap
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version-file: .node-version
package-manager-cache: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
- name: Build and test
run: cargo test --locked --all-targets --all-features
build-artifacts:
name: Build ${{ matrix.target }}
needs: meta
runs-on: ${{ matrix.os }}
timeout-minutes: 60
permissions:
contents: read
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@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version-file: .node-version
package-manager-cache: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c with:
targets: ${{ matrix.target }}
- 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: build-${{ matrix.target }}
path: dist/*
release-assets:
name: Assemble Release Assets
needs:
- meta
- build-artifacts
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Download binary archives
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-assets
path: dist/*
create-release:
name: Publish GitHub Release
needs:
- meta
- build-and-test
- release-assets
runs-on: ubuntu-latest
timeout-minutes: 10
environment: release
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Download release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-assets
path: dist
- name: Create GitHub release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 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:
environment: release
name: Attest Build Provenance
needs:
- meta
- create-release
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
id-token: write
attestations: write
contents: read
steps:
- name: Download release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-assets
path: dist
- name: Attest release assets
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 with:
subject-path: |
dist/*
publish-crate:
name: Publish to crates.io
needs:
- meta
- create-release
runs-on: ubuntu-latest
timeout-minutes: 60
environment: release
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version-file: .node-version
package-manager-cache: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
- name: Verify publish environment
run: pnpm --version
- name: Authenticate with crates.io
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 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..."
if output=$(cargo publish --locked -p "$crate" 2>&1); then
echo "$output"
elif grep -q "already exists on crates.io index" <<< "$output"; 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
component-releases:
name: Publish Component Releases
needs:
- meta
- publish-crate
runs-on: ubuntu-latest
timeout-minutes: 10
environment: release
permissions:
contents: write
steps:
- name: Create component releases
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.meta.outputs.version }}
run: |
set -euo pipefail
CRATES=(
earl-core
earl-protocol-http
earl-protocol-grpc
earl-protocol-bash
earl-protocol-sql
earl-protocol-browser
)
for crate in "${CRATES[@]}"; do
tag="$crate-v$VERSION"
if ! gh release view "$tag" >/dev/null 2>&1; then
gh release create "$tag" \
--generate-notes \
--latest=false \
--target "$GITHUB_SHA" \
--title "$tag"
fi
actual="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq .object.sha)"
if [[ "$actual" != "$GITHUB_SHA" ]]; then
echo "$tag points to $actual instead of $GITHUB_SHA" >&2
exit 1
fi
done