diff --git a/.github/workflows/ci-run.yml b/.github/workflows/ci-run.yml index d637d93c8..e4baa46b5 100644 --- a/.github/workflows/ci-run.yml +++ b/.github/workflows/ci-run.yml @@ -57,6 +57,9 @@ jobs: RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target steps: + - name: Capture lint job start timestamp + shell: bash + run: echo "CI_JOB_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV" - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 @@ -75,7 +78,8 @@ jobs: - name: Ensure cargo component shell: bash run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0 - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + - id: rust-cache + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 with: prefix-key: ci-run-check cache-bin: false @@ -85,6 +89,19 @@ jobs: env: BASE_SHA: ${{ needs.changes.outputs.base_sha }} run: ./scripts/ci/rust_strict_delta_gate.sh + - name: Publish lint telemetry + if: always() + shell: bash + run: | + set -euo pipefail + now="$(date +%s)" + start="${CI_JOB_STARTED_AT:-$now}" + elapsed="$((now - start))" + { + echo "### CI Telemetry: lint" + echo "- rust-cache hit: \`${{ steps.rust-cache.outputs.cache-hit || 'unknown' }}\`" + echo "- Duration (s): \`${elapsed}\`" + } >> "$GITHUB_STEP_SUMMARY" workspace-check: name: Workspace Check @@ -143,6 +160,9 @@ jobs: RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target steps: + - name: Capture test job start timestamp + shell: bash + run: echo "CI_JOB_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV" - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Ensure C toolchain shell: bash @@ -158,7 +178,8 @@ jobs: - name: Ensure cargo component shell: bash run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0 - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + - id: rust-cache + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 with: prefix-key: ci-run-check cache-bin: false @@ -210,10 +231,15 @@ jobs: if [ -f artifacts/flake-probe.json ]; then status=$(python3 -c "import json; print(json.load(open('artifacts/flake-probe.json'))['status'])") flake=$(python3 -c "import json; print(json.load(open('artifacts/flake-probe.json'))['flake_suspected'])") + now="$(date +%s)" + start="${CI_JOB_STARTED_AT:-$now}" + elapsed="$((now - start))" { echo "### Test Flake Probe" echo "- Status: \`${status}\`" echo "- Flake suspected: \`${flake}\`" + echo "- rust-cache hit: \`${{ steps.rust-cache.outputs.cache-hit || 'unknown' }}\`" + echo "- Duration (s): \`${elapsed}\`" } >> "$GITHUB_STEP_SUMMARY" fi - name: Upload flake probe artifact @@ -263,6 +289,9 @@ jobs: CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target steps: + - name: Capture build job start timestamp + shell: bash + run: echo "CI_JOB_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV" - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Ensure C toolchain shell: bash @@ -278,7 +307,8 @@ jobs: - name: Ensure cargo component shell: bash run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0 - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + - id: rust-cache + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 with: prefix-key: ci-run-build cache-targets: true @@ -294,6 +324,83 @@ jobs: BINARY_SIZE_ADVISORY_MB: 20 BINARY_SIZE_TARGET_MB: 5 run: bash scripts/ci/check_binary_size.sh target/release-fast/zeroclaw + - name: Publish build telemetry + if: always() + shell: bash + run: | + set -euo pipefail + now="$(date +%s)" + start="${CI_JOB_STARTED_AT:-$now}" + elapsed="$((now - start))" + { + echo "### CI Telemetry: build" + echo "- rust-cache hit: \`${{ steps.rust-cache.outputs.cache-hit || 'unknown' }}\`" + echo "- Duration (s): \`${elapsed}\`" + } >> "$GITHUB_STEP_SUMMARY" + + binary-size-regression: + name: Binary Size Regression (PR) + needs: [changes] + if: github.event_name == 'pull_request' && needs.changes.outputs.rust_changed == 'true' + runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + timeout-minutes: 120 + env: + CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo + RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup + CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target-head + steps: + - name: Capture binary-size regression job start timestamp + shell: bash + run: echo "CI_JOB_STARTED_AT=$(date +%s)" >> "$GITHUB_ENV" + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - name: Ensure C toolchain + shell: bash + run: bash ./scripts/ci/ensure_c_toolchain.sh + - name: Self-heal Rust toolchain cache + shell: bash + run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0 + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.92.0 + - name: Ensure C toolchain for Rust builds + run: ./scripts/ci/ensure_cc.sh + - name: Ensure cargo component + shell: bash + run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0 + - id: rust-cache + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + with: + prefix-key: ci-run-binary-size-regression + cache-bin: false + - name: Build head binary + shell: bash + run: cargo build --profile release-fast --locked --bin zeroclaw + - name: Compare binary size against base branch + shell: bash + env: + BASE_SHA: ${{ needs.changes.outputs.base_sha }} + BINARY_SIZE_REGRESSION_MAX_PERCENT: 10 + run: | + set -euo pipefail + bash scripts/ci/check_binary_size_regression.sh \ + "$BASE_SHA" \ + "$CARGO_TARGET_DIR/release-fast/zeroclaw" \ + "${BINARY_SIZE_REGRESSION_MAX_PERCENT}" + - name: Publish binary-size regression telemetry + if: always() + shell: bash + run: | + set -euo pipefail + now="$(date +%s)" + start="${CI_JOB_STARTED_AT:-$now}" + elapsed="$((now - start))" + { + echo "### CI Telemetry: binary-size-regression" + echo "- rust-cache hit: \`${{ steps.rust-cache.outputs.cache-hit || 'unknown' }}\`" + echo "- Duration (s): \`${elapsed}\`" + } >> "$GITHUB_STEP_SUMMARY" cross-platform-vm: name: Cross-Platform VM (${{ matrix.name }}) @@ -527,7 +634,7 @@ jobs: ci-required: name: CI Required Gate if: always() - needs: [changes, lint, workspace-check, package-check, test, restricted-hermetic, build, cross-platform-vm, linux-distro-container, docker-smoke, docs-only, non-rust, docs-quality, lint-feedback, license-file-owner-guard] + needs: [changes, lint, workspace-check, package-check, test, restricted-hermetic, build, binary-size-regression, cross-platform-vm, linux-distro-container, docker-smoke, docs-only, non-rust, docs-quality, lint-feedback, license-file-owner-guard] runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] steps: - name: Enforce required status @@ -589,6 +696,7 @@ jobs: cross_platform_vm_result="${{ needs.cross-platform-vm.result }}" linux_distro_container_result="${{ needs.linux-distro-container.result }}" docker_smoke_result="${{ needs.docker-smoke.result }}" + binary_size_regression_result="${{ needs.binary-size-regression.result }}" echo "lint=${lint_result}" echo "workspace-check=${workspace_check_result}" @@ -599,6 +707,7 @@ jobs: echo "cross-platform-vm=${cross_platform_vm_result}" echo "linux-distro-container=${linux_distro_container_result}" echo "docker-smoke=${docker_smoke_result}" + echo "binary-size-regression=${binary_size_regression_result}" echo "docs=${docs_result}" echo "license_file_owner_guard=${license_owner_result}" @@ -609,6 +718,11 @@ jobs: exit 1 fi + if [ "$event_name" = "pull_request" ] && [ "$binary_size_regression_result" != "success" ]; then + echo "Binary size regression guard did not pass for PR." + exit 1 + fi + check_docs_quality echo "All required checks passed." diff --git a/.github/workflows/pub-release.yml b/.github/workflows/pub-release.yml index eeda65d39..e184d58bb 100644 --- a/.github/workflows/pub-release.yml +++ b/.github/workflows/pub-release.yml @@ -438,6 +438,45 @@ jobs: BINARY_SIZE_TARGET_MB: 5 run: bash scripts/ci/check_binary_size.sh "target/${{ matrix.target }}/release-fast/${{ matrix.artifact }}" "${{ matrix.target }}" + - name: Check binary size (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + BINARY_SIZE_HARD_LIMIT_MB: 28 + BINARY_SIZE_ADVISORY_MB: 20 + BINARY_SIZE_TARGET_MB: 5 + run: | + $binaryPath = "target/${{ matrix.target }}/release-fast/${{ matrix.artifact }}" + if (-not (Test-Path $binaryPath)) { + Write-Output "::error::Binary not found at $binaryPath" + exit 1 + } + + $sizeBytes = (Get-Item $binaryPath).Length + $sizeMB = [math]::Floor($sizeBytes / 1MB) + $hardLimitBytes = [int64]$env:BINARY_SIZE_HARD_LIMIT_MB * 1MB + $advisoryLimitBytes = [int64]$env:BINARY_SIZE_ADVISORY_MB * 1MB + $targetLimitBytes = [int64]$env:BINARY_SIZE_TARGET_MB * 1MB + + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### Binary Size: ${{ matrix.target }}" + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "- Size: ``${sizeMB}MB (${sizeBytes} bytes)``" + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "- Limits: hard=``$($env:BINARY_SIZE_HARD_LIMIT_MB)MB`` advisory=``$($env:BINARY_SIZE_ADVISORY_MB)MB`` target=``$($env:BINARY_SIZE_TARGET_MB)MB``" + + if ($sizeBytes -gt $hardLimitBytes) { + Write-Output "::error::Binary exceeds $($env:BINARY_SIZE_HARD_LIMIT_MB)MB safeguard (${sizeMB}MB)" + exit 1 + } + if ($sizeBytes -gt $advisoryLimitBytes) { + Write-Output "::warning::Binary exceeds $($env:BINARY_SIZE_ADVISORY_MB)MB advisory target (${sizeMB}MB)" + exit 0 + } + if ($sizeBytes -gt $targetLimitBytes) { + Write-Output "::warning::Binary exceeds $($env:BINARY_SIZE_TARGET_MB)MB target (${sizeMB}MB)" + exit 0 + } + + Write-Output "Binary size within target." + - name: Package (Unix) if: runner.os != 'Windows' run: | diff --git a/scripts/ci/check_binary_size_regression.sh b/scripts/ci/check_binary_size_regression.sh new file mode 100755 index 000000000..e5b4d2806 --- /dev/null +++ b/scripts/ci/check_binary_size_regression.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Compare PR binary size against the PR base commit and fail on large regressions. +# +# Usage: +# check_binary_size_regression.sh [max_percent_increase] +# +# Behavior: +# - Builds base commit binary with the same release profile (`release-fast`) +# - Emits summary details to GITHUB_STEP_SUMMARY when available +# - Fails only when head binary grows above max_percent_increase +# - Fails open (warning-only) if base build cannot be produced for comparison + +set -euo pipefail + +BASE_SHA="${1:?Usage: check_binary_size_regression.sh [max_percent_increase]}" +HEAD_BIN="${2:?Usage: check_binary_size_regression.sh [max_percent_increase]}" +MAX_PERCENT="${3:-10}" + +size_bytes() { + local file="$1" + stat -f%z "$file" 2>/dev/null || stat -c%s "$file" +} + +if [ ! -f "$HEAD_BIN" ]; then + echo "::error::Head binary not found: ${HEAD_BIN}" + exit 1 +fi + +if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "::warning::Base SHA is not available in this checkout (${BASE_SHA}); skipping binary-size regression gate." + exit 0 +fi + +HEAD_SIZE="$(size_bytes "$HEAD_BIN")" + +tmp_root="${RUNNER_TEMP:-/tmp}" +worktree_dir="$(mktemp -d "${tmp_root%/}/binary-size-base.XXXXXX")" +cleanup() { + git worktree remove --force "$worktree_dir" >/dev/null 2>&1 || true + rm -rf "$worktree_dir" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +if ! git worktree add --detach "$worktree_dir" "$BASE_SHA" >/dev/null 2>&1; then + echo "::warning::Failed to create base worktree at ${BASE_SHA}; skipping binary-size regression gate." + exit 0 +fi + +BASE_TARGET_DIR="${worktree_dir}/target-base" +base_build_status="success" +if ! ( + cd "$worktree_dir" + export CARGO_TARGET_DIR="$BASE_TARGET_DIR" + cargo build --profile release-fast --locked --bin zeroclaw +); then + base_build_status="failure" +fi + +if [ "$base_build_status" != "success" ]; then + echo "::warning::Base commit build failed at ${BASE_SHA}; skipping binary-size regression gate." + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### Binary Size Regression" + echo "- Base SHA: \`${BASE_SHA}\`" + echo "- Result: skipped (base build failed)" + echo "- Head size bytes: \`${HEAD_SIZE}\`" + } >> "$GITHUB_STEP_SUMMARY" + fi + exit 0 +fi + +BASE_BIN="${BASE_TARGET_DIR}/release-fast/zeroclaw" +if [ ! -f "$BASE_BIN" ]; then + echo "::warning::Base binary missing (${BASE_BIN}); skipping binary-size regression gate." + exit 0 +fi + +BASE_SIZE="$(size_bytes "$BASE_BIN")" +DELTA_BYTES="$((HEAD_SIZE - BASE_SIZE))" + +DELTA_PERCENT="$( +python3 - "$BASE_SIZE" "$HEAD_SIZE" <<'PY' +import sys +base = int(sys.argv[1]) +head = int(sys.argv[2]) +if base <= 0: + print("0.00") +else: + pct = ((head - base) / base) * 100.0 + print(f"{pct:.2f}") +PY +)" + +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### Binary Size Regression" + echo "- Base SHA: \`${BASE_SHA}\`" + echo "- Base size bytes: \`${BASE_SIZE}\`" + echo "- Head size bytes: \`${HEAD_SIZE}\`" + echo "- Delta bytes: \`${DELTA_BYTES}\`" + echo "- Delta percent: \`${DELTA_PERCENT}%\`" + echo "- Max allowed increase: \`${MAX_PERCENT}%\`" + } >> "$GITHUB_STEP_SUMMARY" +fi + +if [ "$DELTA_BYTES" -le 0 ]; then + echo "Binary size did not increase vs base (delta=${DELTA_BYTES} bytes)." + exit 0 +fi + +if ! python3 - "$DELTA_PERCENT" "$MAX_PERCENT" <<'PY' +import sys +delta = float(sys.argv[1]) +max_allowed = float(sys.argv[2]) +if delta > max_allowed: + sys.exit(1) +sys.exit(0) +PY +then + echo "::error::Binary size regression ${DELTA_PERCENT}% exceeds threshold ${MAX_PERCENT}%." + exit 1 +fi + +echo "::warning::Binary size increased by ${DELTA_PERCENT}% (within threshold ${MAX_PERCENT}%)." +exit 0