Skip to content

Merge pull request #245 from launchapp-dev/release/v0.5.16 #136

Merge pull request #245 from launchapp-dev/release/v0.5.16

Merge pull request #245 from launchapp-dev/release/v0.5.16 #136

Workflow file for this run

name: Release Binaries
on:
push:
tags:
- "v*"
branches:
- "version/**"
workflow_dispatch:
inputs:
dry_run_note:
description: Optional context for manual non-publishing release validation.
required: false
type: string
permissions:
contents: read
env:
# GitHub Actions runner Node 20 -> Node 24 deprecation (effective 2026-06-02).
# Forces JS-based actions onto Node 24 ahead of the cutover.
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
name: Build (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
archive_ext: tar.gz
- os: macos-15-intel
target: x86_64-apple-darwin
archive_ext: tar.gz
- os: macos-14
target: aarch64-apple-darwin
archive_ext: tar.gz
- os: windows-latest
target: x86_64-pc-windows-msvc
archive_ext: zip
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux build deps (libdbus for keyring crate)
if: contains(matrix.target, 'linux')
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- name: Cache Cargo build artifacts
uses: Swatinem/rust-cache@v2
- name: Compute release version
id: version
shell: bash
run: |
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
else
BRANCH_SANITIZED="$(echo "${GITHUB_REF_NAME}" | sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^-+//; s/-+$//')"
if [[ -z "${BRANCH_SANITIZED}" ]]; then
BRANCH_SANITIZED="ref"
fi
VERSION="${BRANCH_SANITIZED}-${GITHUB_SHA::7}"
fi
echo "value=${VERSION}" >> "${GITHUB_OUTPUT}"
echo "build_number=${GITHUB_RUN_NUMBER}" >> "${GITHUB_OUTPUT}"
echo "build_id=${GITHUB_RUN_ID}" >> "${GITHUB_OUTPUT}"
- name: Enforce Cargo.toml version matches tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF_NAME#v}"
CARGO_VERSION="$(grep '^version' crates/orchestrator-cli/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')"
if [[ "${TAG_VERSION}" != "${CARGO_VERSION}" ]]; then
echo "::error::Version mismatch: tag is v${TAG_VERSION} but Cargo.toml has ${CARGO_VERSION}"
echo "Update crates/orchestrator-cli/Cargo.toml version to ${TAG_VERSION} before tagging."
exit 1
fi
echo "Version verified: v${CARGO_VERSION}"
- name: Build release binaries
shell: bash
run: |
cargo animus-bin-build-release --locked --target ${{ matrix.target }}
- name: Verify binary version
if: startsWith(github.ref, 'refs/tags/v') && !contains(matrix.target, 'windows')
shell: bash
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF_NAME#v}"
BIN_VERSION="$(target/${{ matrix.target }}/release/animus --version 2>&1 | awk '{print $2}')"
if [[ "${BIN_VERSION}" != "${TAG_VERSION}" ]]; then
echo "::error::Binary reports version ${BIN_VERSION} but tag is v${TAG_VERSION}"
exit 1
fi
echo "Binary version verified: ${BIN_VERSION}"
- name: Package artifact (unix)
if: runner.os != 'Windows'
shell: bash
env:
DRY_RUN_NOTE: ${{ github.event.inputs.dry_run_note || '' }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.value }}"
TARGET="${{ matrix.target }}"
STAGE_DIR="ao-${VERSION}-${TARGET}"
mkdir -p "${STAGE_DIR}"
BINARIES=(animus animus-mcp-proxy animus-hook)
for binary in "${BINARIES[@]}"; do
cp "target/${TARGET}/release/${binary}" "${STAGE_DIR}/"
done
export VERSION TARGET STAGE_DIR BUILD_NUMBER BUILD_ID
BUILD_NUMBER="${{ steps.version.outputs.build_number }}"
BUILD_ID="${{ steps.version.outputs.build_id }}"
python3 - <<'PY'
import json
import os
from pathlib import Path
binaries = ["animus", "animus-mcp-proxy", "animus-hook"]
metadata = {
"schema": "animus.release.v1",
"version": os.environ["VERSION"],
"target": os.environ["TARGET"],
"build_number": int(os.environ.get("BUILD_NUMBER", "0")),
"build_id": os.environ.get("BUILD_ID", ""),
"git_ref": os.environ["GITHUB_REF"],
"git_sha": os.environ["GITHUB_SHA"],
"event_name": os.environ["GITHUB_EVENT_NAME"],
"dry_run_note": os.environ.get("DRY_RUN_NOTE", ""),
"binaries": binaries,
"files": binaries,
}
output = Path(os.environ["STAGE_DIR"]) / "release-metadata.json"
output.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
tar -czf "${STAGE_DIR}.tar.gz" "${STAGE_DIR}"
- name: Validate staged artifact contract (unix)
if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.value }}"
TARGET="${{ matrix.target }}"
STAGE_DIR="ao-${VERSION}-${TARGET}"
ARCHIVE_PATH="${STAGE_DIR}.tar.gz"
REQUIRED_FILES=(animus animus-mcp-proxy animus-hook release-metadata.json)
for file in "${REQUIRED_FILES[@]}"; do
if [[ ! -f "${STAGE_DIR}/${file}" ]]; then
echo "missing required staged file: ${STAGE_DIR}/${file}" >&2
exit 1
fi
done
if [[ ! -f "${ARCHIVE_PATH}" ]]; then
echo "missing expected archive: ${ARCHIVE_PATH}" >&2
exit 1
fi
export VERSION TARGET STAGE_DIR
python3 - <<'PY'
import json
import os
from pathlib import Path
stage_dir = Path(os.environ["STAGE_DIR"])
metadata_path = stage_dir / "release-metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
expected_binaries = ["animus", "animus-mcp-proxy", "animus-hook"]
accepted_schemas = ("animus.release.v1", "ao.release.v1")
if metadata.get("schema") not in accepted_schemas:
raise SystemExit(
f"release metadata mismatch for schema: expected one of {accepted_schemas!r}, got {metadata.get('schema')!r}"
)
required_pairs = {
"version": os.environ["VERSION"],
"target": os.environ["TARGET"],
"binaries": expected_binaries,
"files": expected_binaries,
}
for key, expected in required_pairs.items():
if metadata.get(key) != expected:
raise SystemExit(
f"release metadata mismatch for {key}: expected {expected!r}, got {metadata.get(key)!r}"
)
for key in ("git_ref", "git_sha", "event_name", "dry_run_note"):
if key not in metadata:
raise SystemExit(f"release metadata missing key: {key}")
PY
ARCHIVE_LIST="$(tar -tzf "${ARCHIVE_PATH}")"
for file in "${REQUIRED_FILES[@]}"; do
if ! printf '%s\n' "${ARCHIVE_LIST}" | grep -Fqx "${STAGE_DIR}/${file}"; then
echo "archive missing required entry: ${STAGE_DIR}/${file}" >&2
exit 1
fi
done
- name: Package artifact (windows)
if: runner.os == 'Windows'
shell: pwsh
env:
DRY_RUN_NOTE: ${{ github.event.inputs.dry_run_note || '' }}
run: |
$Version = "${{ steps.version.outputs.value }}"
$Target = "${{ matrix.target }}"
$StageDir = "ao-$Version-$Target"
$Binaries = @("animus", "animus-mcp-proxy", "animus-hook")
$BinaryFiles = @($Binaries | ForEach-Object { "$_.exe" })
New-Item -ItemType Directory -Path $StageDir -Force | Out-Null
foreach ($Binary in $BinaryFiles) {
Copy-Item "target/$Target/release/$Binary" "$StageDir/"
}
$Metadata = @{
schema = "animus.release.v1"
version = $Version
target = $Target
git_ref = $env:GITHUB_REF
git_sha = $env:GITHUB_SHA
event_name = $env:GITHUB_EVENT_NAME
dry_run_note = $env:DRY_RUN_NOTE
binaries = $Binaries
files = $BinaryFiles
}
$Metadata | ConvertTo-Json -Depth 4 | Set-Content -Path "$StageDir/release-metadata.json" -Encoding utf8NoBOM
Compress-Archive -Path $StageDir -DestinationPath "$StageDir.zip" -Force
- name: Validate staged artifact contract (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$Version = "${{ steps.version.outputs.value }}"
$Target = "${{ matrix.target }}"
$StageDir = "ao-$Version-$Target"
$ArchivePath = "$StageDir.zip"
$Binaries = @("animus", "animus-mcp-proxy", "animus-hook")
$BinaryFiles = @($Binaries | ForEach-Object { "$_.exe" })
$RequiredFiles = @($BinaryFiles) + "release-metadata.json"
foreach ($File in $RequiredFiles) {
if (-not (Test-Path "$StageDir/$File")) {
throw "Missing required staged file: $StageDir/$File"
}
}
if (-not (Test-Path $ArchivePath)) {
throw "Missing expected archive: $ArchivePath"
}
$Metadata = Get-Content "$StageDir/release-metadata.json" -Raw | ConvertFrom-Json
$AcceptedSchemas = @("animus.release.v1", "ao.release.v1")
if (-not ($AcceptedSchemas -contains $Metadata.schema)) {
throw "release metadata mismatch for schema"
}
if ($Metadata.version -ne $Version) {
throw "release metadata mismatch for version"
}
if ($Metadata.target -ne $Target) {
throw "release metadata mismatch for target"
}
if ((@($Metadata.binaries) -join ",") -ne ($Binaries -join ",")) {
throw "release metadata mismatch for binaries"
}
if ((@($Metadata.files) -join ",") -ne ($BinaryFiles -join ",")) {
throw "release metadata mismatch for files"
}
foreach ($Key in @("git_ref", "git_sha", "event_name", "dry_run_note")) {
if ($null -eq $Metadata.$Key) {
throw "release metadata missing key: $Key"
}
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
$Zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath)
try {
$ZipEntries = $Zip.Entries | ForEach-Object { $_.FullName.TrimEnd("/") }
}
finally {
$Zip.Dispose()
}
foreach ($File in $RequiredFiles) {
$EntryPath = "$StageDir/$File"
if ($ZipEntries -notcontains $EntryPath) {
throw "Archive missing required entry: $EntryPath"
}
}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ao-${{ steps.version.outputs.value }}-${{ matrix.target }}
path: ao-${{ steps.version.outputs.value }}-${{ matrix.target }}.${{ matrix.archive_ext }}
if-no-files-found: error
publish:
name: Publish GitHub release (tags only)
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: dist
- name: Generate checksums
shell: bash
run: |
set -euo pipefail
RELEASE_ASSETS_DIR="dist/release-assets"
rm -rf "${RELEASE_ASSETS_DIR}"
mapfile -d '' ARCHIVES < <(find dist -type f \( -name '*.tar.gz' -o -name '*.zip' \) ! -path "${RELEASE_ASSETS_DIR}/*" -print0 | LC_ALL=C sort -z)
if [[ "${#ARCHIVES[@]}" -eq 0 ]]; then
echo "no release archives found after download-artifact step" >&2
exit 1
fi
mkdir -p "${RELEASE_ASSETS_DIR}"
BASENAMES=()
for archive in "${ARCHIVES[@]}"; do
base_name="$(basename "${archive}")"
if [[ -e "${RELEASE_ASSETS_DIR}/${base_name}" ]]; then
echo "duplicate archive basename detected: ${base_name}" >&2
exit 1
fi
cp "${archive}" "${RELEASE_ASSETS_DIR}/${base_name}"
BASENAMES+=("${base_name}")
done
(
cd "${RELEASE_ASSETS_DIR}"
mapfile -t BASENAMES_SORTED < <(printf '%s\n' "${BASENAMES[@]}" | LC_ALL=C sort)
sha256sum "${BASENAMES_SORTED[@]}" > SHA256SUMS.txt
)
- name: Publish release (private repo)
uses: softprops/action-gh-release@v2
with:
files: |
dist/release-assets/*.tar.gz
dist/release-assets/*.zip
dist/release-assets/SHA256SUMS.txt
generate_release_notes: true
publish-public:
name: Publish to public repo (tags only)
runs-on: ubuntu-latest
needs: publish
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: dist
- name: Collect release assets
shell: bash
run: |
set -euo pipefail
RELEASE_ASSETS_DIR="dist/release-assets"
rm -rf "${RELEASE_ASSETS_DIR}"
mkdir -p "${RELEASE_ASSETS_DIR}"
mapfile -d '' ARCHIVES < <(find dist -type f \( -name '*.tar.gz' -o -name '*.zip' \) ! -path "${RELEASE_ASSETS_DIR}/*" -print0 | LC_ALL=C sort -z)
for archive in "${ARCHIVES[@]}"; do
cp "${archive}" "${RELEASE_ASSETS_DIR}/$(basename "${archive}")"
done
(
cd "${RELEASE_ASSETS_DIR}"
sha256sum *.tar.gz *.zip 2>/dev/null | LC_ALL=C sort -k2 > SHA256SUMS.txt
)
- name: Create or update release on public repo
shell: bash
env:
GH_TOKEN: ${{ secrets.AO_PUBLIC_RELEASE_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME}"
REPO="launchapp-dev/animus-cli"
NOTES="Release ${VERSION} — download the archive for your platform and run install.sh, or use:
\`\`\`bash
curl -fsSL https://raw.githubusercontent.com/launchapp-dev/animus-cli/main/scripts/install.sh | bash
\`\`\`"
# The preceding \`publish\` job already created the release in this
# same repo via softprops/action-gh-release. Detect that and either
# update assets in place or create the release from scratch (the
# latter covers manual reruns where the release was deleted).
if gh release view "${VERSION}" --repo "${REPO}" >/dev/null 2>&1; then
echo "Release ${VERSION} already exists on ${REPO} — uploading assets with --clobber"
gh release upload "${VERSION}" --repo "${REPO}" --clobber dist/release-assets/*
else
gh release create "${VERSION}" \
--repo "${REPO}" \
--title "Animus ${VERSION}" \
--notes "${NOTES}" \
dist/release-assets/*
fi