Release Binaries #46
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 | |
| jobs: | |
| web-ui-gates: | |
| name: Web UI Gates | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22.x" | |
| cache: npm | |
| cache-dependency-path: crates/orchestrator-web-server/web-ui/package-lock.json | |
| - name: Setup Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Install web-ui dependencies | |
| working-directory: crates/orchestrator-web-server/web-ui | |
| run: npm ci | |
| - name: Run web-ui tests | |
| working-directory: crates/orchestrator-web-server/web-ui | |
| run: npm run test | |
| - name: Build web-ui bundle | |
| working-directory: crates/orchestrator-web-server/web-ui | |
| run: npm run build | |
| - name: Run web-ui smoke E2E | |
| id: smoke | |
| working-directory: crates/orchestrator-web-server/web-ui | |
| env: | |
| SMOKE_ARTIFACT_DIR: ${{ github.workspace }}/crates/orchestrator-web-server/web-ui/.smoke-artifacts | |
| run: npm run test:e2e:smoke | |
| - name: Upload smoke diagnostics | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: release-web-ui-smoke-failure-${{ github.run_id }}-${{ github.run_attempt }} | |
| path: ${{ github.workspace }}/crates/orchestrator-web-server/web-ui/.smoke-artifacts | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| build: | |
| name: Build (${{ matrix.target }}) | |
| runs-on: ${{ matrix.os }} | |
| needs: web-ui-gates | |
| 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: 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 ao-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/ao --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=(ao agent-runner llm-cli-wrapper ao-oai-runner ao-workflow-runner) | |
| 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 = ["ao", "agent-runner", "llm-cli-wrapper", "ao-oai-runner", "ao-workflow-runner"] | |
| metadata = { | |
| "schema": "ao.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=(ao agent-runner llm-cli-wrapper ao-oai-runner ao-workflow-runner 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 = ["ao", "agent-runner", "llm-cli-wrapper", "ao-oai-runner", "ao-workflow-runner"] | |
| required_pairs = { | |
| "schema": "ao.release.v1", | |
| "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 = @("ao", "agent-runner", "llm-cli-wrapper", "ao-oai-runner", "ao-workflow-runner") | |
| $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 = "ao.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 = @("ao", "agent-runner", "llm-cli-wrapper", "ao-oai-runner", "ao-workflow-runner") | |
| $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 | |
| if ($Metadata.schema -ne "ao.release.v1") { | |
| 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 release on public repo | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.AO_PUBLIC_RELEASE_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| VERSION="${GITHUB_REF_NAME}" | |
| gh release create "${VERSION}" \ | |
| --repo launchapp-dev/ao \ | |
| --title "AO ${VERSION}" \ | |
| --notes "Release ${VERSION} — download the archive for your platform and run install.sh, or use: | |
| \`\`\`bash | |
| curl -fsSL https://raw.githubusercontent.com/launchapp-dev/ao/main/install.sh | bash | |
| \`\`\`" \ | |
| dist/release-assets/* |