name: Release
on:
push:
tags:
- 'v*'
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v0.1.0)'
required: true
type: string
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Build ${{ matrix.target }}
needs: release-please
if: >-
always() &&
(startsWith(github.ref, 'refs/tags/') ||
github.event_name == 'workflow_dispatch' ||
needs.release-please.outputs.releases_created == 'true')
runs-on: ${{ matrix.os }}
env:
VERSION: ${{ inputs.version || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || needs.release-please.outputs.tag_name }}
permissions:
contents: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-22.04
archive: tar.gz
ort_asset: onnxruntime-linux-x64
ort_version: "1.24.1"
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04
archive: tar.gz
ort_asset: onnxruntime-linux-aarch64
ort_version: "1.24.1"
- target: aarch64-apple-darwin
os: macos-14
archive: tar.gz
ort_asset: onnxruntime-osx-arm64
ort_version: "1.24.1"
- target: x86_64-apple-darwin
os: macos-15-intel
archive: tar.gz
ort_asset: onnxruntime-osx-x86_64
ort_version: "1.23.2"
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with:
ref: ${{ env.VERSION }}
fetch-depth: 0
- name: Verify publishing guard
run: bash scripts/ci/crates-publish-guard.sh --selftest
- name: Install protoc
uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Rust
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de with:
toolchain: "1.98.1"
targets: ${{ matrix.target }}
- name: Cache cargo
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 with:
key: ${{ matrix.target }}-${{ matrix.os }}
- name: Install cmake (for aws-lc-sys source build)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y cmake
- name: Install aarch64 cross-compilation toolchain
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
- name: Install release metadata tools
run: |
cargo install cargo-auditable --version 0.7.6 --locked
cargo install cargo-cyclonedx --version 0.5.9 --locked
- name: Build
shell: bash
env:
AWS_LC_SYS_CMAKE_BUILDER: "1"
run: |
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
export CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
export AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar
# THIS LEG OOM-KILLS THE RUNNER UNDER FAT LTO. Not a guess -- measured.
#
# [profile.release] is lto = true + codegen-units = 1, i.e. a
# single-threaded whole-program link. That fits on the x86_64 legs. On this
# one it does not, and it has silently cost us every release since v0.6.0:
# tags v0.6.1, v0.6.2, v0.6.3 and v0.6.4 were all pushed and all produced NO
# GitHub Release, because `checksums` needs: build and `release` needs:
# [build, checksums] -- so this single leg took the whole lane down, four
# times, for 19 days, with no alert (a tag with no Release is
# indistinguishable from no tag at all).
#
# The variable is in the repo's own history: v0.6.0 built WITHOUT
# `--features knowledge` and released fine; commit 296238c added the flag and
# every one of the 5 subsequent tag runs died here. The feature pulls the
# quipu/RDF dependency tree through that fat-LTO link.
#
# Measured on a 4-core/16GB runner, one run, single variable
# (probe/aarch64-oom, run 30753070604):
#
# baseline (lto=true, cgu=1) peak 15719M used / 21M available /
# 3071M swap exhausted -> exit 143 at 24m
# thin (this setting) peak 3440M used / 12154M available
# -> SUCCESS in 18m37s, max RSS 2.4GB
#
# So: ~4.6x less peak memory, and it builds. Scoped to this target on
# purpose -- the x86_64 legs keep fat LTO because they are the artifacts
# anyone actually runs, and they are not the ones failing.
#
# KNOWN UNMEASURED: the x86_64 legs' remaining headroom. They pass today,
# but nothing here tells us by how much, so a future dependency bump could
# walk them into the same wall. If a linux leg starts dying at ~20+ minutes
# of log silence with exit 143, this is the cause -- come straight here.
export CARGO_PROFILE_RELEASE_LTO=thin
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
fi
# --features knowledge is REQUIRED, not optional: it gates bobbin's entire
# Quipu integration (MCP surface, coupling exporter, PPR reranking) and is
# not a cargo default. Every release before this line shipped with all of it
# silently compiled out (bobbin-jdlkh). Do not drop this flag.
# --locked: build exactly the committed Cargo.lock. Without it cargo may
# re-resolve and ship a dependency graph no one reviewed.
cargo auditable build --release --locked --features knowledge --target ${{ matrix.target }}
- name: Download ONNX Runtime
shell: bash
run: |
ORT_TAG="v${{ matrix.ort_version }}"
ORT_TARBALL="${{ matrix.ort_asset }}-${{ matrix.ort_version }}.tgz"
# --fail so a missing/renamed asset errors here, not 2 steps later.
curl -sSL --fail "https://github.com/microsoft/onnxruntime/releases/download/${ORT_TAG}/${ORT_TARBALL}" \
-o /tmp/ort.tgz
mkdir -p /tmp/ort-extracted
# NOTE: do NOT use --strip-components here. The osx-x86_64 tarball entries
# carry a leading "./" component (others don't), so strip-components=1 would
# remove "." and leave the lib one level too deep. Extract verbatim and let
# the Package step locate lib/ wherever it lands. See bo-ewk.
tar xzf /tmp/ort.tgz -C /tmp/ort-extracted
- name: Package
shell: bash
run: |
STAGING="bobbin-${{ env.VERSION }}-${{ matrix.target }}"
mkdir -p "$STAGING/lib"
cp target/${{ matrix.target }}/release/bobbin "$STAGING/"
# Bundle ONNX Runtime shared library from extracted tarball.
# Locate the lib/ dir regardless of how deep the tarball nests it
# (osx-x86_64 nests one level deeper — see bo-ewk).
ORT_LIB_DIR=$(find /tmp/ort-extracted -type d -name lib | head -1)
if [ -z "$ORT_LIB_DIR" ]; then
echo "ERROR: no lib/ dir found under /tmp/ort-extracted" >&2
find /tmp/ort-extracted -maxdepth 2 >&2
exit 1
fi
copied=0
for f in $(find "$ORT_LIB_DIR" -maxdepth 1 -name "libonnxruntime*" \
-not -name "*providers*" -not -name "*.pc" -not -type d); do
cp "$f" "$STAGING/lib/"
copied=$((copied + 1))
done
if [ "$copied" -eq 0 ]; then
echo "ERROR: no libonnxruntime* libraries copied from $ORT_LIB_DIR" >&2
ls -la "$ORT_LIB_DIR" >&2
exit 1
fi
tar czvf "${STAGING}.tar.gz" "$STAGING"
- name: Generate Rust dependency SBOM
shell: bash
run: |
cargo cyclonedx --format json --spec-version 1.5 --all \
--features knowledge --target "${{ matrix.target }}" \
--override-filename "bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx"
# Metadata generation must not silently re-resolve the release lockfile.
git diff --exit-code -- Cargo.lock
test -s "bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json"
- name: Attest build provenance
id: provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 with:
subject-path: |
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
target/${{ matrix.target }}/release/bobbin
- name: Attest Rust dependency SBOM
id: sbom
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e with:
subject-path: bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
sbom-path: bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
- name: Preserve offline attestation bundles
shell: bash
env:
PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }}
SBOM_BUNDLE: ${{ steps.sbom.outputs.bundle-path }}
run: |
cp "$PROVENANCE_BUNDLE" "bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json"
cp "$SBOM_BUNDLE" "bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json"
- name: Build repository index pack
if: matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: |
# Bobbin's own canonical pack rides each version release. The filename
# gives clone/bootstrap consumers an immutable URL, while the embedded
# manifest remains the authority for the exact repository SHA and
# embedding/tool identity. Rebuild it at every tag; never carry a pack
# forward merely because the source diff looks irrelevant to indexing.
PACK="bobbin-${{ env.VERSION }}-repository.bbpack"
PACK_HOME=$(mktemp -d)
mkdir -p "$PACK_HOME/home"
ORT_LIB_DIR=$(find /tmp/ort-extracted -type d -name lib | head -1)
export LD_LIBRARY_PATH="$ORT_LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
BOBBIN="target/${{ matrix.target }}/release/bobbin"
"$BOBBIN" init "$PACK_HOME/home"
"$BOBBIN" index "$PACK_HOME/home" \
--repo bobbin \
--source "$GITHUB_WORKSPACE" \
--skip-calibrate
"$BOBBIN" pack export "$PACK_HOME/home" \
--repo bobbin \
--source "$GITHUB_WORKSPACE" \
--output "$PACK"
"$BOBBIN" pack verify "$PACK" --path "$PACK_HOME/home"
sha256sum "$PACK" > "$PACK.sha256"
- name: Upload artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with:
name: bobbin-${{ matrix.target }}
path: |
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
bobbin-${{ env.VERSION }}-repository.bbpack
bobbin-${{ env.VERSION }}-repository.bbpack.sha256
- name: Generate early Linux deploy checksum
if: matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: |
sha256sum \
"bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}" \
"bobbin-${{ env.VERSION }}-repository.bbpack" \
> SHA256SUMS.txt
- name: Publish Linux deploy archive immediately
id: publish_deploy
if: matrix.target == 'x86_64-unknown-linux-gnu'
continue-on-error: true
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b with:
tag_name: ${{ env.VERSION }}
draft: true
files: |
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
bobbin-${{ env.VERSION }}-repository.bbpack
bobbin-${{ env.VERSION }}-repository.bbpack.sha256
SHA256SUMS.txt
fail_on_unmatched_files: true
- name: Back off before retrying publish Linux deploy archive immediately
if: steps.publish_deploy.outcome == 'failure'
run: sleep 30
- name: Retry publish Linux deploy archive immediately after a transient failure
if: steps.publish_deploy.outcome == 'failure'
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b with:
tag_name: ${{ env.VERSION }}
draft: true
files: |
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
bobbin-${{ env.VERSION }}-repository.bbpack
bobbin-${{ env.VERSION }}-repository.bbpack.sha256
SHA256SUMS.txt
fail_on_unmatched_files: true
- name: Publish target archive immediately
id: publish_target
if: matrix.target != 'x86_64-unknown-linux-gnu'
continue-on-error: true
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b with:
tag_name: ${{ env.VERSION }}
draft: true
files: |
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
fail_on_unmatched_files: true
- name: Back off before retrying publish target archive immediately
if: steps.publish_target.outcome == 'failure'
run: sleep 30
- name: Retry publish target archive immediately after a transient failure
if: steps.publish_target.outcome == 'failure'
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b with:
tag_name: ${{ env.VERSION }}
draft: true
files: |
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}
bobbin-${{ env.VERSION }}-${{ matrix.target }}.cdx.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sigstore.json
bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}.sbom.sigstore.json
fail_on_unmatched_files: true
checksums:
name: Generate checksums
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with:
path: artifacts
- name: Generate SHA256 checksums
run: |
cd artifacts
find . -type f \( -name "*.tar.gz" -o -name "*.bbpack" -o -name "*.bbpack.sha256" -o -name "*.cdx.json" -o -name "*.sigstore.json" \) -exec mv {} . \;
rm -rf bobbin-*/
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Upload checksums
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with:
name: checksums
path: artifacts/SHA256SUMS.txt
release:
name: Create release
needs: [release-please, build, checksums]
if: >-
always() &&
needs.build.result == 'success' &&
needs.checksums.result == 'success'
runs-on: ubuntu-latest
env:
VERSION: ${{ inputs.version || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || needs.release-please.outputs.tag_name }}
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with:
path: artifacts
- name: Prepare release assets
run: |
cd artifacts
# Extract the linux-amd64 binary as a standalone for homelab deployment
find . -name "bobbin-*-x86_64-unknown-linux-gnu.tar.gz" -exec tar xzf {} -C . \;
LINUX_DIR=$(find . -maxdepth 1 -type d -name "bobbin-*-x86_64-unknown-linux-gnu" | head -1)
cp "$LINUX_DIR/bobbin" bobbin-linux-amd64
# Also publish the ONNX Runtime .so for homelab deployment
cp "$LINUX_DIR"/lib/libonnxruntime.so.* libonnxruntime-linux-amd64.so 2>/dev/null || true
rm -rf "$LINUX_DIR"
# Extract the linux-arm64 binary as a standalone
find . -name "bobbin-*-aarch64-unknown-linux-gnu.tar.gz" -exec tar xzf {} -C . \;
ARM_DIR=$(find . -maxdepth 1 -type d -name "bobbin-*-aarch64-unknown-linux-gnu" | head -1)
if [ -n "$ARM_DIR" ]; then
cp "$ARM_DIR/bobbin" bobbin-linux-arm64
cp "$ARM_DIR"/lib/libonnxruntime.so.* libonnxruntime-linux-arm64.so 2>/dev/null || true
rm -rf "$ARM_DIR"
fi
find . -type f \( -name "*.tar.gz" -o -name "*.bbpack" -o -name "*.bbpack.sha256" -o -name "*.cdx.json" -o -name "*.sigstore.json" -o -name "SHA256SUMS.txt" -o -name "bobbin-linux-*" -o -name "libonnxruntime-linux-*.so" \) -exec mv {} . \; 2>/dev/null || true
rm -rf bobbin-*/ checksums/
# The checksums job runs before the standalone Linux binaries are
# extracted, so its manifest can only cover the archives. Regenerate
# the release manifest here, after every published asset exists in the
# same directory. This keeps the convenient raw binaries verifiable
# instead of making them an attractive unchecked download path.
rm -f SHA256SUMS.txt
find . -maxdepth 1 -type f ! -name SHA256SUMS.txt -printf '%f\0' \
| sort -z \
| xargs -0 sha256sum > SHA256SUMS.txt
echo "Final release checksums:"
cat SHA256SUMS.txt
ls -la
- name: Finalize GitHub Release assets and checksums
id: finalize_assets
continue-on-error: true
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b with:
tag_name: ${{ env.VERSION }}
draft: true
files: artifacts/*
generate_release_notes: true
fail_on_unmatched_files: true
- name: Back off before retrying finalize GitHub Release assets and checksums
if: steps.finalize_assets.outcome == 'failure'
run: sleep 30
- name: Retry finalize GitHub Release assets and checksums after a transient failure
if: steps.finalize_assets.outcome == 'failure'
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b with:
tag_name: ${{ env.VERSION }}
draft: true
files: artifacts/*
generate_release_notes: true
fail_on_unmatched_files: true
- name: Publish complete GitHub Release
shell: bash
run: |
set -euo pipefail
version="${{ env.VERSION }}"
# Bounded retry for transient GitHub API failures (aegis-25sovf). Each
# call is idempotent: a list is a read, and `release edit` sets state.
retry() {
local attempt
for attempt in 1 2 3; do
"$@" && return 0
[ "$attempt" -lt 3 ] || break
echo "attempt $attempt failed: $*; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
done
return 1
}
# Highest published (non-draft, non-prerelease) version, by semver.
# `sort -V` is version-aware, so 0.9.0 sorts below 0.10.0 where a
# lexical sort would not.
highest="$(retry gh release list --repo "${{ github.repository }}" \
--exclude-drafts --exclude-pre-releases --limit 200 \
--json tagName --jq '.[].tagName' \
| sed 's/^v//' | sort -V | tail -1)"
mine="${version#v}"
# A PRERELEASE must never claim `latest`, whatever it sorts against.
# Two reasons, the second being why this is not merely tidiness
# (wu, reviewing aegis-egqrv4; quipu hit the same shape in #142):
# * `latest` is what the deploy timer resolves, so a prerelease
# claiming it would be DEPLOYED — the exact inversion this guard
# exists to stop, arriving by another route.
# * `sort -V` is not semver-aware about prereleases: it ranks
# 0.17.0-rc1 ABOVE both 0.16.0 and 0.17.0, where semver puts a
# prerelease BELOW its own final. Excluding prereleases on both
# sides means that disagreement can never decide anything;
# `--exclude-pre-releases` above covers the other side.
# Reachable with an arbitrary tag via workflow_dispatch, so not
# hypothetical.
case "$mine" in
*-*)
echo "publishing $version WITHOUT --latest: it is a PRERELEASE."
retry gh release edit "$version" --repo "${{ github.repository }}" --draft=false --latest=false
exit 0
;;
esac
if [ -z "$highest" ] || [ "$(printf '%s\n%s\n' "$highest" "$mine" | sort -V | tail -1)" = "$mine" ]; then
echo "publishing $version as latest (highest published: ${highest:-none})"
retry gh release edit "$version" --repo "${{ github.repository }}" --draft=false --latest
else
echo "publishing $version WITHOUT --latest: $highest is already published and is newer."
echo "This is the aegis-egqrv4 guard. The release is still published and its"
echo "assets are still available; it simply does not demote a newer release."
retry gh release edit "$version" --repo "${{ github.repository }}" --draft=false --latest=false
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-please:
name: Release Please
if: startsWith(github.ref, 'refs/heads/')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
outputs:
releases_created: ${{ steps.release.outputs.releases_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
crates:
name: Publish to crates.io
needs: [release-please, build, checksums, release]
if: >-
always() &&
needs.build.result == 'success' &&
needs.checksums.result == 'success' &&
needs.release.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
id-token: write
env:
VERSION: ${{ inputs.version || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || needs.release-please.outputs.tag_name }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with:
ref: ${{ env.VERSION }}
fetch-depth: 0
- name: Verify publishing guard
run: bash scripts/ci/crates-publish-guard.sh --selftest
- name: Resolve independently declared release version
id: want
run: |
set -euo pipefail
echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT"
- name: Install package build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler cmake g++
- uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de with:
toolchain: "1.98.1"
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 - uses: ./.github/actions/crates-publish
with:
expected-version: ${{ steps.want.outputs.version }}
dry-run: 'false'