name: release
on:
push:
branches: [main]
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Existing tag to retroactively build + attach binaries to (e.g. v0.2.0)"
required: true
type: string
evidence_only:
description: "Only regenerate and attest release evidence for an existing tag"
required: false
default: false
type: boolean
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
release-please:
name: release-please
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
permissions:
actions: write contents: write
pull-requests: write
statuses: write
steps:
- name: Report merged release PRs left untagged
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
repo="${GITHUB_REPOSITORY}"
owner="${repo%%/*}"
# REST (not search): the search index lags right after a merge — the
# post-merge run fired 10s after merging #75 and search still saw
# nothing pending. The pulls list endpoint is immediately consistent.
pending="$(
gh api "repos/${repo}/pulls?state=closed&base=main&head=${owner}:release-please--branches--main&per_page=10" \
--jq '[ .[] | select(.merged_at != null)
| select([.labels[].name] | index("autorelease: pending")) ]
| sort_by(.merged_at) | reverse | .[0] // empty'
)"
if [[ -z "${pending}" ]]; then
echo "No merged pending release PR to recover."
exit 0
fi
pr_number="$(jq -r '.number' <<< "${pending}")"
pr_title="$(jq -r '.title' <<< "${pending}")"
pr_sha="$(jq -r '.merge_commit_sha // empty' <<< "${pending}")"
if [[ -z "${pr_sha}" ]]; then
echo "::warning::Release PR #${pr_number} has no merge commit; skipping recovery."
exit 0
fi
# The default manifest PR title ('chore: release main') carries no
# version — read it from the manifest the release PR itself updated,
# at the merge commit. Title stays as a fallback for custom patterns.
version="$(
gh api "repos/${repo}/contents/.release-please-manifest.json?ref=${pr_sha}" \
--jq '.content' | base64 -d | jq -r '."." // empty'
)"
if [[ -z "${version}" && "${pr_title}" =~ ([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?) ]]; then
version="${BASH_REMATCH[1]}"
fi
if [[ -z "${version}" ]]; then
echo "::warning::Could not determine the version for release PR #${pr_number} (${pr_title})."
exit 0
fi
tag="v${version}"
if gh release view "${tag}" --repo "${repo}" >/dev/null 2>&1; then
echo "Release ${tag} already exists; marking PR #${pr_number} tagged."
gh pr edit "${pr_number}" --repo "${repo}" \
--remove-label 'autorelease: pending' \
--add-label 'autorelease: tagged'
exit 0
fi
if git ls-remote --exit-code --tags "https://github.com/${repo}.git" "refs/tags/${tag}" >/dev/null 2>&1; then
echo "Tag ${tag} exists but release does not yet — a build is presumably in flight; not dispatching again."
exit 0
fi
echo "::warning::Merged release PR #${pr_number} is untagged (${tag} at ${pr_sha}); self-healing."
# GITHUB_TOKEN can create the ref, but events from refs it creates do
# not trigger workflows (GitHub anti-recursion). So create the tag and
# dispatch the tag build explicitly, from the tag ref itself so release
# signatures verify against the right run identity.
gh api "repos/${repo}/git/refs" -f ref="refs/tags/${tag}" -f sha="${pr_sha}" >/dev/null
gh workflow run release.yml --repo "${repo}" --ref "${tag}" -f tag="${tag}"
echo "Self-heal: created ${tag} and dispatched the tag build (binaries + release + crate)."
exit 0
- id: release
uses: googleapis/release-please-action@v5
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ secrets.GITHUB_TOKEN }}
- name: Run CI on the open release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
required_contexts=(
"rustfmt"
"clippy (default)"
"clippy (mlx)"
"cargo-deny"
"commitlint"
"test (ubuntu-latest / default)"
"test (ubuntu-latest / mlx)"
"test (macos-latest / default)"
"test (macos-latest / mlx)"
)
publish_status() {
local context="$1"
local state="$2"
local description="$3"
local target_url="${4:-}"
local args=(
"repos/${GITHUB_REPOSITORY}/statuses/${sha}"
-f "state=${state}"
-f "context=${context}"
-f "description=${description}"
)
if [[ -n "${target_url}" ]]; then
args+=(-f "target_url=${target_url}")
fi
gh api "${args[@]}" >/dev/null
}
# The PR that release-please just opened can lag a few seconds behind
# this read (GitHub API read-after-write is only eventually
# consistent), so retry briefly before giving up. A miss is self-
# healing anyway — the next push to main runs this step again — but the
# retry avoids a needless one-cycle delay.
pr_info=""
for _ in 1 2 3; do
# shellcheck disable=SC2016 # $pr is a jq variable.
pr_info="$(gh pr list --repo "${GITHUB_REPOSITORY}" \
--label 'autorelease: pending' --state open \
--json number,headRefName,headRefOid,baseRefName,headRepositoryOwner,author \
--jq '(map(select(.baseRefName == "main" and .headRefName == "release-please--branches--main" and .author.login == "app/github-actions" and .headRepositoryOwner.login == env.GITHUB_REPOSITORY_OWNER)) | .[0]) as $pr | if $pr == null then empty else [$pr.number, $pr.headRefName, $pr.headRefOid] | @tsv end')"
[[ -n "${pr_info}" ]] && break
sleep 5
done
if [[ -z "${pr_info}" ]]; then
echo "No open release PR; nothing to dispatch."
exit 0
fi
IFS=$'\t' read -r pr_number branch sha <<< "${pr_info}"
for context in "${required_contexts[@]}"; do
publish_status "${context}" pending "Waiting for release PR CI dispatch"
done
echo "Dispatching ci.yml for release PR branch ${branch}"
dispatch_started="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
gh workflow run ci.yml --repo "${GITHUB_REPOSITORY}" --ref "${branch}"
run_id=""
run_url=""
run_status=""
run_conclusion=""
for _ in {1..180}; do
run_json="$(gh run list --repo "${GITHUB_REPOSITORY}" \
--workflow ci.yml --branch "${branch}" --event workflow_dispatch \
--limit 10 --json databaseId,headSha,status,conclusion,url,createdAt \
--jq "map(select(.headSha == \"${sha}\" and .createdAt >= \"${dispatch_started}\")) | sort_by(.createdAt) | reverse | .[0] // empty")"
if [[ -n "${run_json}" ]]; then
run_id="$(jq -r '.databaseId' <<< "${run_json}")"
run_url="$(jq -r '.url' <<< "${run_json}")"
run_status="$(jq -r '.status' <<< "${run_json}")"
run_conclusion="$(jq -r '.conclusion // empty' <<< "${run_json}")"
[[ "${run_status}" == "completed" ]] && break
fi
sleep 10
done
if [[ -z "${run_id}" ]]; then
for context in "${required_contexts[@]}"; do
publish_status "${context}" error "Release PR CI dispatch did not start"
done
exit 1
fi
if [[ "${run_status}" != "completed" ]]; then
for context in "${required_contexts[@]}"; do
publish_status "${context}" error "Release PR CI dispatch timed out" "${run_url}"
done
exit 1
fi
jobs_json="$(gh run view "${run_id}" --repo "${GITHUB_REPOSITORY}" --json jobs --jq '.jobs')"
failed=0
for context in "${required_contexts[@]}"; do
job_conclusion="$(jq -r --arg name "${context}" '[.[] | select(.name == $name) | .conclusion] | last // empty' <<< "${jobs_json}")"
case "${job_conclusion}" in
success)
publish_status "${context}" success "Passed in release PR CI dispatch" "${run_url}"
;;
skipped)
if [[ "${context}" == "commitlint" ]]; then
publish_status "${context}" success "Skipped in release PR CI dispatch" "${run_url}"
else
publish_status "${context}" error "Unexpected skipped job in release PR CI" "${run_url}"
failed=1
fi
;;
failure|cancelled|timed_out|action_required)
publish_status "${context}" failure "Failed in release PR CI dispatch" "${run_url}"
failed=1
;;
"")
publish_status "${context}" error "Missing job in release PR CI dispatch" "${run_url}"
failed=1
;;
*)
publish_status "${context}" error "Unexpected ${job_conclusion} in release PR CI" "${run_url}"
failed=1
;;
esac
done
if [[ "${run_conclusion}" != "success" || "${failed}" != "0" ]]; then
echo "::error::Release PR CI did not pass cleanly: ${run_url}"
exit 1
fi
echo "Release PR #${pr_number} CI passed and required statuses were published."
- name: Trigger binary build
if: ${{ steps.release.outputs.release_created == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.release.outputs.tag_name }}
run: gh workflow run release.yml --repo "${GITHUB_REPOSITORY}" --ref "${TAG}" -f "tag=${TAG}"
build-binaries:
name: build (${{ matrix.target }})
if: >
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
|| (github.event_name == 'workflow_dispatch' && inputs.evidence_only != true)
runs-on: ${{ matrix.os }}
timeout-minutes: 45
permissions:
contents: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
asset: galdr-x86_64-linux
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
asset: galdr-aarch64-linux
- os: macos-latest
target: x86_64-apple-darwin
asset: galdr-x86_64-darwin
- os: macos-latest
target: aarch64-apple-darwin
asset: galdr-aarch64-darwin
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Resolve and validate release tag
shell: bash
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
upload_tag="${{ github.event.inputs.tag }}"
else
upload_tag="${GITHUB_REF#refs/tags/}"
fi
expected_ref="refs/tags/${upload_tag}"
if [[ "${GITHUB_REF}" != "${expected_ref}" ]]; then
echo "::error::Release signatures are verified against ${expected_ref}, but this run identity is ${GITHUB_REF}. Re-run workflow_dispatch from the tag ref."
exit 1
fi
echo "UPLOAD_TAG=${upload_tag}" >> "$GITHUB_ENV"
echo "SIGNER_IDENTITY=https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@${expected_ref}" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- uses: sigstore/cosign-installer@v4.1.2
- name: Install cross toolchain (linux-aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
timeout-minutes: 10
shell: bash
run: |
set -euo pipefail
for attempt in 1 2 3; do
if sudo env DEBIAN_FRONTEND=noninteractive timeout 300s apt-get update \
&& sudo env DEBIAN_FRONTEND=noninteractive timeout 300s apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross; then
break
fi
if [[ "${attempt}" == "3" ]]; then
echo "::error::Failed to install Linux aarch64 cross toolchain after 3 attempts."
exit 1
fi
sleep "$((attempt * 10))"
done
mkdir -p .cargo
echo '[target.aarch64-unknown-linux-gnu]' > .cargo/config.toml
echo 'linker = "aarch64-linux-gnu-gcc"' >> .cargo/config.toml
- name: Build release binary
timeout-minutes: 25
run: cargo build --release --target ${{ matrix.target }}
- name: Package + checksum
shell: bash
run: |
mkdir -p dist
cd target/${{ matrix.target }}/release
tar czf "$GITHUB_WORKSPACE/dist/${{ matrix.asset }}.tar.gz" galdr
cd "$GITHUB_WORKSPACE"
(cd dist && shasum -a 256 "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256")
- name: Sign + verify artifact
shell: bash
run: |
artifact="dist/${{ matrix.asset }}.tar.gz"
bundle="${artifact}.sigstore.json"
cosign sign-blob --yes "${artifact}" --bundle "${bundle}"
cosign verify-blob "${artifact}" \
--bundle "${bundle}" \
--certificate-identity "${SIGNER_IDENTITY}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
- name: Attest artifact provenance
uses: actions/attest@v4
with:
subject-path: dist/${{ matrix.asset }}.tar.gz
- uses: softprops/action-gh-release@v3
with:
tag_name: ${{ env.UPLOAD_TAG }}
files: |
dist/${{ matrix.asset }}.tar.gz
dist/${{ matrix.asset }}.tar.gz.sha256
dist/${{ matrix.asset }}.tar.gz.sigstore.json
release-evidence:
name: release evidence
if: >
always()
&& (
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.build-binaries.result == 'success')
|| (github.event_name == 'workflow_dispatch' && (inputs.evidence_only == true || needs.build-binaries.result == 'success'))
)
needs: build-binaries
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "UPLOAD_TAG=${{ github.event.inputs.tag }}" >> "$GITHUB_ENV"
else
echo "UPLOAD_TAG=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV"
fi
- name: Prepare SBOM output directory
run: mkdir -p dist
- name: Generate CycloneDX SBOM
uses: anchore/sbom-action@v0
with:
path: .
format: cyclonedx-json
artifact-name: galdr-${{ env.UPLOAD_TAG }}.cdx.json
output-file: dist/galdr-${{ env.UPLOAD_TAG }}.cdx.json
upload-artifact: false
upload-release-assets: false
- name: Upload SBOM release asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
asset_name="galdr-${UPLOAD_TAG}.cdx.json"
asset_path="dist/${asset_name}"
if gh release upload "${UPLOAD_TAG}" "${asset_path}" \
--repo "${GITHUB_REPOSITORY}" --clobber; then
exit 0
fi
if gh release view "${UPLOAD_TAG}" --repo "${GITHUB_REPOSITORY}" \
--json assets --jq '.assets[].name' | grep -Fxq "${asset_name}"; then
echo "::warning::SBOM asset ${asset_name} already exists on ${UPLOAD_TAG}; treating upload as idempotent success."
existing_dir="$(mktemp -d)"
gh release download "${UPLOAD_TAG}" --repo "${GITHUB_REPOSITORY}" \
--pattern "${asset_name}" --dir "${existing_dir}"
cp "${existing_dir}/${asset_name}" "${asset_path}"
exit 0
fi
echo "::error::Failed to upload SBOM asset ${asset_name} to ${UPLOAD_TAG}."
exit 1
- name: Attest SBOM provenance
uses: actions/attest@v4
with:
subject-path: dist/galdr-${{ env.UPLOAD_TAG }}.cdx.json
publish-crate:
name: publish to crates.io
if: >
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
|| (github.event_name == 'workflow_dispatch' && inputs.evidence_only != true)
needs: build-binaries
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Skip if this version is already on crates.io
id: state
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
version="${{ github.event.inputs.tag }}"
else
version="${GITHUB_REF#refs/tags/}"
fi
version="${version#v}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
if curl -fsSL -H "User-Agent: galdr-release" "https://crates.io/api/v1/crates/galdr/${version}" >/dev/null 2>&1; then
echo "already=true" >> "$GITHUB_OUTPUT"
echo "galdr ${version} is already on crates.io — nothing to publish."
else
echo "already=false" >> "$GITHUB_OUTPUT"
fi
- name: Authenticate to crates.io (Trusted Publishing)
if: steps.state.outputs.already == 'false'
id: auth
uses: rust-lang/crates-io-auth-action@v1
- name: Publish galdr to crates.io
if: steps.state.outputs.already == 'false'
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
shell: bash
run: |
set -euo pipefail
version="${{ steps.state.outputs.version }}"
publish_log="$(mktemp)"
trap 'rm -f "${publish_log}"' EXIT
set +e
cargo publish 2>&1 | tee "${publish_log}"
status="${PIPESTATUS[0]}"
set -e
if [[ "${status}" == "0" ]]; then
exit 0
fi
if grep -Fq "is already uploaded" "${publish_log}"; then
echo "cargo publish reported galdr ${version} is already uploaded; verifying crates.io state."
for _ in {1..12}; do
if curl -fsSL -H "User-Agent: galdr-release" "https://crates.io/api/v1/crates/galdr/${version}" >/dev/null 2>&1; then
echo "galdr ${version} is present on crates.io; treating publish as idempotent success."
exit 0
fi
sleep 5
done
fi
exit "${status}"