name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g., v1.2.0)'
required: true
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
permissions:
contents: write
concurrency:
group: release-${{ github.repository }}
cancel-in-progress: false
jobs:
verify:
runs-on: [self-hosted, clean-room, X64]
env:
CARGO_HOME: ${{ github.workspace }}/../cargo-home-${{ github.job }}
outputs:
crate_name: ${{ steps.parse.outputs.crate_name }}
version: ${{ steps.parse.outputs.version }}
has_binaries: ${{ steps.bincheck.outputs.has_binaries }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Parse tag and verify version
id: parse
run: |
TAG="${RELEASE_TAG}"
# Parse tag format: v1.0.0 or v-cratename-1.0.0
if [[ "$TAG" =~ ^v-([a-z][a-z0-9_-]*)-([0-9]+\..+)$ ]]; then
CRATE_NAME="${BASH_REMATCH[1]}"
TAG_VER="${BASH_REMATCH[2]}"
echo "Workspace release: crate=$CRATE_NAME version=$TAG_VER"
# Since #436 the repo is a workspace: .packages[0] is forjar-contracts
# (0.31.2), which failed the v1.25.0/v1.25.1 tags as "Tag version !=
# Cargo.toml version 0.31.2". A single-crate tag names the ROOT crate.
elif [[ "$TAG" =~ ^v([0-9]+\..+)$ ]]; then
CRATE_NAME=""
TAG_VER="${BASH_REMATCH[1]}"
echo "Single-crate release: version=$TAG_VER"
else
echo "::error::Tag '$TAG' does not match expected format (v1.0.0 or v-crate-1.0.0)"
exit 1
fi
# Use cargo metadata for reliable version extraction
if [ -n "$CRATE_NAME" ]; then
CARGO_VER=$(cargo metadata --format-version 1 --no-deps \
| jq -r ".packages[] | select(.name == \"$CRATE_NAME\") | .version")
if [ -z "$CARGO_VER" ] || [ "$CARGO_VER" = "null" ]; then
echo "::error::Crate '$CRATE_NAME' not found in workspace"
exit 1
fi
else
CARGO_VER=$(cargo metadata --format-version 1 --no-deps \
| jq -r '.packages[] | select(.name == "forjar") | .version')
fi
if [ "$TAG_VER" != "$CARGO_VER" ]; then
echo "::error::Tag version $TAG_VER != Cargo.toml version $CARGO_VER"
exit 1
fi
echo "crate_name=$CRATE_NAME" >> "$GITHUB_OUTPUT"
echo "version=$TAG_VER" >> "$GITHUB_OUTPUT"
echo "Version verified: $TAG_VER"
- name: Detect binary targets
id: bincheck
run: |
HAS_BINS=$(cargo metadata --format-version 1 --no-deps \
| jq '[.packages[].targets[] | select(.kind[] == "bin")] | length')
echo "has_binaries=$( [ "$HAS_BINS" -gt 0 ] && echo true || echo false )" >> "$GITHUB_OUTPUT"
echo "Binary targets found: $HAS_BINS"
- name: Verify package tarball
run: |
# #423: the contract crates are workspace members and the root's path
# dependencies until the release publishes them, so the tarball is
# verified for the whole workspace (cargo >= 1.90 resolves the root
# against the members it just packaged).
cargo package --workspace
create-release:
needs: verify
runs-on: [self-hosted, clean-room, X64]
env:
CARGO_HOME: ${{ github.workspace }}/../cargo-home-${{ github.job }}
outputs:
created: ${{ steps.create.outputs.created }}
steps:
- name: Create or update GitHub Release
id: create
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Idempotent: create if missing, otherwise leave existing release
# (and its hand-written notes) intact so re-runs don't clobber them.
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists — leaving notes intact, downstream jobs will upload assets."
echo "created=false" >> "$GITHUB_OUTPUT"
else
# PMAT-166: born a PRERELEASE. The clean-room verify above is the
# hard gate for the artifact; promotion to a full release is the
# operator's step after the published crate passes the dogfood
# arm (`make dogfood-published VERSION=`), never this workflow's.
# Born a DRAFT so no consumer sees a half-uploaded asset set
# (the field's pattern: cargo-dist and GoReleaser build into a
# draft); the publish-release job un-drafts once every asset job
# has finished. It stays a PRERELEASE after that.
gh release create "$RELEASE_TAG" \
--title "$RELEASE_TAG" \
--generate-notes \
--verify-tag \
--draft \
--prerelease \
--repo "${{ github.repository }}"
echo "created=true" >> "$GITHUB_OUTPUT"
fi
- name: Assert the release this run created is a draft prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# release-check (PMAT-166): what this run created must be exactly a
# draft prerelease at this point — anything else means the create
# step was bypassed or a producer raced it. A release that existed
# before this run (re-run of an old tag) is reported, never touched.
state="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease,isDraft --jq '"prerelease=\(.isPrerelease) draft=\(.isDraft)"')"
echo "release $RELEASE_TAG: $state (created by this run: ${{ steps.create.outputs.created }})"
if [ "${{ steps.create.outputs.created }}" = "true" ] && [ "$state" != "prerelease=true draft=true" ]; then
echo "::error::release $RELEASE_TAG was created by this run but is not a draft prerelease: $state"
exit 1
fi
build-binaries:
needs: [verify, create-release]
if: needs.verify.outputs.has_binaries == 'true'
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: [self-hosted, clean-room, X64]
- target: x86_64-unknown-linux-musl
runner: [self-hosted, clean-room, X64]
- target: aarch64-unknown-linux-gnu
runner: [self-hosted, clean-room, X64]
- target: aarch64-unknown-linux-musl
runner: [self-hosted, clean-room, X64]
- target: x86_64-apple-darwin
runner: macos-latest
- target: aarch64-apple-darwin
runner: macos-latest
runs-on: ${{ matrix.runner }}
env:
CARGO_HOME: ${{ github.workspace }}/../cargo-home-${{ github.job }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install Rust toolchain (macOS)
if: contains(matrix.target, 'darwin')
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# ADD THE TARGET. The Linux branch below already does this; macOS did
# not, and it only mattered once `macos-latest` became ARM: with an
# aarch64 host, aarch64-apple-darwin is NATIVE and builds fine, while
# x86_64-apple-darwin has no std and dies with
# error[E0463]: can't find crate for `core`
# That is what happened on v1.17.0 — 5 of 6 targets built, and the
# release shipped without an x86_64 macOS binary while `create-release`
# reported success and `checksums`/`dist-artifacts` silently skipped.
#
# NOT `|| true`. The Linux branch swallows failure that way, so a
# missing target there would surface later as this same inscrutable
# `core` error rather than at the point of cause. A target that cannot
# be installed must fail here, loudly.
export PATH="$HOME/.cargo/bin:$PATH"
rustup target add "${{ matrix.target }}"
rustup target list --installed | grep -qx "${{ matrix.target }}" || {
echo "::error::rustup reported success but ${{ matrix.target }} is not installed"
exit 1
}
- name: Install target prerequisites (Linux)
if: contains(matrix.target, 'linux')
run: |
case "${{ matrix.target }}" in
*-musl)
if command -v apt-get &>/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq musl-tools >/dev/null
fi
rustup target add "${{ matrix.target }}" || true
;;
esac
case "${{ matrix.target }}" in
aarch64-*)
if ! command -v cross &>/dev/null; then
cargo install cross --locked
fi
;;
esac
- name: Discover binary names
id: bins
run: |
BINS=$(cargo metadata --format-version 1 --no-deps \
| jq -r '[.packages[].targets[] | select(.kind[] == "bin") | .name] | join(" ")')
echo "names=$BINS" >> "$GITHUB_OUTPUT"
echo "Binaries to build: $BINS"
- name: Build release binaries
run: |
case "${{ matrix.target }}" in
aarch64-unknown-linux-*)
cross build --release --features vendored-openssl --target "${{ matrix.target }}"
;;
*)
cargo build --release --features vendored-openssl --target "${{ matrix.target }}"
;;
esac
- name: Package binaries
run: |
VERSION="${{ needs.verify.outputs.version }}"
TARGET="${{ matrix.target }}"
STAGING="${RUNNER_TEMP}/release-staging"
mkdir -p "$STAGING"
for BIN in ${{ steps.bins.outputs.names }}; do
ARCHIVE="${BIN}-${VERSION}-${TARGET}.tar.gz"
tar -czf "${STAGING}/${ARCHIVE}" \
-C "target/${TARGET}/release" "$BIN"
echo "Packaged: $ARCHIVE"
done
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: binaries-${{ matrix.target }}
path: ${{ runner.temp }}/release-staging/*.tar.gz
retention-days: 5
checksums:
needs: [verify, create-release, build-binaries]
if: needs.verify.outputs.has_binaries == 'true'
runs-on: [self-hosted, clean-room, X64]
steps:
- name: Clear the staging directory
run: rm -rf /tmp/release-assets && mkdir -p /tmp/release-assets
- name: Download all binary artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: binaries-*
merge-multiple: true
path: /tmp/release-assets
- name: Refuse to publish anything that is not this release
working-directory: /tmp/release-assets
run: |
set -euo pipefail
ver="${RELEASE_TAG#v}"
stray=0
for f in *.tar.gz; do
case "$f" in
forjar-"$ver"-*) ;;
*) echo "::error::staging holds $f, which is not $RELEASE_TAG"; stray=1 ;;
esac
done
# A CLEAN DIRECTORY IS NOT PROOF THE CLEAR WORKED — an empty staging
# dir would also pass a "no strays" test while publishing nothing. So
# assert the denominator too.
n=$(ls -1 *.tar.gz 2>/dev/null | wc -l)
echo "staging holds $n archive(s) for $RELEASE_TAG"
[ "$n" -gt 0 ] || { echo "::error::no archives staged — nothing to publish"; exit 1; }
[ "$stray" -eq 0 ] || exit 1
- name: Generate SHA256SUMS and per-asset sidecars
working-directory: /tmp/release-assets
run: |
set -euo pipefail
sha256sum *.tar.gz > SHA256SUMS
# Per-asset `.sha256` as well, because binary-release.yml produces
# them and install.sh falls back to them when SHA256SUMS is missing.
# Two producers writing to one release with different conventions is
# how v1.18.0 ended up with sidecars for its Linux assets and none for
# its macOS ones.
for f in *.tar.gz; do sha256sum "$f" > "$f.sha256"; done
echo "=== SHA256SUMS ==="
cat SHA256SUMS
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
working-directory: /tmp/release-assets
run: |
gh release upload "$RELEASE_TAG" \
*.tar.gz *.tar.gz.sha256 SHA256SUMS \
--repo "${{ github.repository }}" \
--clobber
dist-artifacts:
needs: [verify, checksums]
if: needs.verify.outputs.has_binaries == 'true'
runs-on: [self-hosted, clean-room, X64]
env:
CARGO_HOME: ${{ github.workspace }}/../cargo-home-${{ github.job }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Fetch the release checksums (the release is still a DRAFT here)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# `forjar dist` resolves checksums from
# https://github.com/<repo>/releases/download/<tag>/<asset>, and GitHub
# does not serve that path for a DRAFT release — which this one is
# until `publish-release` un-drafts it. The 1.26.0 run uploaded all
# thirteen assets and then died here with "cannot resolve checksums
# for release asset(s) ...", and the installer had to be generated by
# hand from the tag (PMAT-208).
#
# `gh release download` is authenticated and does see a draft, so the
# checksums come from the release itself either way; the only change
# is that they arrive over the API rather than the public URL.
# PMAT-230: --clobber, because /tmp persists between jobs on these
# runners and the file the PREVIOUS release left is still there. The
# v1.28.0 run died right here ("/tmp/SHA256SUMS already exists"),
# leaving a draft release with thirteen assets and no installer.
# NOT --skip-existing: that keeps the previous release's checksums,
# exits 0, satisfies the `test -s` below and embeds them in
# install.sh — the v1.18.0 failure again, with a friendlier exit code.
gh release download "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--pattern SHA256SUMS \
--clobber \
--dir /tmp
test -s /tmp/SHA256SUMS
- name: Generate distribution artifacts
run: |
# `dist --all` embeds real checksums into the homebrew formula and the
# nix flake, so it REQUIRES the tag (placeholders are never emitted);
# the v1.25.1 backfill failed here without it. `--checksums-file` is
# the interface forjar's own error message names, and it is what keeps
# this step off the public download URL (PMAT-208).
# PMAT-230: same non-ephemeral-runner lesson the `checksums` job
# records — /tmp/dist-output is a FIXED path and `dist` does not
# clear it, so a file from the previous release would be uploaded as
# this release's artifact, which is the v1.18.0 laundering shape.
rm -rf /tmp/dist-output
cargo run --release -- dist \
-f dist-forjar.yaml \
--all \
--version "$RELEASE_TAG" \
--checksums-file /tmp/SHA256SUMS \
--output-dir /tmp/dist-output
- name: Upload installer to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$RELEASE_TAG" \
/tmp/dist-output/install.sh \
--repo "${{ github.repository }}" \
--clobber
- name: Upload dist artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: dist-artifacts
path: /tmp/dist-output/
retention-days: 30
homebrew:
needs: [verify, dist-artifacts]
if: needs.verify.outputs.has_binaries == 'true'
runs-on: [self-hosted, clean-room, X64]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Download dist artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: dist-artifacts
path: /tmp/dist-output
- name: Download release checksums
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# PMAT-230: same fixed-path hazard as dist-artifacts, same fix.
gh release download "$RELEASE_TAG" \
--pattern "SHA256SUMS" \
--clobber \
--dir /tmp/checksums \
--repo "${{ github.repository }}"
- name: Patch Homebrew formula with real checksums and version
run: |
VERSION="${{ needs.verify.outputs.version }}"
FORMULA="/tmp/dist-output/homebrew.rb"
# Replace VERSION placeholder
sed -i "s/version \"VERSION\"/version \"${VERSION}\"/" "$FORMULA"
# Replace PLACEHOLDER_CHECKSUM for each target
for TARGET_ASSET in \
"forjar-${VERSION}-x86_64-unknown-linux-gnu.tar.gz" \
"forjar-${VERSION}-aarch64-unknown-linux-gnu.tar.gz" \
"forjar-${VERSION}-x86_64-apple-darwin.tar.gz" \
"forjar-${VERSION}-aarch64-apple-darwin.tar.gz"; do
CHECKSUM=$(grep "$TARGET_ASSET" /tmp/checksums/SHA256SUMS | awk '{print $1}' || echo "")
if [ -n "$CHECKSUM" ]; then
# Replace first occurrence of PLACEHOLDER_CHECKSUM
sed -i "0,/PLACEHOLDER_CHECKSUM/s/PLACEHOLDER_CHECKSUM/${CHECKSUM}/" "$FORMULA"
fi
done
echo "=== Patched formula ==="
cat "$FORMULA"
- name: Publish to Homebrew tap
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
VERSION="${{ needs.verify.outputs.version }}"
FORMULA="/tmp/dist-output/homebrew.rb"
# Clone the tap repo
# PMAT-230: /tmp/tap is a FIXED path on a runner that is not
# ephemeral, and `git clone` into an existing directory exits 128.
# Found by a quorum lane reviewing the --clobber fix: this step has
# never been reached, because the checksums download two steps above
# died first on the same class of defect (v1.27.0's homebrew job
# failed there).
rm -rf /tmp/tap
git clone "https://x-access-token:${GH_TOKEN}@github.com/paiml/homebrew-tap.git" /tmp/tap
cp "$FORMULA" /tmp/tap/Formula/forjar.rb
cd /tmp/tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/forjar.rb
git commit -m "forjar ${VERSION}" || echo "No changes to commit"
git push
publish-release:
needs: [verify, create-release, build-binaries, checksums, dist-artifacts]
if: >-
always() &&
needs.verify.result == 'success' &&
needs.create-release.result == 'success' &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
runs-on: [self-hosted, clean-room, X64]
steps:
- name: Publish the prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# A DRAFT IS UN-DRAFTED HERE WHOEVER CREATED IT (PMAT-232).
#
# This used to run only when THIS run created the release, and the
# assertion below sat behind the same guard. The v1.28.0 release is
# what that cost: its first tag-triggered run created the release and
# then died in dist-artifacts, and the re-dispatch that carried the
# fix reported `publish-release: success` while doing nothing at all.
# By then the release was complete — fourteen assets, installer
# included, dist-artifacts green six minutes earlier — and still
# invisible to everyone but its author, because a draft is. A release
# whose creating run fails after `create-release` could never be
# published by this workflow.
#
# What the old guard was protecting is real and is kept: an EXISTING
# release keeps the state the operator gave it. That is now decided by
# reading the release rather than by asking who created it — a draft
# is published, a published release is left exactly as it is, so a
# re-dispatch over a release an operator has already promoted to a
# full release cannot demote it back to a prerelease.
state="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease,isDraft --jq '"prerelease=\(.isPrerelease) draft=\(.isDraft)"')"
echo "release $RELEASE_TAG before: $state"
case "$state" in
*"draft=true")
gh release edit "$RELEASE_TAG" --draft=false --prerelease --repo "${{ github.repository }}"
;;
esac
state="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease,isDraft --jq '"prerelease=\(.isPrerelease) draft=\(.isDraft)"')"
echo "release $RELEASE_TAG: $state"
# UNCONDITIONAL. The assertion is the whole point of the job, and a
# job that passes while doing nothing is what PMAT-232 was.
case "$state" in
*"draft=false") ;;
*)
echo "::error::release $RELEASE_TAG is still a draft after the asset jobs: $state"
exit 1
;;
esac
# NAME THE COMMAND, AND SAY WHY IT NEEDS --latest (PMAT-534).
#
# "Promotion is the operator's step" was the whole of the instruction
# for four releases, and for four releases nobody took it:
# repos/<r>/releases/latest resolved to v1.25.2 while v1.26.0, v1.27.0
# and v1.28.0 were all full releases days newer. Every
# `curl -L .../releases/latest/download/...` got the old binary.
#
# `--latest` is not decoration. GitHub fixes "latest" when the
# prerelease flag is WRITTEN and never recomputes it, so
# `gh release edit <tag> --prerelease=false` alone clears the flag and
# leaves the pointer exactly where it was. Gate R now refuses a full
# release that /releases/latest does not point at.
echo "Promotion to a full release is the operator's step after 'make dogfood-published VERSION=' passes:"
echo " gh release edit $RELEASE_TAG --repo ${{ github.repository }} --prerelease=false --latest"
echo "and 'make release-check' refuses a full release that repos/${{ github.repository }}/releases/latest does not resolve to."