Skip to content

chore: align release manifest source commit with refreshed surface ma… #236

chore: align release manifest source commit with refreshed surface ma…

chore: align release manifest source commit with refreshed surface ma… #236

Workflow file for this run

name: Release
on:
push:
tags:
- "v*"
concurrency:
# Include SHA so a retag at a different commit gets its own concurrency
# group and won't get blocked behind a stuck zombie queued run.
group: release-${{ github.ref }}-${{ github.sha }}
cancel-in-progress: false
permissions:
contents: write
id-token: write
env:
CARGO_TERM_COLOR: always
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# All unit-level coverage (Linux, macOS, Windows cargo, Windows bash e2e).
# Defined as a reusable workflow so tests.yml shares the exact same
# workload. Release mode (strict=true) makes EVERY job blocking — including
# the Windows jobs that are non-blocking at PR time. If a Windows-only
# regression slipped past PR-time CI, this gate catches it before publish.
unit:
name: Unit
uses: ./.github/workflows/_unit-suite.yml
# The subc-core fetch needs the CK_CI_APP_* secrets (see tests.yml).
secrets: inherit
with:
strict: true
# Run the full E2E matrix (Linux Docker + Pi RPC + Windows native +
# macOS native) at release time. Reuses the same workflow tests.yml runs
# at PR time — single source of truth, so PR-time and release-time e2e
# can never drift. Runs IN PARALLEL with unit (they share no artifacts);
# every publishing job still `needs:` BOTH, so a regression in either
# blocks the release exactly as before. Release e2e keeps the default
# `release` cargo profile: the shipped fat-LTO binary shape is tested.
e2e:
name: E2E
uses: ./.github/workflows/_e2e-suite.yml
publish-crates:
name: Publish to crates.io
runs-on: ubuntu-latest
needs: [unit, e2e]
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: dtolnay/rust-toolchain@stable
# Sync Cargo.toml version to match the tag. Without this, `cargo publish`
# tries to publish whatever version is in HEAD's Cargo.toml — which is
# last-released-version on tag pushes (the tag does NOT carry a separate
# version commit; release.sh commits the version bump locally, but if
# someone retags HEAD without running release.sh, Cargo.toml stays
# stale). Mirrors the same step in publish-npm-platforms.
- name: Sync versions from tag
run: node scripts/version-sync.mjs --from-tag
- name: Publish to crates.io
# Allow re-runs of the same tag: treat "already exists" as success
# ONLY if the version we're publishing matches the tag. Otherwise the
# fallback silently masks the version-mismatch bug (we try to publish
# vN, it already exists, we say success even though we wanted vN+1).
#
# `agent-file-tools` depends on `aft-tokenizer` by path; cargo refuses
# to publish a crate whose path-only dep has no `version =`. Both
# crates therefore declare versions (synced by version-sync.mjs) and
# we publish `aft-tokenizer` first so `agent-file-tools` can resolve
# the published version on crates.io. The `--no-verify` flag on the
# tokenizer publish skips the build step (saves ~30s for a leaf crate
# with no consumers in workspace until `agent-file-tools` resolves it
# from crates.io after publish settles).
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
CARGO_VERSION=$(grep '^version' crates/aft/Cargo.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Tag $GITHUB_REF_NAME wants $TAG_VERSION but Cargo.toml has $CARGO_VERSION after version-sync. version-sync.mjs broken?"
exit 1
fi
# CRITICAL: use `true` (not `exit 0`) in the success branch of the
# || fallback. `exit 0` in `||` terminates the WHOLE script, so the
# second `cargo publish` is skipped silently when the first crate
# is already published — which is exactly the retag-recovery case
# this fallback exists for. (Repro: v0.28.1 retag — aft-tokenizer
# already existed, agent-file-tools never published.)
cargo publish --package aft-tokenizer \
|| { ec=$?; cargo publish --package aft-tokenizer --dry-run 2>&1 | grep -q "already exists" && true || exit $ec; }
# Wait briefly for the new tokenizer version to become visible on
# crates.io's sparse index before publishing `agent-file-tools` —
# otherwise the verify step in the second publish can race against
# index propagation and fail with "no matching package found".
sleep 30
cargo publish --package agent-file-tools \
|| { ec=$?; cargo publish --package agent-file-tools --dry-run 2>&1 | grep -q "already exists" && true || exit $ec; }
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# ---------------------------------------------------------------------------
# Binary builds: NO `needs:` — they start immediately, in parallel with the
# test stages. They are pure compute with no side effects (artifacts are
# only uploaded to the run); every job that PUBLISHES anything still gates
# on unit+e2e (publish-crates directly, github-release/publish-npm through
# publish-crates), so a test regression blocks the release exactly as
# before. A red run wastes some build minutes — accepted for cutting the
# release critical path roughly in half.
# ---------------------------------------------------------------------------
build-darwin-arm64:
name: Build macOS ARM64
runs-on: macos-26
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
# cache-bin:false is what makes rust-cache safe on macOS (see
# _unit-suite.yml unit-macos).
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
cache-on-failure: true
# Safety net: if someone retags HEAD without running release.sh,
# Cargo.toml.version stays stale. Bake the tag's version into the
# binary before cargo build so `aft --version` matches what the
# plugin expects.
- name: Sync versions from tag
run: node scripts/version-sync.mjs --from-tag
- name: Build
run: cargo build --release --target aarch64-apple-darwin
- name: Strip binary
run: strip target/aarch64-apple-darwin/release/aft
- uses: actions/upload-artifact@v4
with:
name: darwin-arm64
path: target/aarch64-apple-darwin/release/aft
if-no-files-found: error
build-darwin-x64:
name: Build macOS x64
runs-on: macos-26
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
cache-on-failure: true
- name: Sync versions from tag
run: node scripts/version-sync.mjs --from-tag
- name: Build
run: cargo build --release --target x86_64-apple-darwin
- name: Strip binary
run: strip target/x86_64-apple-darwin/release/aft
- uses: actions/upload-artifact@v4
with:
name: darwin-x64
path: target/x86_64-apple-darwin/release/aft
if-no-files-found: error
build-linux-arm64:
name: Build Linux ARM64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Sync versions from tag
run: node scripts/version-sync.mjs --from-tag
# Prebuilt binary via binstall — `cargo install cross` compiled it from
# source for 2-3 minutes on every release.
- name: Install cross
uses: taiki-e/install-action@v2
with:
tool: cross@0.2.5
# Use gnu target (not musl) so dlopen works for ONNX Runtime loading.
# musl produces static binaries where dlopen is a stub that always fails.
- name: Build
run: cross build --release --target aarch64-unknown-linux-gnu
- uses: actions/upload-artifact@v4
with:
name: linux-arm64
path: target/aarch64-unknown-linux-gnu/release/aft
if-no-files-found: error
build-linux-x64:
name: Build Linux x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: dtolnay/rust-toolchain@stable
- name: Sync versions from tag
run: node scripts/version-sync.mjs --from-tag
- name: Install cross
uses: taiki-e/install-action@v2
with:
tool: cross@0.2.5
# Use gnu target (not musl) so dlopen works for ONNX Runtime loading.
# musl produces static binaries where dlopen is a stub that always fails.
- name: Build
run: cross build --release --target x86_64-unknown-linux-gnu
- uses: actions/upload-artifact@v4
with:
name: linux-x64
path: target/x86_64-unknown-linux-gnu/release/aft
if-no-files-found: error
build-win32-x64:
name: Build Windows x64
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Sync versions from tag
run: node scripts/version-sync.mjs --from-tag
shell: pwsh
- name: Build
run: cargo build --release --target x86_64-pc-windows-msvc
- uses: actions/upload-artifact@v4
with:
name: win32-x64
path: target/x86_64-pc-windows-msvc/release/aft.exe
if-no-files-found: error
build-win32-arm64:
name: Build Windows ARM64
# v0.28: native ARM64 binary. Host is windows-latest (x64) which
# cross-compiles to aarch64-pc-windows-msvc via the MSVC ARM64 cross
# toolchain bundled in the Visual Studio Build Tools on the runner.
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-pc-windows-msvc
- name: Sync versions from tag
run: node scripts/version-sync.mjs --from-tag
shell: pwsh
- name: Build
run: cargo build --release --target aarch64-pc-windows-msvc
- uses: actions/upload-artifact@v4
with:
name: win32-arm64
path: target/aarch64-pc-windows-msvc/release/aft.exe
if-no-files-found: error
# npm publish runs AFTER github-release so that checksums.sha256 is already
# available on the GitHub release page when ensureBinary() runs during
# @cortexkit/aft-opencode@latest install.
publish-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs:
- build-darwin-arm64
- build-darwin-x64
- build-linux-arm64
- build-linux-x64
- build-win32-arm64
- build-win32-x64
- github-release
steps:
- uses: actions/checkout@v5
with:
# The v0.49 release gate proves the surface-manifest source commit is
# an ancestor of the candidate; a shallow (depth-1) clone makes
# merge-base fail for every historical commit and the gate
# fail-closed on a healthy release.
fetch-depth: 0
- uses: actions/setup-node@v5
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Ensure latest npm (for trusted publishing)
run: npm install -g npm@latest
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Download darwin-arm64 binary
uses: actions/download-artifact@v4
with:
name: darwin-arm64
path: packages/npm/darwin-arm64/bin
- name: Download darwin-x64 binary
uses: actions/download-artifact@v4
with:
name: darwin-x64
path: packages/npm/darwin-x64/bin
- name: Download linux-arm64 binary
uses: actions/download-artifact@v4
with:
name: linux-arm64
path: packages/npm/linux-arm64/bin
- name: Download linux-x64 binary
uses: actions/download-artifact@v4
with:
name: linux-x64
path: packages/npm/linux-x64/bin
- name: Download win32-x64 binary
uses: actions/download-artifact@v4
with:
name: win32-x64
path: packages/npm/win32-x64/bin
- name: Download win32-arm64 binary
uses: actions/download-artifact@v4
with:
name: win32-arm64
path: packages/npm/win32-arm64/bin
- name: Set binary permissions
run: |
chmod +x packages/npm/darwin-arm64/bin/aft
chmod +x packages/npm/darwin-x64/bin/aft
chmod +x packages/npm/linux-arm64/bin/aft
chmod +x packages/npm/linux-x64/bin/aft
- name: Sync versions from tag
run: node scripts/version-sync.mjs --from-tag
- name: Validate packages
run: node scripts/validate-packages.mjs
- name: Install workspace dependencies
run: bun install --frozen-lockfile
- name: Build aft-bridge
run: bun run build
working-directory: packages/aft-bridge
- name: Build OpenCode plugin
run: bun run build
working-directory: packages/opencode-plugin
- name: Build Pi plugin
run: bun run build
working-directory: packages/pi-plugin
- name: Build CLI
run: bun run build
working-directory: packages/aft-cli
- name: Fetch final v0.48.1 activation baseline
run: git fetch --force origin refs/tags/v0.48.1:refs/tags/v0.48.1
- name: Verify v0.49 artifact and release gates
run: node scripts/release-gate-v049.mjs --candidate --evidence docs/v0.49-release-evidence.json --require-platform-artifacts
# Uses npm Trusted Publishing (OIDC) — configured per-package on npmjs.com
- name: Publish platform packages
run: |
set -euo pipefail
publish_or_skip() {
local pkg_dir="$1"
local pkg_name
local version
pkg_name=$(node -p "require('./${pkg_dir}/package.json').name")
version=$(node -p "require('./${pkg_dir}/package.json').version")
if npm view "${pkg_name}@${version}" version >/dev/null 2>&1; then
echo "::notice::${pkg_name}@${version} already published; skipping"
return 0
fi
npm publish --access public --provenance "$pkg_dir"
}
for pkg in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-arm64 win32-x64; do
publish_or_skip "packages/npm/$pkg"
done
# aft-bridge MUST publish before the plugins, because @cortexkit/aft-opencode
# and @cortexkit/aft-pi depend on it. Plugins publish from local builds, so
# there's no install-time race here, but consumers installing @latest will
# need aft-bridge resolvable on npm.
#
# Tolerate `already published` so a re-run of the same tag (or the
# initial bootstrap publish that claims the package name with a
# token before Trusted Publishing is configured) doesn't fail the
# whole release. Mirrors the crates.io publish step above.
- name: Publish @cortexkit/aft-bridge
run: |
set -euo pipefail
PKG_NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
if npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1; then
echo "::notice::${PKG_NAME}@${VERSION} already published; skipping"
exit 0
fi
npm publish --access public --provenance
working-directory: packages/aft-bridge
- name: Publish @cortexkit/aft-opencode
run: |
set -euo pipefail
PKG_NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
if npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1; then
echo "::notice::${PKG_NAME}@${VERSION} already published; skipping"
exit 0
fi
npm publish --access public --provenance
working-directory: packages/opencode-plugin
- name: Publish @cortexkit/aft-pi
run: |
set -euo pipefail
PKG_NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
if npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1; then
echo "::notice::${PKG_NAME}@${VERSION} already published; skipping"
exit 0
fi
npm publish --access public --provenance
working-directory: packages/pi-plugin
- name: Publish @cortexkit/aft (unified CLI)
run: |
set -euo pipefail
PKG_NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
if npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1; then
echo "::notice::${PKG_NAME}@${VERSION} already published; skipping"
exit 0
fi
npm publish --access public --provenance
working-directory: packages/aft-cli
# GitHub release must complete before npm publish so that checksums.sha256
# is available when freshly-installed @cortexkit/aft-opencode@latest triggers
# ensureBinary(). crates.io publish is also gated so that a failed crate
# publish blocks the release — no point shipping binaries if the Rust crate
# isn't available yet.
github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs:
- build-darwin-arm64
- build-darwin-x64
- build-linux-arm64
- build-linux-x64
- build-win32-arm64
- build-win32-x64
- publish-crates
steps:
- uses: actions/checkout@v5
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release assets
run: |
mkdir -p release-assets
cp artifacts/darwin-arm64/aft release-assets/aft-darwin-arm64
cp artifacts/darwin-x64/aft release-assets/aft-darwin-x64
cp artifacts/linux-arm64/aft release-assets/aft-linux-arm64
cp artifacts/linux-x64/aft release-assets/aft-linux-x64
cp artifacts/win32-arm64/aft.exe release-assets/aft-win32-arm64.exe
cp artifacts/win32-x64/aft.exe release-assets/aft-win32-x64.exe
chmod +x release-assets/aft-*
- name: Generate checksums
run: |
cd release-assets
sha256sum aft-* > checksums.sha256
cat checksums.sha256
- name: Verify curated release notes exist
run: |
notes_file=".alfonso/release-notes/${GITHUB_REF_NAME}.md"
if [ ! -f "$notes_file" ]; then
echo "::error::Curated release notes missing: $notes_file"
echo "Draft them under .alfonso/release-notes/ before tagging the release."
exit 1
fi
echo "Using $notes_file as the release body."
wc -l "$notes_file"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: .alfonso/release-notes/${{ github.ref_name }}.md
files: release-assets/*
# Announce on Discord after the GitHub release is created.
#
# Why this lives here (not in discord-release.yml as `on: release: published`):
# GitHub Actions intentionally suppresses event-triggered workflow cascades
# from `GITHUB_TOKEN`-authored events. `softprops/action-gh-release@v2` uses
# the default `GITHUB_TOKEN`, so the `release: published` event it emits does
# not trigger downstream workflows. Inlining the Discord post here is the
# cleanest fix (vs rotating PATs for release creation).
#
# `continue-on-error: true` keeps Discord failures from rolling back a
# successful release — if the webhook is down, the announcement can be
# retried via `gh workflow run discord-release.yml ...`.
discord-announce:
name: Announce on Discord
runs-on: ubuntu-latest
# MUST wait for BOTH github-release AND publish-npm. Without `publish-npm`
# the announcement can fire while npm packages are still uploading,
# leaving users who click the release link unable to install the
# versions Discord just announced.
needs:
- github-release
- publish-npm
if: success()
continue-on-error: true
steps:
- uses: actions/checkout@v5
- name: Read curated release notes
id: notes
run: |
notes_file=".alfonso/release-notes/${GITHUB_REF_NAME}.md"
if [ ! -f "$notes_file" ]; then
echo "::error::Curated release notes missing: $notes_file"
exit 1
fi
# Use a random delimiter to guard against any string in the notes
# accidentally matching the heredoc terminator. Also ensure the file
# ends with a newline so `cat`'s output doesn't glue onto the EOF
# marker (v0.26.3 release notes had no trailing newline, which
# produced "Matching delimiter not found" and skipped the Discord
# announcement entirely).
eof="EOF_$(openssl rand -hex 12)"
{
printf 'body<<%s\n' "$eof"
cat "$notes_file"
# Guarantee a newline before the closing delimiter, even if the
# file itself doesn't end with one.
printf '\n%s\n' "$eof"
} >> "$GITHUB_OUTPUT"
- name: Post to Discord
uses: SethCohen/github-releases-to-discord@v1
with:
webhook_url: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
# Cortexkit teal — decimal value of #2C4A7C.
color: "2902140"
username: "AFT Releases"
# Reduce h1/h2 headings to fit Discord embed limits comfortably.
reduce_headings: true
# Keep PR/issue links — useful context in Discord.
remove_github_reference_links: false
footer_title: "Changelog"
footer_timestamp: true
release_name: ${{ github.ref_name }}
release_body: ${{ steps.notes.outputs.body }}
release_html_url: https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}