name: Pub Release on: push: tags: ["v*"] workflow_dispatch: inputs: release_ref: description: "Git ref (branch, tag, or SHA) to build" required: false default: "main" type: string publish_release: description: "Publish a GitHub release (false = verification build only)" required: false default: false type: boolean release_tag: description: "Existing release tag (required when publish_release=true), e.g. v0.1.1" required: false default: "" type: string draft: description: "Create release as draft (manual publish only)" required: false default: true type: boolean schedule: # Weekly release-readiness verification on default branch (no publish) - cron: "17 8 * * 1" concurrency: group: release-${{ github.ref || github.run_id }} cancel-in-progress: false permissions: contents: write packages: read id-token: write # Required for cosign keyless signing via OIDC env: CARGO_TERM_COLOR: always jobs: prepare: name: Prepare Release Context runs-on: blacksmith-2vcpu-ubuntu-2404 outputs: release_ref: ${{ steps.vars.outputs.release_ref }} release_tag: ${{ steps.vars.outputs.release_tag }} publish_release: ${{ steps.vars.outputs.publish_release }} draft_release: ${{ steps.vars.outputs.draft_release }} steps: - name: Resolve release inputs id: vars shell: bash run: | set -euo pipefail event_name="${GITHUB_EVENT_NAME}" publish_release="false" draft_release="false" semver_pattern='^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$' if [[ "$event_name" == "push" ]]; then release_ref="${GITHUB_REF_NAME}" release_tag="${GITHUB_REF_NAME}" publish_release="true" elif [[ "$event_name" == "workflow_dispatch" ]]; then release_ref="${{ inputs.release_ref }}" publish_release="${{ inputs.publish_release }}" draft_release="${{ inputs.draft }}" if [[ "$publish_release" == "true" ]]; then release_tag="${{ inputs.release_tag }}" if [[ -z "$release_tag" ]]; then echo "::error::release_tag is required when publish_release=true" exit 1 fi release_ref="$release_tag" else release_tag="verify-${GITHUB_SHA::12}" fi else # schedule release_ref="main" release_tag="verify-${GITHUB_SHA::12}" fi if [[ "$publish_release" == "true" ]]; then if [[ ! "$release_tag" =~ $semver_pattern ]]; then echo "::error::release_tag must match semver-like format (vX.Y.Z[-suffix])" exit 1 fi if ! git ls-remote --exit-code --tags "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${release_tag}" >/dev/null; then echo "::error::Tag ${release_tag} does not exist on origin. Push the tag first, then rerun manual publish." exit 1 fi # Guardrail: release tags must resolve to commits already reachable from main. tmp_repo="$(mktemp -d)" trap 'rm -rf "$tmp_repo"' EXIT git -C "$tmp_repo" init -q git -C "$tmp_repo" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" git -C "$tmp_repo" fetch --quiet --filter=blob:none origin main "refs/tags/${release_tag}:refs/tags/${release_tag}" if ! git -C "$tmp_repo" merge-base --is-ancestor "refs/tags/${release_tag}" "origin/main"; then echo "::error::Tag ${release_tag} is not reachable from origin/main. Release tags must be cut from main." exit 1 fi # Guardrail: release tag and Cargo package version must stay aligned. tag_version="${release_tag#v}" cargo_version="$(git -C "$tmp_repo" show "refs/tags/${release_tag}:Cargo.toml" | sed -n 's/^version = "\([^"]*\)"/\1/p' | head -n1)" if [[ -z "$cargo_version" ]]; then echo "::error::Unable to read Cargo package version from ${release_tag}:Cargo.toml" exit 1 fi if [[ "$cargo_version" != "$tag_version" ]]; then echo "::error::Tag ${release_tag} does not match Cargo.toml version (${cargo_version})." echo "::error::Bump Cargo.toml version first, then create/publish the matching tag." exit 1 fi fi { echo "release_ref=${release_ref}" echo "release_tag=${release_tag}" echo "publish_release=${publish_release}" echo "draft_release=${draft_release}" } >> "$GITHUB_OUTPUT" { echo "### Release Context" echo "- event: ${event_name}" echo "- release_ref: ${release_ref}" echo "- release_tag: ${release_tag}" echo "- publish_release: ${publish_release}" echo "- draft_release: ${draft_release}" } >> "$GITHUB_STEP_SUMMARY" build-release: name: Build ${{ matrix.target }} needs: [prepare] runs-on: ${{ matrix.os }} timeout-minutes: 40 strategy: fail-fast: false matrix: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu artifact: zeroclaw archive_ext: tar.gz cross_compiler: "" linker_env: "" linker: "" - os: ubuntu-latest target: aarch64-unknown-linux-gnu artifact: zeroclaw archive_ext: tar.gz cross_compiler: gcc-aarch64-linux-gnu linker_env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER linker: aarch64-linux-gnu-gcc - os: ubuntu-latest target: armv7-unknown-linux-gnueabihf artifact: zeroclaw archive_ext: tar.gz cross_compiler: gcc-arm-linux-gnueabihf linker_env: CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER linker: arm-linux-gnueabihf-gcc - os: ubuntu-latest target: armv7-linux-androideabi artifact: zeroclaw archive_ext: tar.gz cross_compiler: "" linker_env: "" linker: "" android_ndk: true android_api: 21 - os: ubuntu-latest target: aarch64-linux-android artifact: zeroclaw archive_ext: tar.gz cross_compiler: "" linker_env: "" linker: "" android_ndk: true android_api: 21 - os: macos-15-intel target: x86_64-apple-darwin artifact: zeroclaw archive_ext: tar.gz cross_compiler: "" linker_env: "" linker: "" - os: macos-14 target: aarch64-apple-darwin artifact: zeroclaw archive_ext: tar.gz cross_compiler: "" linker_env: "" linker: "" - os: windows-latest target: x86_64-pc-windows-msvc artifact: zeroclaw.exe archive_ext: zip cross_compiler: "" linker_env: "" linker: "" steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.prepare.outputs.release_ref }} - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: toolchain: 1.92.0 targets: ${{ matrix.target }} - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 if: runner.os != 'Windows' - name: Install cross-compilation toolchain (Linux) if: runner.os == 'Linux' && matrix.cross_compiler != '' run: | sudo apt-get update -qq sudo apt-get install -y ${{ matrix.cross_compiler }} - name: Setup Android NDK if: matrix.android_ndk uses: nttld/setup-ndk@v1 id: setup-ndk with: ndk-version: r26d add-to-path: true - name: Configure Android toolchain if: matrix.android_ndk run: | echo "Setting up Android NDK toolchain for ${{ matrix.target }}" NDK_HOME="${{ steps.setup-ndk.outputs.ndk-path }}" TOOLCHAIN="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" # Add to path for linker resolution echo "$TOOLCHAIN" >> "$GITHUB_PATH" # Set linker environment variables if [[ "${{ matrix.target }}" == "armv7-linux-androideabi" ]]; then echo "CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=${TOOLCHAIN}/armv7a-linux-androideabi${{ matrix.android_api }}-clang" >> "$GITHUB_ENV" elif [[ "${{ matrix.target }}" == "aarch64-linux-android" ]]; then echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${TOOLCHAIN}/aarch64-linux-android${{ matrix.android_api }}-clang" >> "$GITHUB_ENV" fi - name: Build release shell: bash env: LINKER_ENV: ${{ matrix.linker_env }} LINKER: ${{ matrix.linker }} run: | if [ -n "$LINKER_ENV" ] && [ -n "$LINKER" ]; then echo "Using linker override: $LINKER_ENV=$LINKER" export "$LINKER_ENV=$LINKER" fi cargo build --profile release-fast --locked --target ${{ matrix.target }} - name: Check binary size (Unix) if: runner.os != 'Windows' run: bash scripts/ci/check_binary_size.sh "target/${{ matrix.target }}/release-fast/${{ matrix.artifact }}" "${{ matrix.target }}" - name: Package (Unix) if: runner.os != 'Windows' run: | cd target/${{ matrix.target }}/release-fast tar czf ../../../zeroclaw-${{ matrix.target }}.${{ matrix.archive_ext }} ${{ matrix.artifact }} - name: Package (Windows) if: runner.os == 'Windows' run: | cd target/${{ matrix.target }}/release-fast 7z a ../../../zeroclaw-${{ matrix.target }}.${{ matrix.archive_ext }} ${{ matrix.artifact }} - name: Upload artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: zeroclaw-${{ matrix.target }} path: zeroclaw-${{ matrix.target }}.${{ matrix.archive_ext }} retention-days: 7 verify-artifacts: name: Verify Artifact Set needs: [prepare, build-release] runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Download all artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: artifacts - name: Validate expected archives shell: bash run: | set -euo pipefail expected=( "zeroclaw-x86_64-unknown-linux-gnu.tar.gz" "zeroclaw-aarch64-unknown-linux-gnu.tar.gz" "zeroclaw-armv7-unknown-linux-gnueabihf.tar.gz" "zeroclaw-armv7-linux-androideabi.tar.gz" "zeroclaw-aarch64-linux-android.tar.gz" "zeroclaw-x86_64-apple-darwin.tar.gz" "zeroclaw-aarch64-apple-darwin.tar.gz" "zeroclaw-x86_64-pc-windows-msvc.zip" ) missing=0 for file in "${expected[@]}"; do if ! find artifacts -type f -name "$file" -print -quit | grep -q .; then echo "::error::Missing release archive: $file" missing=1 fi done if [ "$missing" -ne 0 ]; then exit 1 fi echo "All expected release archives are present." publish: name: Publish Release if: needs.prepare.outputs.publish_release == 'true' needs: [prepare, verify-artifacts] runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 45 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.prepare.outputs.release_ref }} - name: Download all artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: artifacts - name: Install syft run: | curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin - name: Generate SBOM (CycloneDX) run: | syft dir:. --source-name zeroclaw -o cyclonedx-json=artifacts/zeroclaw.cdx.json -o spdx-json=artifacts/zeroclaw.spdx.json { echo "### SBOM Generated" echo "- CycloneDX: zeroclaw.cdx.json" echo "- SPDX: zeroclaw.spdx.json" } >> "$GITHUB_STEP_SUMMARY" - name: Attach license and notice files run: | cp LICENSE-APACHE artifacts/LICENSE-APACHE cp LICENSE-MIT artifacts/LICENSE-MIT cp NOTICE artifacts/NOTICE - name: Generate SHA256 checksums run: | cd artifacts find . -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.cdx.json' -o -name '*.spdx.json' -o -name 'LICENSE-APACHE' -o -name 'LICENSE-MIT' -o -name 'NOTICE' \) -exec sha256sum {} + | sed 's| \./[^/]*/| |' > SHA256SUMS echo "Generated checksums:" cat SHA256SUMS - name: Install cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - name: Sign artifacts with cosign (keyless) shell: bash run: | set -euo pipefail while IFS= read -r -d '' file; do cosign sign-blob --yes \ --bundle="${file}.sigstore.json" \ --output-signature="${file}.sig" \ --output-certificate="${file}.pem" \ "$file" done < <(find artifacts -type f ! -name '*.sig' ! -name '*.pem' ! -name '*.sigstore.json' -print0) - name: Verify GHCR release tag availability shell: bash env: RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} run: | set -euo pipefail repo="${GITHUB_REPOSITORY,,}" manifest_url="https://ghcr.io/v2/${repo}/manifests/${RELEASE_TAG}" accept_header="application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json" max_attempts=75 sleep_seconds=20 for attempt in $(seq 1 "$max_attempts"); do token_resp="$(curl -sS "https://ghcr.io/token?scope=repository:${repo}:pull" || true)" token="$(echo "$token_resp" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" if [ -z "$token" ]; then code="000" else code="$(curl -sS -o /tmp/ghcr-release-manifest.json -w "%{http_code}" \ -H "Authorization: Bearer ${token}" \ -H "Accept: ${accept_header}" \ "${manifest_url}" || true)" fi if [ "$code" = "200" ]; then echo "GHCR release tag is available: ${repo}:${RELEASE_TAG}" exit 0 fi if [ "$attempt" -lt "$max_attempts" ]; then echo "Waiting for GHCR tag ${repo}:${RELEASE_TAG} (attempt ${attempt}/${max_attempts}, HTTP ${code})..." sleep "$sleep_seconds" fi done echo "::error::GHCR tag ${repo}:${RELEASE_TAG} was not available before release publish timeout." cat /tmp/ghcr-release-manifest.json || true exit 1 - name: Create GitHub Release uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: ${{ needs.prepare.outputs.release_tag }} draft: ${{ needs.prepare.outputs.draft_release == 'true' }} generate_release_notes: true files: | artifacts/**/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}