Merge pull request #245 from launchapp-dev/release/v0.5.16 #136
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |