From fdabb3c29050b53b0d51f887176f1fed59fb7e85 Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Tue, 3 Mar 2026 23:36:59 -0500 Subject: [PATCH] ci: standardize production pipeline to 8 core workflows --- .github/workflows/README.md | 36 -- .github/workflows/ci-auto-main-release.yml | 169 ++++++ .github/workflows/ci-build-fast.yml | 63 --- .github/workflows/ci-canary-gate.yml | 329 ------------ .github/workflows/ci-cd-security.yml | 296 ++++++++++ .github/workflows/ci-change-audit.yml | 154 ------ .../workflows/ci-provider-connectivity.yml | 112 ---- .github/workflows/ci-reproducible-build.yml | 121 ----- .github/workflows/ci-rollback.yml | 257 --------- .github/workflows/ci-run.yml | 508 +++++++++++------- .../workflows/ci-supply-chain-provenance.yml | 110 ---- .github/workflows/deploy-web.yml | 56 -- .github/workflows/docs-deploy.yml | 291 ---------- .github/workflows/feature-matrix.yml | 382 ------------- .github/workflows/main-branch-flow.md | 266 --------- .github/workflows/nightly-all-features.yml | 187 ------- .github/workflows/pages-deploy.yml | 64 --- .github/workflows/pr-auto-response.yml | 89 --- .github/workflows/pr-check-stale.yml | 49 -- .github/workflows/pr-check-status.yml | 36 -- .github/workflows/pr-intake-checks.yml | 37 -- .github/workflows/pr-label-policy-check.yml | 80 --- .github/workflows/pr-labeler.yml | 56 -- .github/workflows/pub-docker-img.yml | 103 +++- .github/workflows/pub-prerelease.yml | 259 --------- .github/workflows/pub-release.yml | 104 +++- .github/workflows/sec-audit.yml | 143 ++++- .github/workflows/sec-codeql.yml | 69 ++- .github/workflows/sec-vorpal-reviewdog.yml | 191 ------- .github/workflows/sync-contributors.yml | 116 ---- .github/workflows/test-benchmarks.yml | 53 -- .github/workflows/test-e2e.yml | 37 +- .github/workflows/test-fuzz.yml | 75 --- .github/workflows/workflow-sanity.yml | 106 ---- 34 files changed, 1181 insertions(+), 3823 deletions(-) delete mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/ci-auto-main-release.yml delete mode 100644 .github/workflows/ci-build-fast.yml delete mode 100644 .github/workflows/ci-canary-gate.yml create mode 100644 .github/workflows/ci-cd-security.yml delete mode 100644 .github/workflows/ci-change-audit.yml delete mode 100644 .github/workflows/ci-provider-connectivity.yml delete mode 100644 .github/workflows/ci-reproducible-build.yml delete mode 100644 .github/workflows/ci-rollback.yml delete mode 100644 .github/workflows/ci-supply-chain-provenance.yml delete mode 100644 .github/workflows/deploy-web.yml delete mode 100644 .github/workflows/docs-deploy.yml delete mode 100644 .github/workflows/feature-matrix.yml delete mode 100644 .github/workflows/main-branch-flow.md delete mode 100644 .github/workflows/nightly-all-features.yml delete mode 100644 .github/workflows/pages-deploy.yml delete mode 100644 .github/workflows/pr-auto-response.yml delete mode 100644 .github/workflows/pr-check-stale.yml delete mode 100644 .github/workflows/pr-check-status.yml delete mode 100644 .github/workflows/pr-intake-checks.yml delete mode 100644 .github/workflows/pr-label-policy-check.yml delete mode 100644 .github/workflows/pr-labeler.yml delete mode 100644 .github/workflows/pub-prerelease.yml delete mode 100644 .github/workflows/sec-vorpal-reviewdog.yml delete mode 100644 .github/workflows/sync-contributors.yml delete mode 100644 .github/workflows/test-benchmarks.yml delete mode 100644 .github/workflows/test-fuzz.yml delete mode 100644 .github/workflows/workflow-sanity.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md deleted file mode 100644 index fe3b3d868..000000000 --- a/.github/workflows/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Workflow Directory Layout - -GitHub Actions only loads workflow entry files from: - -- `.github/workflows/*.yml` -- `.github/workflows/*.yaml` - -Subdirectories are not valid locations for workflow entry files. - -Repository convention: - -1. Keep runnable workflow entry files at `.github/workflows/` root. -2. Keep workflow-only helper scripts under `.github/workflows/scripts/`. -3. Keep cross-tooling/local CI scripts under `scripts/ci/` when they are used outside Actions. - -Workflow behavior documentation in this directory: - -- `.github/workflows/main-branch-flow.md` - -Current workflow helper scripts: - -- `.github/workflows/scripts/ci_workflow_owner_approval.js` -- `.github/workflows/scripts/ci_license_file_owner_guard.js` -- `.github/workflows/scripts/lint_feedback.js` -- `.github/workflows/scripts/pr_auto_response_contributor_tier.js` -- `.github/workflows/scripts/pr_auto_response_labeled_routes.js` -- `.github/workflows/scripts/pr_check_status_nudge.js` -- `.github/workflows/scripts/pr_intake_checks.js` -- `.github/workflows/scripts/pr_labeler.js` -- `.github/workflows/scripts/test_benchmarks_pr_comment.js` - -Release/CI policy assets introduced for advanced delivery lanes: - -- `.github/release/nightly-owner-routing.json` -- `.github/release/canary-policy.json` -- `.github/release/prerelease-stage-gates.json` diff --git a/.github/workflows/ci-auto-main-release.yml b/.github/workflows/ci-auto-main-release.yml new file mode 100644 index 000000000..5b222bdb2 --- /dev/null +++ b/.github/workflows/ci-auto-main-release.yml @@ -0,0 +1,169 @@ +name: Auto Main Release Tag + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: auto-main-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: core.hooksPath + GIT_CONFIG_VALUE_0: /dev/null + +jobs: + tag-and-bump: + name: Tag current main + prepare next patch version + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Skip release-prep commits + id: skip + shell: bash + run: | + set -euo pipefail + msg="$(git log -1 --pretty=%B | tr -d '\r')" + if [[ "${msg}" == *"[skip ci]"* && "${msg}" == chore\(release\):\ prepare\ v* ]]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Enforce release automation actor policy + if: steps.skip.outputs.skip != 'true' + shell: bash + run: | + set -euo pipefail + actor="${GITHUB_ACTOR}" + actor_lc="$(echo "${actor}" | tr '[:upper:]' '[:lower:]')" + allowed_actors_lc="theonlyhennygod,jordanthejet" + if [[ ",${allowed_actors_lc}," != *",${actor_lc},"* ]]; then + echo "::error::Only maintainer actors (${allowed_actors_lc}) can trigger main release tagging. Actor: ${actor}" + exit 1 + fi + + - name: Resolve current and next version + if: steps.skip.outputs.skip != 'true' + id: version + shell: bash + run: | + set -euo pipefail + + current_version="$(awk ' + BEGIN { in_pkg=0 } + /^\[package\]/ { in_pkg=1; next } + in_pkg && /^\[/ { in_pkg=0 } + in_pkg && $1 == "version" { + value=$3 + gsub(/"/, "", value) + print value + exit + } + ' Cargo.toml)" + + if [[ -z "${current_version}" ]]; then + echo "::error::Failed to resolve current package version from Cargo.toml" + exit 1 + fi + if [[ ! "${current_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Cargo.toml version must be strict semver X.Y.Z (found: ${current_version})" + exit 1 + fi + + IFS='.' read -r major minor patch <<< "${current_version}" + next_patch="$((patch + 1))" + next_version="${major}.${minor}.${next_patch}" + + { + echo "current=${current_version}" + echo "next=${next_version}" + echo "tag=v${current_version}" + } >> "$GITHUB_OUTPUT" + + - name: Verify tag does not already exist + id: tag_check + if: steps.skip.outputs.skip != 'true' + shell: bash + run: | + set -euo pipefail + tag="${{ steps.version.outputs.tag }}" + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "::warning::Release tag ${tag} already exists on origin; skipping auto-tag/bump for this push." + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push annotated release tag + if: steps.skip.outputs.skip != 'true' && steps.tag_check.outputs.exists != 'true' + shell: bash + run: | + set -euo pipefail + tag="${{ steps.version.outputs.tag }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git tag -a "${tag}" -m "Release ${tag}" + git push origin "refs/tags/${tag}" + + - name: Bump Cargo version for next release + if: steps.skip.outputs.skip != 'true' && steps.tag_check.outputs.exists != 'true' + shell: bash + run: | + set -euo pipefail + next="${{ steps.version.outputs.next }}" + + awk -v new_version="${next}" ' + BEGIN { in_pkg=0; done=0 } + /^\[package\]/ { in_pkg=1 } + in_pkg && /^\[/ && $0 !~ /^\[package\]/ { in_pkg=0 } + in_pkg && $1 == "version" && done == 0 { + sub(/"[^"]+"/, "\"" new_version "\"") + done=1 + } + { print } + ' Cargo.toml > Cargo.toml.tmp + mv Cargo.toml.tmp Cargo.toml + + awk -v new_version="${next}" ' + BEGIN { in_pkg=0; zc_pkg=0; done=0 } + /^\[\[package\]\]/ { in_pkg=1; zc_pkg=0 } + in_pkg && /^name = "zeroclaw"$/ { zc_pkg=1 } + in_pkg && zc_pkg && /^version = "/ && done == 0 { + sub(/"[^"]+"/, "\"" new_version "\"") + done=1 + } + { print } + ' Cargo.lock > Cargo.lock.tmp + mv Cargo.lock.tmp Cargo.lock + + - name: Commit and push next-version prep + if: steps.skip.outputs.skip != 'true' && steps.tag_check.outputs.exists != 'true' + shell: bash + run: | + set -euo pipefail + next="${{ steps.version.outputs.next }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add Cargo.toml Cargo.lock + if git diff --cached --quiet; then + echo "No version changes detected; nothing to commit." + exit 0 + fi + + git commit -m "chore(release): prepare v${next} [skip ci]" + git push origin HEAD:main diff --git a/.github/workflows/ci-build-fast.yml b/.github/workflows/ci-build-fast.yml deleted file mode 100644 index a9cab2b0b..000000000 --- a/.github/workflows/ci-build-fast.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: CI Build (Fast) - -# Optional fast release build that runs alongside the normal Build (Smoke) job. -# This workflow is informational and does not gate merges. - -on: - push: - branches: [dev, main] - pull_request: - branches: [dev, main] - -concurrency: - group: ci-fast-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - CARGO_TERM_COLOR: always - -jobs: - changes: - name: Detect Change Scope - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - outputs: - rust_changed: ${{ steps.scope.outputs.rust_changed }} - docs_only: ${{ steps.scope.outputs.docs_only }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - name: Detect docs-only changes - id: scope - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - run: ./scripts/ci/detect_change_scope.sh - - build-fast: - name: Build (Fast) - needs: [changes] - if: needs.changes.outputs.rust_changed == 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 25 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: 1.92.0 - - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 - with: - prefix-key: fast-build - cache-targets: true - - - name: Build release binary - run: cargo build --release --locked --verbose diff --git a/.github/workflows/ci-canary-gate.yml b/.github/workflows/ci-canary-gate.yml deleted file mode 100644 index 9fcfdfff4..000000000 --- a/.github/workflows/ci-canary-gate.yml +++ /dev/null @@ -1,329 +0,0 @@ -name: CI Canary Gate - -on: - workflow_dispatch: - inputs: - mode: - description: "dry-run computes decision only; execute enables canary dispatch" - required: true - default: dry-run - type: choice - options: - - dry-run - - execute - candidate_tag: - description: "Candidate release tag (e.g. v0.1.8-rc.1 or v0.1.8)" - required: false - default: "" - type: string - candidate_sha: - description: "Optional explicit candidate SHA" - required: false - default: "" - type: string - error_rate: - description: "Observed canary error rate (0.0-1.0)" - required: true - default: "0.0" - type: string - crash_rate: - description: "Observed canary crash rate (0.0-1.0)" - required: true - default: "0.0" - type: string - p95_latency_ms: - description: "Observed canary p95 latency in milliseconds" - required: true - default: "0" - type: string - sample_size: - description: "Observed canary sample size" - required: true - default: "0" - type: string - emit_repository_dispatch: - description: "Emit canary decision repository_dispatch event" - required: true - default: false - type: boolean - trigger_rollback_on_abort: - description: "Automatically dispatch CI Rollback Guard when canary decision is abort" - required: true - default: true - type: boolean - rollback_branch: - description: "Rollback integration branch used by CI Rollback Guard dispatch" - required: true - default: dev - type: choice - options: - - dev - - main - rollback_target_ref: - description: "Optional explicit rollback target ref passed to CI Rollback Guard" - required: false - default: "" - type: string - fail_on_violation: - description: "Fail on policy violations" - required: true - default: true - type: boolean - schedule: - - cron: "45 7 * * 1" # Weekly Monday 07:45 UTC - -concurrency: - group: canary-gate-${{ github.event.inputs.candidate_tag || github.ref || github.run_id }} - cancel-in-progress: false - -permissions: - contents: read - actions: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - canary-plan: - name: Canary Plan - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 - outputs: - mode: ${{ steps.inputs.outputs.mode }} - candidate_tag: ${{ steps.inputs.outputs.candidate_tag }} - candidate_sha: ${{ steps.inputs.outputs.candidate_sha }} - trigger_rollback_on_abort: ${{ steps.inputs.outputs.trigger_rollback_on_abort }} - rollback_branch: ${{ steps.inputs.outputs.rollback_branch }} - rollback_target_ref: ${{ steps.inputs.outputs.rollback_target_ref }} - decision: ${{ steps.extract.outputs.decision }} - ready_to_execute: ${{ steps.extract.outputs.ready_to_execute }} - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - name: Resolve canary inputs - id: inputs - shell: bash - run: | - set -euo pipefail - - mode="dry-run" - candidate_tag="" - candidate_sha="" - error_rate="0.0" - crash_rate="0.0" - p95_latency_ms="0" - sample_size="0" - trigger_rollback_on_abort="true" - rollback_branch="dev" - rollback_target_ref="" - fail_on_violation="true" - - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - mode="${{ github.event.inputs.mode || 'dry-run' }}" - candidate_tag="${{ github.event.inputs.candidate_tag || '' }}" - candidate_sha="${{ github.event.inputs.candidate_sha || '' }}" - error_rate="${{ github.event.inputs.error_rate || '0.0' }}" - crash_rate="${{ github.event.inputs.crash_rate || '0.0' }}" - p95_latency_ms="${{ github.event.inputs.p95_latency_ms || '0' }}" - sample_size="${{ github.event.inputs.sample_size || '0' }}" - trigger_rollback_on_abort="${{ github.event.inputs.trigger_rollback_on_abort || 'true' }}" - rollback_branch="${{ github.event.inputs.rollback_branch || 'dev' }}" - rollback_target_ref="${{ github.event.inputs.rollback_target_ref || '' }}" - fail_on_violation="${{ github.event.inputs.fail_on_violation || 'true' }}" - else - git fetch --tags --force origin - candidate_tag="$(git tag --list 'v*' --sort=-version:refname | head -n1)" - if [ -n "$candidate_tag" ]; then - candidate_sha="$(git rev-parse "${candidate_tag}^{commit}")" - fi - fi - - { - echo "mode=${mode}" - echo "candidate_tag=${candidate_tag}" - echo "candidate_sha=${candidate_sha}" - echo "error_rate=${error_rate}" - echo "crash_rate=${crash_rate}" - echo "p95_latency_ms=${p95_latency_ms}" - echo "sample_size=${sample_size}" - echo "trigger_rollback_on_abort=${trigger_rollback_on_abort}" - echo "rollback_branch=${rollback_branch}" - echo "rollback_target_ref=${rollback_target_ref}" - echo "fail_on_violation=${fail_on_violation}" - } >> "$GITHUB_OUTPUT" - - - name: Run canary guard - shell: bash - run: | - set -euo pipefail - mkdir -p artifacts - - args=() - if [ "${{ steps.inputs.outputs.fail_on_violation }}" = "true" ]; then - args+=(--fail-on-violation) - fi - - python3 scripts/ci/canary_guard.py \ - --policy-file .github/release/canary-policy.json \ - --candidate-tag "${{ steps.inputs.outputs.candidate_tag }}" \ - --candidate-sha "${{ steps.inputs.outputs.candidate_sha }}" \ - --mode "${{ steps.inputs.outputs.mode }}" \ - --error-rate "${{ steps.inputs.outputs.error_rate }}" \ - --crash-rate "${{ steps.inputs.outputs.crash_rate }}" \ - --p95-latency-ms "${{ steps.inputs.outputs.p95_latency_ms }}" \ - --sample-size "${{ steps.inputs.outputs.sample_size }}" \ - --output-json artifacts/canary-guard.json \ - --output-md artifacts/canary-guard.md \ - "${args[@]}" - - - name: Extract canary decision outputs - id: extract - shell: bash - run: | - set -euo pipefail - decision="$(python3 - <<'PY' - import json - data = json.load(open('artifacts/canary-guard.json', encoding='utf-8')) - print(data.get('decision', 'hold')) - PY - )" - ready_to_execute="$(python3 - <<'PY' - import json - data = json.load(open('artifacts/canary-guard.json', encoding='utf-8')) - print(str(bool(data.get('ready_to_execute', False))).lower()) - PY - )" - echo "decision=${decision}" >> "$GITHUB_OUTPUT" - echo "ready_to_execute=${ready_to_execute}" >> "$GITHUB_OUTPUT" - - - name: Emit canary audit event - if: always() - shell: bash - run: | - set -euo pipefail - python3 scripts/ci/emit_audit_event.py \ - --event-type canary_guard \ - --input-json artifacts/canary-guard.json \ - --output-json artifacts/audit-event-canary-guard.json \ - --artifact-name canary-guard \ - --retention-days 21 - - - name: Publish canary summary - if: always() - shell: bash - run: | - set -euo pipefail - cat artifacts/canary-guard.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload canary artifacts - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: canary-guard - path: | - artifacts/canary-guard.json - artifacts/canary-guard.md - artifacts/audit-event-canary-guard.json - if-no-files-found: error - retention-days: 21 - - canary-execute: - name: Canary Execute - needs: [canary-plan] - if: github.event_name == 'workflow_dispatch' && needs.canary-plan.outputs.mode == 'execute' && needs.canary-plan.outputs.ready_to_execute == 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 10 - permissions: - contents: write - actions: write - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Create canary marker tag - shell: bash - run: | - set -euo pipefail - marker_tag="canary-${{ needs.canary-plan.outputs.candidate_tag }}-${{ github.run_id }}" - git fetch --tags --force origin - git tag -a "$marker_tag" "${{ needs.canary-plan.outputs.candidate_sha }}" -m "Canary decision marker from run ${{ github.run_id }}" - git push origin "$marker_tag" - echo "Created marker tag: $marker_tag" >> "$GITHUB_STEP_SUMMARY" - - - name: Emit canary repository dispatch - if: github.event.inputs.emit_repository_dispatch == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: `canary_${{ needs.canary-plan.outputs.decision }}`, - client_payload: { - candidate_tag: "${{ needs.canary-plan.outputs.candidate_tag }}", - candidate_sha: "${{ needs.canary-plan.outputs.candidate_sha }}", - decision: "${{ needs.canary-plan.outputs.decision }}", - run_id: context.runId, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - source_sha: context.sha - } - }); - - - name: Trigger rollback guard workflow on abort - if: needs.canary-plan.outputs.decision == 'abort' && needs.canary-plan.outputs.trigger_rollback_on_abort == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const rollbackBranch = "${{ needs.canary-plan.outputs.rollback_branch }}" || "dev"; - const rollbackTargetRef = `${{ needs.canary-plan.outputs.rollback_target_ref }}`.trim(); - const workflowRef = process.env.GITHUB_REF_NAME || "dev"; - - const inputs = { - branch: rollbackBranch, - mode: "execute", - allow_non_ancestor: "false", - fail_on_violation: "true", - create_marker_tag: "true", - emit_repository_dispatch: "true", - }; - - if (rollbackTargetRef.length > 0) { - inputs.target_ref = rollbackTargetRef; - } - - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: "ci-rollback.yml", - ref: workflowRef, - inputs, - }); - - - name: Publish rollback trigger summary - if: needs.canary-plan.outputs.decision == 'abort' - shell: bash - run: | - set -euo pipefail - if [ "${{ needs.canary-plan.outputs.trigger_rollback_on_abort }}" = "true" ]; then - { - echo "### Canary Abort Rollback Trigger" - echo "- CI Rollback Guard dispatch: triggered" - echo "- Rollback branch: \`${{ needs.canary-plan.outputs.rollback_branch }}\`" - if [ -n "${{ needs.canary-plan.outputs.rollback_target_ref }}" ]; then - echo "- Rollback target ref: \`${{ needs.canary-plan.outputs.rollback_target_ref }}\`" - else - echo "- Rollback target ref: _auto (latest release tag strategy)_" - fi - } >> "$GITHUB_STEP_SUMMARY" - else - { - echo "### Canary Abort Rollback Trigger" - echo "- CI Rollback Guard dispatch: skipped (trigger_rollback_on_abort=false)" - } >> "$GITHUB_STEP_SUMMARY" - fi diff --git a/.github/workflows/ci-cd-security.yml b/.github/workflows/ci-cd-security.yml new file mode 100644 index 000000000..970654bd6 --- /dev/null +++ b/.github/workflows/ci-cd-security.yml @@ -0,0 +1,296 @@ +name: CI/CD with Security Hardening + +# Hard rule (branch + cadence policy): +# 1) Contributors branch from `dev` and open PRs into `dev`. +# 2) PRs into `main` are promotion PRs from `dev` (or explicit hotfix override). +# 3) Full CI/CD runs on merge/direct push to `main` and manual dispatch only. +# 3a) Main/manual build triggers are restricted to maintainers: +# `theonlyhennygod`, `jordanthejet`. +# 4) release published: run publish path on every release. +# Cost policy: no daily auto-release and no heavy PR-triggered release pipeline. +on: + workflow_dispatch: + release: + types: [published] + +concurrency: + group: ci-cd-security-${{ github.event.pull_request.number || github.ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: core.hooksPath + GIT_CONFIG_VALUE_0: /dev/null + CARGO_TERM_COLOR: always + +jobs: + authorize-main-build: + name: Access and Execution Gate + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] + outputs: + run_pipeline: ${{ steps.gate.outputs.run_pipeline }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1 + + - name: Enforce actor policy and skip rules + id: gate + shell: bash + run: | + set -euo pipefail + + actor="${GITHUB_ACTOR}" + actor_lc="$(echo "${actor}" | tr '[:upper:]' '[:lower:]')" + event="${GITHUB_EVENT_NAME}" + allowed_humans_lc="theonlyhennygod,jordanthejet" + allowed_bot="github-actions[bot]" + run_pipeline="true" + + if [[ "${event}" == "push" ]]; then + commit_msg="$(git log -1 --pretty=%B | tr -d '\r')" + if [[ "${commit_msg}" == *"[skip ci]"* ]]; then + run_pipeline="false" + echo "Skipping heavy pipeline because commit message includes [skip ci]." + fi + + if [[ "${run_pipeline}" == "true" && ",${allowed_humans_lc}," != *",${actor_lc},"* ]]; then + echo "::error::Only maintainer actors (${allowed_humans_lc}) can trigger main build runs. Actor: ${actor}" + exit 1 + fi + elif [[ "${event}" == "workflow_dispatch" ]]; then + if [[ ",${allowed_humans_lc}," != *",${actor_lc},"* ]]; then + echo "::error::Only maintainer actors (${allowed_humans_lc}) can run manual CI/CD dispatches. Actor: ${actor}" + exit 1 + fi + elif [[ "${event}" == "release" ]]; then + if [[ ",${allowed_humans_lc}," != *",${actor_lc},"* && "${actor}" != "${allowed_bot}" ]]; then + echo "::error::Only maintainer actors (${allowed_humans_lc}) or ${allowed_bot} can trigger release build lanes. Actor: ${actor}" + exit 1 + fi + fi + + echo "run_pipeline=${run_pipeline}" >> "$GITHUB_OUTPUT" + + build-and-test: + needs: authorize-main-build + if: needs.authorize-main-build.outputs.run_pipeline == 'true' + runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Ensure C toolchain + shell: bash + run: bash ./scripts/ci/ensure_c_toolchain.sh + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.92.0 + components: clippy, rustfmt + + - name: Ensure C toolchain for Rust builds + shell: bash + run: ./scripts/ci/ensure_cc.sh + + - name: Cache Cargo dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + with: + prefix-key: ci-cd-security-build + cache-bin: false + + - name: Build + shell: bash + run: cargo build --locked --verbose --all-features + + - name: Run tests + shell: bash + run: cargo test --locked --verbose --all-features + + - name: Run benchmarks + shell: bash + run: cargo bench --locked --verbose + + - name: Lint with Clippy + shell: bash + run: cargo clippy --locked --all-targets --all-features -- -D warnings + + - name: Check formatting + shell: bash + run: cargo fmt -- --check + + security-scans: + runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + timeout-minutes: 60 + needs: build-and-test + permissions: + contents: read + security-events: write + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Ensure C toolchain + shell: bash + run: bash ./scripts/ci/ensure_c_toolchain.sh + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.92.0 + + - name: Ensure C toolchain for Rust builds + shell: bash + run: ./scripts/ci/ensure_cc.sh + + - name: Cache Cargo dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + with: + prefix-key: ci-cd-security-security + cache-bin: false + + - name: Install cargo-audit + shell: bash + run: cargo install cargo-audit --locked --features=fix + + - name: Install cargo-deny + shell: bash + run: cargo install cargo-deny --locked + + - name: Dependency vulnerability audit + shell: bash + run: cargo audit --deny warnings + + - name: Dependency license and security check + shell: bash + run: cargo deny check + + - name: Install gitleaks + shell: bash + run: | + set -euo pipefail + bin_dir="${RUNNER_TEMP}/bin" + mkdir -p "${bin_dir}" + bash ./scripts/ci/install_gitleaks.sh "${bin_dir}" + echo "${bin_dir}" >> "$GITHUB_PATH" + + - name: Scan for secrets + shell: bash + run: gitleaks detect --source=. --verbose --config=.gitleaks.toml + + - name: Static analysis with Semgrep + uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 + with: + config: auto + + fuzz-testing: + runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + timeout-minutes: 90 + needs: build-and-test + strategy: + fail-fast: false + matrix: + target: + - fuzz_config_parse + - fuzz_tool_params + - fuzz_webhook_payload + - fuzz_provider_response + - fuzz_command_validation + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Ensure C toolchain + shell: bash + run: bash ./scripts/ci/ensure_c_toolchain.sh + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: nightly + components: llvm-tools-preview + + - name: Cache Cargo dependencies + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + with: + prefix-key: ci-cd-security-fuzz + cache-bin: false + + - name: Run fuzz tests + shell: bash + run: | + set -euo pipefail + cargo install cargo-fuzz --locked + cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=300 -max_len=4096 + + container-build-and-scan: + runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + timeout-minutes: 45 + needs: security-scans + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Blacksmith Docker builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1 + + - name: Build Docker image + uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2 + with: + context: . + push: false + load: true + tags: ghcr.io/${{ github.repository }}:ci-security + + - name: Scan Docker image for vulnerabilities + shell: bash + run: | + set -euo pipefail + docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy:0.58.2 image \ + --exit-code 1 \ + --no-progress \ + --severity HIGH,CRITICAL \ + ghcr.io/${{ github.repository }}:ci-security + + publish: + runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + timeout-minutes: 60 + if: github.event_name == 'release' + needs: + - build-and-test + - security-scans + - fuzz-testing + - container-build-and-scan + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Blacksmith Docker builder + uses: useblacksmith/setup-docker-builder@ef12d5b165b596e3aa44ea8198d8fde563eab402 # v1 + + - name: Login to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_TOKEN }} + + - name: Build and push Docker image + uses: useblacksmith/build-push-action@30c71162f16ea2c27c3e21523255d209b8b538c1 # v2 + with: + context: . + push: true + tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }},ghcr.io/${{ github.repository }}:latest + build-args: | + ZEROCLAW_CARGO_ALL_FEATURES=true diff --git a/.github/workflows/ci-change-audit.yml b/.github/workflows/ci-change-audit.yml deleted file mode 100644 index 1fa4970bf..000000000 --- a/.github/workflows/ci-change-audit.yml +++ /dev/null @@ -1,154 +0,0 @@ -name: CI/CD Change Audit - -on: - pull_request: - branches: [dev, main] - paths: - - ".github/workflows/**" - - ".github/release/**" - - ".github/codeql/**" - - "scripts/ci/**" - - ".github/dependabot.yml" - - "deny.toml" - - ".gitleaks.toml" - push: - branches: [dev, main] - paths: - - ".github/workflows/**" - - ".github/release/**" - - ".github/codeql/**" - - "scripts/ci/**" - - ".github/dependabot.yml" - - "deny.toml" - - ".gitleaks.toml" - workflow_dispatch: - inputs: - base_sha: - description: "Optional base SHA (default: HEAD~1)" - required: false - default: "" - type: string - fail_on_policy: - description: "Fail when audit policy violations are found" - required: true - default: true - type: boolean - -concurrency: - group: ci-change-audit-${{ github.event.pull_request.number || github.sha || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - audit: - name: CI Change Audit - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - name: Resolve base/head commits - id: refs - shell: bash - run: | - set -euo pipefail - head_sha="$(git rev-parse HEAD)" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - # For pull_request events, checkout uses refs/pull/*/merge; HEAD^1 is the - # effective base commit for this synthesized merge and avoids stale base.sha. - if git rev-parse --verify HEAD^1 >/dev/null 2>&1; then - base_sha="$(git rev-parse HEAD^1)" - else - base_sha="${{ github.event.pull_request.base.sha }}" - fi - elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then - base_sha="${{ github.event.before }}" - else - base_sha="${{ github.event.inputs.base_sha || '' }}" - if [ -z "$base_sha" ]; then - base_sha="$(git rev-parse HEAD~1)" - fi - fi - echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" - echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" - - - name: Run CI helper script unit tests - shell: bash - run: | - set -euo pipefail - python3 -m unittest discover -s scripts/ci/tests -p 'test_*.py' -v - - - name: Generate CI change audit - shell: bash - env: - BASE_SHA: ${{ steps.refs.outputs.base_sha }} - HEAD_SHA: ${{ steps.refs.outputs.head_sha }} - run: | - set -euo pipefail - mkdir -p artifacts - fail_on_policy="true" - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - fail_on_policy="${{ github.event.inputs.fail_on_policy || 'true' }}" - fi - cmd=(python3 scripts/ci/ci_change_audit.py - --base-sha "$BASE_SHA" - --head-sha "$HEAD_SHA" - --output-json artifacts/ci-change-audit.json - --output-md artifacts/ci-change-audit.md) - if [ "$fail_on_policy" = "true" ]; then - cmd+=(--fail-on-violations) - fi - "${cmd[@]}" - - - name: Emit normalized audit event - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/ci-change-audit.json ]; then - python3 scripts/ci/emit_audit_event.py \ - --event-type ci_change_audit \ - --input-json artifacts/ci-change-audit.json \ - --output-json artifacts/audit-event-ci-change-audit.json \ - --artifact-name ci-change-audit-event \ - --retention-days 14 - fi - - - name: Upload audit artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - if: always() - with: - name: ci-change-audit - path: artifacts/ci-change-audit.* - retention-days: 14 - - - name: Publish audit summary - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/ci-change-audit.md ]; then - cat artifacts/ci-change-audit.md >> "$GITHUB_STEP_SUMMARY" - else - echo "CI change audit report was not generated." >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Upload audit event artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - if: always() - with: - name: ci-change-audit-event - path: artifacts/audit-event-ci-change-audit.json - if-no-files-found: ignore - retention-days: 14 diff --git a/.github/workflows/ci-provider-connectivity.yml b/.github/workflows/ci-provider-connectivity.yml deleted file mode 100644 index 701f923b3..000000000 --- a/.github/workflows/ci-provider-connectivity.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: CI Provider Connectivity - -on: - schedule: - - cron: "30 */6 * * *" # Every 6 hours - workflow_dispatch: - inputs: - fail_on_critical: - description: "Fail run when critical endpoints are unreachable" - required: true - default: false - type: boolean - pull_request: - branches: [dev, main] - paths: - - ".github/workflows/ci-provider-connectivity.yml" - - ".github/connectivity/providers.json" - - "scripts/ci/provider_connectivity_matrix.py" - push: - branches: [dev, main] - paths: - - ".github/workflows/ci-provider-connectivity.yml" - - ".github/connectivity/providers.json" - - "scripts/ci/provider_connectivity_matrix.py" - -concurrency: - group: provider-connectivity-${{ github.event.pull_request.number || github.ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - probe: - name: Provider Connectivity Probe - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Run connectivity matrix probe - shell: bash - run: | - set -euo pipefail - mkdir -p artifacts - fail_on_critical="false" - case "${GITHUB_EVENT_NAME}" in - schedule) - fail_on_critical="true" - ;; - workflow_dispatch) - fail_on_critical="${{ github.event.inputs.fail_on_critical || 'false' }}" - ;; - esac - - cmd=(python3 scripts/ci/provider_connectivity_matrix.py - --config .github/connectivity/providers.json - --output-json artifacts/provider-connectivity-matrix.json - --output-md artifacts/provider-connectivity-matrix.md) - if [ "$fail_on_critical" = "true" ]; then - cmd+=(--fail-on-critical) - fi - "${cmd[@]}" - - - name: Emit normalized audit event - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/provider-connectivity-matrix.json ]; then - python3 scripts/ci/emit_audit_event.py \ - --event-type provider_connectivity \ - --input-json artifacts/provider-connectivity-matrix.json \ - --output-json artifacts/audit-event-provider-connectivity.json \ - --artifact-name provider-connectivity-audit-event \ - --retention-days 14 - fi - - - name: Upload connectivity artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - if: always() - with: - name: provider-connectivity-matrix - path: artifacts/provider-connectivity-matrix.* - retention-days: 14 - - - name: Publish summary - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/provider-connectivity-matrix.md ]; then - cat artifacts/provider-connectivity-matrix.md >> "$GITHUB_STEP_SUMMARY" - else - echo "Provider connectivity report missing." >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Upload audit event artifact - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: provider-connectivity-audit-event - path: artifacts/audit-event-provider-connectivity.json - if-no-files-found: ignore - retention-days: 14 diff --git a/.github/workflows/ci-reproducible-build.yml b/.github/workflows/ci-reproducible-build.yml deleted file mode 100644 index 9deb0d6e6..000000000 --- a/.github/workflows/ci-reproducible-build.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: CI Reproducible Build - -on: - push: - branches: [dev, main] - paths: - - "Cargo.toml" - - "Cargo.lock" - - "src/**" - - "crates/**" - - "scripts/ci/reproducible_build_check.sh" - - ".github/workflows/ci-reproducible-build.yml" - pull_request: - branches: [dev, main] - paths: - - "Cargo.toml" - - "Cargo.lock" - - "src/**" - - "crates/**" - - "scripts/ci/reproducible_build_check.sh" - - ".github/workflows/ci-reproducible-build.yml" - schedule: - - cron: "45 5 * * 1" # Weekly Monday 05:45 UTC - workflow_dispatch: - inputs: - fail_on_drift: - description: "Fail workflow if deterministic hash drift is detected" - required: true - default: true - type: boolean - allow_build_id_drift: - description: "Treat GNU build-id-only drift as non-blocking" - required: true - default: true - type: boolean - -concurrency: - group: repro-build-${{ github.event.pull_request.number || github.ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - CARGO_TERM_COLOR: always - -jobs: - reproducibility: - name: Reproducible Build Probe - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: 1.92.0 - - - name: Run reproducible build check - shell: bash - run: | - set -euo pipefail - fail_on_drift="false" - allow_build_id_drift="true" - if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then - fail_on_drift="true" - elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - fail_on_drift="${{ github.event.inputs.fail_on_drift || 'true' }}" - allow_build_id_drift="${{ github.event.inputs.allow_build_id_drift || 'true' }}" - fi - FAIL_ON_DRIFT="$fail_on_drift" \ - ALLOW_BUILD_ID_DRIFT="$allow_build_id_drift" \ - OUTPUT_DIR="artifacts" \ - ./scripts/ci/reproducible_build_check.sh - - - name: Emit normalized audit event - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/reproducible-build.json ]; then - python3 scripts/ci/emit_audit_event.py \ - --event-type reproducible_build \ - --input-json artifacts/reproducible-build.json \ - --output-json artifacts/audit-event-reproducible-build.json \ - --artifact-name reproducible-build-audit-event \ - --retention-days 14 - fi - - - name: Upload reproducibility artifacts - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: reproducible-build - path: artifacts/reproducible-build* - retention-days: 14 - - - name: Upload audit event artifact - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: reproducible-build-audit-event - path: artifacts/audit-event-reproducible-build.json - if-no-files-found: ignore - retention-days: 14 - - - name: Publish summary - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/reproducible-build.md ]; then - cat artifacts/reproducible-build.md >> "$GITHUB_STEP_SUMMARY" - else - echo "Reproducible build report missing." >> "$GITHUB_STEP_SUMMARY" - fi diff --git a/.github/workflows/ci-rollback.yml b/.github/workflows/ci-rollback.yml deleted file mode 100644 index cc6cde2e9..000000000 --- a/.github/workflows/ci-rollback.yml +++ /dev/null @@ -1,257 +0,0 @@ -name: CI Rollback Guard - -on: - workflow_dispatch: - inputs: - branch: - description: "Integration branch this rollback targets" - required: true - default: dev - type: choice - options: - - dev - - main - mode: - description: "dry-run only plans; execute enables rollback marker/dispatch actions" - required: true - default: dry-run - type: choice - options: - - dry-run - - execute - target_ref: - description: "Optional explicit rollback target (tag/sha/ref). Empty = latest matching tag." - required: false - default: "" - type: string - allow_non_ancestor: - description: "Allow target not being ancestor of current head (warning-only)" - required: true - default: false - type: boolean - fail_on_violation: - description: "Fail workflow when guard violations are detected" - required: true - default: true - type: boolean - create_marker_tag: - description: "In execute mode, create and push rollback marker tag" - required: true - default: false - type: boolean - emit_repository_dispatch: - description: "In execute mode, emit repository_dispatch event `rollback_execute`" - required: true - default: false - type: boolean - schedule: - - cron: "15 7 * * 1" # Weekly Monday 07:15 UTC - -concurrency: - group: ci-rollback-${{ github.event.inputs.branch || 'dev' }} - cancel-in-progress: false - -permissions: - contents: read - actions: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - rollback-plan: - name: Rollback Guard Plan - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 - outputs: - branch: ${{ steps.plan.outputs.branch }} - mode: ${{ steps.plan.outputs.mode }} - target_sha: ${{ steps.plan.outputs.target_sha }} - target_ref: ${{ steps.plan.outputs.target_ref }} - ready_to_execute: ${{ steps.plan.outputs.ready_to_execute }} - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - ref: ${{ github.event.inputs.branch || 'dev' }} - - - name: Build rollback plan - id: plan - shell: bash - run: | - set -euo pipefail - mkdir -p artifacts - - branch_input="dev" - mode_input="dry-run" - target_ref_input="" - allow_non_ancestor="false" - fail_on_violation="true" - - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - branch_input="${{ github.event.inputs.branch || 'dev' }}" - mode_input="${{ github.event.inputs.mode || 'dry-run' }}" - target_ref_input="${{ github.event.inputs.target_ref || '' }}" - allow_non_ancestor="${{ github.event.inputs.allow_non_ancestor || 'false' }}" - fail_on_violation="${{ github.event.inputs.fail_on_violation || 'true' }}" - fi - - cmd=(python3 scripts/ci/rollback_guard.py - --repo-root . - --branch "$branch_input" - --mode "$mode_input" - --strategy latest-release-tag - --tag-pattern "v*" - --output-json artifacts/rollback-plan.json - --output-md artifacts/rollback-plan.md) - - if [ -n "$target_ref_input" ]; then - cmd+=(--target-ref "$target_ref_input") - fi - if [ "$allow_non_ancestor" = "true" ]; then - cmd+=(--allow-non-ancestor) - fi - if [ "$fail_on_violation" = "true" ]; then - cmd+=(--fail-on-violation) - fi - - "${cmd[@]}" - - target_sha="$(python3 - <<'PY' - import json - d = json.load(open("artifacts/rollback-plan.json", "r", encoding="utf-8")) - print(d.get("target_sha", "")) - PY - )" - target_ref="$(python3 - <<'PY' - import json - d = json.load(open("artifacts/rollback-plan.json", "r", encoding="utf-8")) - print(d.get("target_ref", "")) - PY - )" - ready_to_execute="$(python3 - <<'PY' - import json - d = json.load(open("artifacts/rollback-plan.json", "r", encoding="utf-8")) - print(str(d.get("ready_to_execute", False)).lower()) - PY - )" - - { - echo "branch=$branch_input" - echo "mode=$mode_input" - echo "target_sha=$target_sha" - echo "target_ref=$target_ref" - echo "ready_to_execute=$ready_to_execute" - } >> "$GITHUB_OUTPUT" - - - name: Emit rollback audit event - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/rollback-plan.json ]; then - python3 scripts/ci/emit_audit_event.py \ - --event-type rollback_guard \ - --input-json artifacts/rollback-plan.json \ - --output-json artifacts/audit-event-rollback-guard.json \ - --artifact-name ci-rollback-plan \ - --retention-days 21 - fi - - - name: Upload rollback artifacts - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: ci-rollback-plan - path: | - artifacts/rollback-plan.* - artifacts/audit-event-rollback-guard.json - if-no-files-found: ignore - retention-days: 21 - - - name: Publish rollback summary - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/rollback-plan.md ]; then - cat artifacts/rollback-plan.md >> "$GITHUB_STEP_SUMMARY" - else - echo "Rollback plan markdown report missing." >> "$GITHUB_STEP_SUMMARY" - fi - - rollback-execute: - name: Rollback Execute Actions - needs: [rollback-plan] - if: github.event_name == 'workflow_dispatch' && needs.rollback-plan.outputs.mode == 'execute' && needs.rollback-plan.outputs.ready_to_execute == 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 15 - permissions: - contents: write - actions: read - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - ref: ${{ needs.rollback-plan.outputs.branch }} - - - name: Fetch tags - shell: bash - run: | - set -euo pipefail - git fetch --tags --force origin - - - name: Create rollback marker tag - id: marker - if: github.event.inputs.create_marker_tag == 'true' - shell: bash - run: | - set -euo pipefail - target_sha="${{ needs.rollback-plan.outputs.target_sha }}" - if [ -z "$target_sha" ]; then - echo "Rollback guard did not resolve target_sha." - exit 1 - fi - marker_tag="rollback-${{ needs.rollback-plan.outputs.branch }}-${{ github.run_id }}" - git tag -a "$marker_tag" "$target_sha" -m "Rollback marker from run ${{ github.run_id }}" - git push origin "$marker_tag" - echo "marker_tag=$marker_tag" >> "$GITHUB_OUTPUT" - - - name: Emit rollback repository dispatch - if: github.event.inputs.emit_repository_dispatch == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - await github.rest.repos.createDispatchEvent({ - owner: context.repo.owner, - repo: context.repo.repo, - event_type: "rollback_execute", - client_payload: { - branch: "${{ needs.rollback-plan.outputs.branch }}", - target_ref: "${{ needs.rollback-plan.outputs.target_ref }}", - target_sha: "${{ needs.rollback-plan.outputs.target_sha }}", - run_id: context.runId, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - source_sha: context.sha - } - }); - - - name: Publish execute summary - if: always() - shell: bash - run: | - set -euo pipefail - { - echo "### Rollback Execute Actions" - echo "- Branch: \`${{ needs.rollback-plan.outputs.branch }}\`" - echo "- Target ref: \`${{ needs.rollback-plan.outputs.target_ref }}\`" - echo "- Target sha: \`${{ needs.rollback-plan.outputs.target_sha }}\`" - if [ -n "${{ steps.marker.outputs.marker_tag || '' }}" ]; then - echo "- Marker tag: \`${{ steps.marker.outputs.marker_tag }}\`" - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci-run.yml b/.github/workflows/ci-run.yml index fd74bf42d..cd66646ec 100644 --- a/.github/workflows/ci-run.yml +++ b/.github/workflows/ci-run.yml @@ -9,7 +9,7 @@ on: branches: [dev, main] concurrency: - group: ci-${{ github.event.pull_request.number || github.sha }} + group: ci-run-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.sha }} cancel-in-progress: true permissions: @@ -24,12 +24,13 @@ env: jobs: changes: name: Detect Change Scope - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] outputs: docs_only: ${{ steps.scope.outputs.docs_only }} docs_changed: ${{ steps.scope.outputs.docs_changed }} rust_changed: ${{ steps.scope.outputs.rust_changed }} workflow_changed: ${{ steps.scope.outputs.workflow_changed }} + ci_cd_changed: ${{ steps.scope.outputs.ci_cd_changed }} docs_files: ${{ steps.scope.outputs.docs_files }} base_sha: ${{ steps.scope.outputs.base_sha }} steps: @@ -50,18 +51,34 @@ jobs: needs: [changes] if: needs.changes.outputs.rust_changed == 'true' runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 25 + timeout-minutes: 75 + 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 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 + - name: Self-heal Rust toolchain cache + shell: bash + run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0 + - name: Ensure C toolchain + shell: bash + run: bash ./scripts/ci/ensure_c_toolchain.sh - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: toolchain: 1.92.0 components: rustfmt, clippy - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 + - 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 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 with: - prefix-key: ci-run-lint + prefix-key: ci-run-check + cache-bin: false - name: Run rust quality gate run: ./scripts/ci/rust_quality_gate.sh - name: Run strict lint delta gate @@ -69,81 +86,135 @@ jobs: BASE_SHA: ${{ needs.changes.outputs.base_sha }} run: ./scripts/ci/rust_strict_delta_gate.sh - test: - name: Test - needs: [changes, lint] - if: needs.changes.outputs.rust_changed == 'true' && needs.lint.result == 'success' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 30 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: 1.92.0 - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 - with: - prefix-key: ci-run-test - - name: Run tests - run: cargo test --locked --verbose - - build: - name: Build (Smoke) + workspace-check: + name: Workspace Check needs: [changes] if: needs.changes.outputs.rust_changed == 'true' runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 - + timeout-minutes: 45 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 with: - prefix-key: ci-run-build - cache-targets: true - - name: Build binary (smoke check) - run: cargo build --profile release-fast --locked --verbose - - name: Check binary size - run: bash scripts/ci/check_binary_size.sh target/release-fast/zeroclaw + prefix-key: ci-run-workspace-check + cache-bin: false + - name: Check workspace + run: cargo check --workspace --locked - flake-probe: - name: Test Flake Retry Probe - needs: [changes, lint, test] - if: always() && needs.changes.outputs.rust_changed == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:full')) + package-check: + name: Package Check (${{ matrix.package }}) + needs: [changes] + if: needs.changes.outputs.rust_changed == 'true' runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + package: [zeroclaw-types, zeroclaw-core] steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 with: - prefix-key: ci-run-flake-probe - - name: Probe flaky failure via single retry + prefix-key: ci-run-package-check + cache-bin: false + - name: Check package + run: cargo check -p ${{ matrix.package }} --locked + + test: + name: Test + needs: [changes] + if: 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 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + with: + prefix-key: ci-run-check + cache-bin: false + - name: Run tests with flake detection shell: bash env: - INITIAL_TEST_RESULT: ${{ needs.test.result }} BLOCK_ON_FLAKE: ${{ vars.CI_BLOCK_ON_FLAKE_SUSPECTED || 'false' }} run: | set -euo pipefail mkdir -p artifacts - python3 scripts/ci/flake_retry_probe.py \ - --initial-result "${INITIAL_TEST_RESULT}" \ - --retry-command "cargo test --locked --verbose" \ - --output-json artifacts/flake-probe.json \ - --output-md artifacts/flake-probe.md \ - --block-on-flake "${BLOCK_ON_FLAKE}" + + toolchain_bin="" + if [ -n "${CARGO:-}" ]; then + toolchain_bin="$(dirname "${CARGO}")" + elif [ -n "${RUSTC:-}" ]; then + toolchain_bin="$(dirname "${RUSTC}")" + fi + + if [ -n "${toolchain_bin}" ] && [ -d "${toolchain_bin}" ]; then + case ":$PATH:" in + *":${toolchain_bin}:"*) ;; + *) export PATH="${toolchain_bin}:$PATH" ;; + esac + fi + + if cargo test --locked --verbose; then + echo '{"flake_suspected":false,"status":"success"}' > artifacts/flake-probe.json + exit 0 + fi + + echo "::warning::First test run failed. Retrying for flake detection..." + if cargo test --locked --verbose; then + echo '{"flake_suspected":true,"status":"flake"}' > artifacts/flake-probe.json + echo "::warning::Flake suspected — test passed on retry" + if [ "${BLOCK_ON_FLAKE}" = "true" ]; then + echo "BLOCK_ON_FLAKE is set; failing on suspected flake." + exit 1 + fi + exit 0 + fi + + echo '{"flake_suspected":false,"status":"failure"}' > artifacts/flake-probe.json + exit 1 - name: Publish flake probe summary if: always() shell: bash run: | set -euo pipefail - if [ -f artifacts/flake-probe.md ]; then - cat artifacts/flake-probe.md >> "$GITHUB_STEP_SUMMARY" - else - echo "Flake probe report missing." >> "$GITHUB_STEP_SUMMARY" + 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'])") + { + echo "### Test Flake Probe" + echo "- Status: \`${status}\`" + echo "- Flake suspected: \`${flake}\`" + } >> "$GITHUB_STEP_SUMMARY" fi - name: Upload flake probe artifact if: always() @@ -154,11 +225,163 @@ jobs: if-no-files-found: ignore retention-days: 14 + build: + name: Build (Smoke) + needs: [changes] + if: needs.changes.outputs.rust_changed == 'true' + runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + timeout-minutes: 90 + 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 + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + with: + prefix-key: ci-run-build + cache-targets: true + cache-bin: false + - name: Build binary (smoke check) + env: + CARGO_BUILD_JOBS: 2 + CI_SMOKE_BUILD_ATTEMPTS: 3 + run: bash scripts/ci/smoke_build_retry.sh + - name: Check binary size + env: + BINARY_SIZE_HARD_LIMIT_MB: 28 + BINARY_SIZE_ADVISORY_MB: 20 + BINARY_SIZE_TARGET_MB: 5 + run: bash scripts/ci/check_binary_size.sh target/release-fast/zeroclaw + + cross-platform-vm: + name: Cross-Platform VM (${{ matrix.name }}) + needs: [changes] + if: needs.changes.outputs.rust_changed == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 80 + strategy: + fail-fast: false + matrix: + include: + - name: ubuntu-24.04 + os: ubuntu-24.04 + shell: bash + command: cargo test --locked --lib --bins --verbose + - name: ubuntu-22.04 + os: ubuntu-22.04 + shell: bash + command: cargo test --locked --lib --bins --verbose + - name: windows-2022 + os: windows-2022 + shell: pwsh + command: cargo check --workspace --locked --all-targets --verbose + - name: macos-14 + os: macos-14 + shell: bash + command: cargo test --locked --lib --bins --verbose + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.92.0 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + with: + prefix-key: ci-run-cross-vm-${{ matrix.name }} + cache-bin: false + - name: Build and test on VM + shell: ${{ matrix.shell }} + run: ${{ matrix.command }} + + linux-distro-container: + name: Linux Distro Container (${{ matrix.name }}) + needs: [changes] + if: needs.changes.outputs.rust_changed == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - name: debian-bookworm + image: debian:bookworm-slim + - name: ubuntu-24.04 + image: ubuntu:24.04 + - name: fedora-41 + image: fedora:41 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Cargo check inside distro container + shell: bash + run: | + set -euo pipefail + docker run --rm \ + -e CARGO_TERM_COLOR=always \ + -v "$PWD":/work \ + -w /work \ + "${{ matrix.image }}" \ + /bin/bash -lc ' + set -euo pipefail + + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y --no-install-recommends \ + curl ca-certificates build-essential pkg-config libssl-dev git + elif command -v dnf >/dev/null 2>&1; then + dnf install -y \ + curl ca-certificates gcc gcc-c++ make pkgconfig openssl-devel git tar xz + else + echo "Unsupported package manager in ${HOSTNAME:-container}" >&2 + exit 1 + fi + + curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain 1.92.0 + . "$HOME/.cargo/env" + rustc --version + cargo --version + cargo check --workspace --locked --all-targets --verbose + ' + + docker-smoke: + name: Docker Container Smoke + needs: [changes] + if: needs.changes.outputs.rust_changed == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Build release container image + shell: bash + run: | + set -euo pipefail + docker build --target release --tag zeroclaw-ci:${{ github.sha }} . + - name: Run container smoke check + shell: bash + run: | + set -euo pipefail + docker run --rm zeroclaw-ci:${{ github.sha }} --version + docs-only: name: Docs-Only Fast Path needs: [changes] if: needs.changes.outputs.docs_only == 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] steps: - name: Skip heavy jobs for docs-only change run: echo "Docs-only change detected. Rust lint/test/build skipped." @@ -167,7 +390,7 @@ jobs: name: Non-Rust Fast Path needs: [changes] if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.rust_changed != 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] steps: - name: Skip Rust jobs for non-Rust change scope run: echo "No Rust-impacting files changed. Rust lint/test/build skipped." @@ -176,12 +399,16 @@ jobs: name: Docs Quality needs: [changes] if: needs.changes.outputs.docs_changed == 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] timeout-minutes: 15 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 + - name: Setup Node.js for markdown lint + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" - name: Markdown lint (changed lines only) env: @@ -212,7 +439,7 @@ jobs: - name: Link check (offline, added links only) if: steps.collect_links.outputs.count != '0' - uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2 + uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2 with: fail: true args: >- @@ -231,7 +458,7 @@ jobs: name: Lint Feedback if: github.event_name == 'pull_request' needs: [changes, lint, docs-quality] - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] permissions: contents: read pull-requests: write @@ -253,55 +480,11 @@ jobs: const script = require('./.github/workflows/scripts/lint_feedback.js'); await script({github, context, core}); - workflow-owner-approval: - name: Workflow Owner Approval - needs: [changes] - if: github.event_name == 'pull_request' && needs.changes.outputs.workflow_changed == 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - permissions: - contents: read - pull-requests: read - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Require owner approval for workflow file changes - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_OWNER_LOGINS: ${{ vars.WORKFLOW_OWNER_LOGINS }} - with: - script: | - const script = require('./.github/workflows/scripts/ci_workflow_owner_approval.js'); - await script({ github, context, core }); - - human-review-approval: - name: Human Review Approval - needs: [changes] - if: github.event_name == 'pull_request' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - permissions: - contents: read - pull-requests: read - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ github.event.pull_request.base.sha }} - - - name: Require at least one human approving review - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - HUMAN_REVIEW_BOT_LOGINS: ${{ vars.HUMAN_REVIEW_BOT_LOGINS }} - with: - script: | - const script = require('./.github/workflows/scripts/ci_human_review_guard.js'); - await script({ github, context, core }); - license-file-owner-guard: name: License File Owner Guard needs: [changes] if: github.event_name == 'pull_request' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] permissions: contents: read pull-requests: read @@ -318,8 +501,8 @@ jobs: ci-required: name: CI Required Gate if: always() - needs: [changes, lint, test, build, flake-probe, docs-only, non-rust, docs-quality, lint-feedback, workflow-owner-approval, human-review-approval, license-file-owner-guard] - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + needs: [changes, lint, workspace-check, package-check, test, build, 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 shell: bash @@ -327,120 +510,77 @@ jobs: set -euo pipefail event_name="${{ github.event_name }}" + base_ref="${{ github.base_ref }}" + head_ref="${{ github.head_ref }}" rust_changed="${{ needs.changes.outputs.rust_changed }}" docs_changed="${{ needs.changes.outputs.docs_changed }}" - workflow_changed="${{ needs.changes.outputs.workflow_changed }}" docs_result="${{ needs.docs-quality.result }}" - workflow_owner_result="${{ needs.workflow-owner-approval.result }}" - human_review_result="${{ needs.human-review-approval.result }}" license_owner_result="${{ needs.license-file-owner-guard.result }}" - if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then - echo "workflow_owner_approval=${workflow_owner_result}" - echo "human_review_approval=${human_review_result}" - echo "license_file_owner_guard=${license_owner_result}" - if [ "$event_name" = "pull_request" ] && [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then - echo "Workflow files changed but workflow owner approval gate did not pass." + # --- Helper: enforce PR governance gates --- + check_pr_governance() { + if [ "$event_name" != "pull_request" ]; then return 0; fi + if [ "$base_ref" = "main" ] && [ "$head_ref" != "dev" ]; then + echo "Promotion policy violation: PRs to main must originate from dev. Found ${head_ref} -> ${base_ref}." exit 1 fi - if [ "$event_name" = "pull_request" ] && [ "$human_review_result" != "success" ]; then - echo "Human review approval guard did not pass." - exit 1 - fi - if [ "$event_name" = "pull_request" ] && [ "$license_owner_result" != "success" ]; then + if [ "$license_owner_result" != "success" ]; then echo "License file owner guard did not pass." exit 1 fi + } + + check_docs_quality() { if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then - echo "Docs-only change detected, but docs-quality did not pass." + echo "Docs changed but docs-quality did not pass." exit 1 fi + } + + # --- Docs-only fast path --- + if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then + check_pr_governance + check_docs_quality echo "Docs-only fast path passed." exit 0 fi + # --- Non-rust fast path --- if [ "$rust_changed" != "true" ]; then - echo "rust_changed=false (non-rust fast path)" - echo "workflow_owner_approval=${workflow_owner_result}" - echo "human_review_approval=${human_review_result}" - echo "license_file_owner_guard=${license_owner_result}" - if [ "$event_name" = "pull_request" ] && [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then - echo "Workflow files changed but workflow owner approval gate did not pass." - exit 1 - fi - if [ "$event_name" = "pull_request" ] && [ "$human_review_result" != "success" ]; then - echo "Human review approval guard did not pass." - exit 1 - fi - if [ "$event_name" = "pull_request" ] && [ "$license_owner_result" != "success" ]; then - echo "License file owner guard did not pass." - exit 1 - fi - if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then - echo "Non-rust change touched docs, but docs-quality did not pass." - exit 1 - fi + check_pr_governance + check_docs_quality echo "Non-rust fast path passed." exit 0 fi + # --- Rust change path --- lint_result="${{ needs.lint.result }}" - lint_strict_delta_result="${{ needs.lint.result }}" + workspace_check_result="${{ needs.workspace-check.result }}" + package_check_result="${{ needs.package-check.result }}" test_result="${{ needs.test.result }}" build_result="${{ needs.build.result }}" - flake_result="${{ needs.flake-probe.result }}" + 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 }}" echo "lint=${lint_result}" - echo "lint_strict_delta=${lint_strict_delta_result}" + echo "workspace-check=${workspace_check_result}" + echo "package-check=${package_check_result}" echo "test=${test_result}" echo "build=${build_result}" - echo "flake_probe=${flake_result}" + echo "cross-platform-vm=${cross_platform_vm_result}" + echo "linux-distro-container=${linux_distro_container_result}" + echo "docker-smoke=${docker_smoke_result}" echo "docs=${docs_result}" - echo "workflow_owner_approval=${workflow_owner_result}" - echo "human_review_approval=${human_review_result}" echo "license_file_owner_guard=${license_owner_result}" - if [ "$event_name" = "pull_request" ] && [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then - echo "Workflow files changed but workflow owner approval gate did not pass." + check_pr_governance + + if [ "$lint_result" != "success" ] || [ "$workspace_check_result" != "success" ] || [ "$package_check_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ] || [ "$cross_platform_vm_result" != "success" ] || [ "$linux_distro_container_result" != "success" ] || [ "$docker_smoke_result" != "success" ]; then + echo "Required CI jobs did not pass: lint=${lint_result} workspace-check=${workspace_check_result} package-check=${package_check_result} test=${test_result} build=${build_result} cross-platform-vm=${cross_platform_vm_result} linux-distro-container=${linux_distro_container_result} docker-smoke=${docker_smoke_result}" exit 1 fi - if [ "$event_name" = "pull_request" ] && [ "$human_review_result" != "success" ]; then - echo "Human review approval guard did not pass." - exit 1 - fi + check_docs_quality - if [ "$event_name" = "pull_request" ] && [ "$license_owner_result" != "success" ]; then - echo "License file owner guard did not pass." - exit 1 - fi - - if [ "$event_name" = "pull_request" ]; then - if [ "$lint_result" != "success" ] || [ "$lint_strict_delta_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then - echo "Required PR CI jobs did not pass." - exit 1 - fi - if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then - echo "PR changed docs, but docs-quality did not pass." - exit 1 - fi - echo "PR required checks passed." - exit 0 - fi - - if [ "$lint_result" != "success" ] || [ "$lint_strict_delta_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then - echo "Required push CI jobs did not pass." - exit 1 - fi - - if [ "$flake_result" != "success" ]; then - echo "Flake probe did not pass under current blocking policy." - exit 1 - fi - - if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then - echo "Push changed docs, but docs-quality did not pass." - exit 1 - fi - - echo "Push required checks passed." + echo "All required checks passed." diff --git a/.github/workflows/ci-supply-chain-provenance.yml b/.github/workflows/ci-supply-chain-provenance.yml deleted file mode 100644 index 55eb28c95..000000000 --- a/.github/workflows/ci-supply-chain-provenance.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: CI Supply Chain Provenance - -on: - push: - branches: [dev, main] - paths: - - "Cargo.toml" - - "Cargo.lock" - - "src/**" - - "crates/**" - - "scripts/ci/generate_provenance.py" - - ".github/workflows/ci-supply-chain-provenance.yml" - workflow_dispatch: - schedule: - - cron: "20 6 * * 1" # Weekly Monday 06:20 UTC - -concurrency: - group: supply-chain-provenance-${{ github.ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - id-token: write - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - CARGO_TERM_COLOR: always - -jobs: - provenance: - name: Build + Provenance Bundle - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 35 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: 1.92.0 - - - name: Build release-fast artifact - shell: bash - run: | - set -euo pipefail - mkdir -p artifacts - host_target="$(rustc -vV | sed -n 's/^host: //p')" - cargo build --profile release-fast --locked --target "$host_target" - cp "target/${host_target}/release-fast/zeroclaw" "artifacts/zeroclaw-${host_target}" - sha256sum "artifacts/zeroclaw-${host_target}" > "artifacts/zeroclaw-${host_target}.sha256" - - - name: Generate provenance statement - shell: bash - run: | - set -euo pipefail - host_target="$(rustc -vV | sed -n 's/^host: //p')" - python3 scripts/ci/generate_provenance.py \ - --artifact "artifacts/zeroclaw-${host_target}" \ - --subject-name "zeroclaw-${host_target}" \ - --output "artifacts/provenance-${host_target}.intoto.json" - - - name: Install cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - - - name: Sign provenance bundle - shell: bash - run: | - set -euo pipefail - host_target="$(rustc -vV | sed -n 's/^host: //p')" - statement="artifacts/provenance-${host_target}.intoto.json" - cosign sign-blob --yes \ - --bundle="${statement}.sigstore.json" \ - --output-signature="${statement}.sig" \ - --output-certificate="${statement}.pem" \ - "${statement}" - - - name: Emit normalized audit event - shell: bash - run: | - set -euo pipefail - host_target="$(rustc -vV | sed -n 's/^host: //p')" - python3 scripts/ci/emit_audit_event.py \ - --event-type supply_chain_provenance \ - --input-json "artifacts/provenance-${host_target}.intoto.json" \ - --output-json "artifacts/audit-event-supply-chain-provenance.json" \ - --artifact-name supply-chain-provenance \ - --retention-days 30 - - - name: Upload provenance artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: supply-chain-provenance - path: artifacts/* - retention-days: 30 - - - name: Publish summary - shell: bash - run: | - set -euo pipefail - host_target="$(rustc -vV | sed -n 's/^host: //p')" - { - echo "### Supply Chain Provenance" - echo "- Target: \`${host_target}\`" - echo "- Artifact: \`artifacts/zeroclaw-${host_target}\`" - echo "- Statement: \`artifacts/provenance-${host_target}.intoto.json\`" - echo "- Signature: \`artifacts/provenance-${host_target}.intoto.json.sig\`" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml deleted file mode 100644 index 8ad35e63e..000000000 --- a/.github/workflows/deploy-web.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Deploy Web to GitHub Pages - -on: - push: - branches: [main] - paths: - - 'web/**' - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - build: - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '20' - - - name: Install dependencies - working-directory: ./web - run: npm ci - - - name: Build - working-directory: ./web - run: npm run build - - - name: Setup Pages - uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d - - - name: Upload artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa - with: - path: ./web/dist - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml deleted file mode 100644 index a344c7b0d..000000000 --- a/.github/workflows/docs-deploy.yml +++ /dev/null @@ -1,291 +0,0 @@ -name: Docs Deploy - -on: - pull_request: - branches: [dev, main] - paths: - - "docs/**" - - "README*.md" - - ".github/workflows/docs-deploy.yml" - - "scripts/ci/docs_quality_gate.sh" - - "scripts/ci/collect_changed_links.py" - - ".github/release/docs-deploy-policy.json" - - "scripts/ci/docs_deploy_guard.py" - push: - branches: [dev, main] - paths: - - "docs/**" - - "README*.md" - - ".github/workflows/docs-deploy.yml" - - "scripts/ci/docs_quality_gate.sh" - - "scripts/ci/collect_changed_links.py" - - ".github/release/docs-deploy-policy.json" - - "scripts/ci/docs_deploy_guard.py" - workflow_dispatch: - inputs: - deploy_target: - description: "preview uploads artifact only; production deploys to Pages" - required: true - default: preview - type: choice - options: - - preview - - production - preview_evidence_run_url: - description: "Required for manual production deploys when policy enforces preview promotion evidence" - required: false - default: "" - rollback_ref: - description: "Optional rollback source ref (tag/sha/ref) for manual production dispatch" - required: false - default: "" - -concurrency: - group: docs-deploy-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - docs-quality: - name: Docs Quality Gate - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 - outputs: - docs_files: ${{ steps.scope.outputs.docs_files }} - base_sha: ${{ steps.scope.outputs.base_sha }} - deploy_target: ${{ steps.deploy_guard.outputs.deploy_target }} - deploy_mode: ${{ steps.deploy_guard.outputs.deploy_mode }} - source_ref: ${{ steps.deploy_guard.outputs.source_ref }} - production_branch_ref: ${{ steps.deploy_guard.outputs.production_branch_ref }} - ready_to_deploy: ${{ steps.deploy_guard.outputs.ready_to_deploy }} - docs_preview_retention_days: ${{ steps.deploy_guard.outputs.docs_preview_retention_days }} - docs_guard_artifact_retention_days: ${{ steps.deploy_guard.outputs.docs_guard_artifact_retention_days }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - name: Resolve docs diff scope - id: scope - shell: bash - run: | - set -euo pipefail - - base_sha="" - docs_files="" - - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - base_sha="${{ github.event.pull_request.base.sha }}" - docs_files="$(git diff --name-only "$base_sha" HEAD | awk '/\.md$|\.mdx$|^README/ {print}')" - elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then - base_sha="${{ github.event.before }}" - if [ -n "$base_sha" ] && [ "$base_sha" != "0000000000000000000000000000000000000000" ]; then - docs_files="$(git diff --name-only "$base_sha" HEAD | awk '/\.md$|\.mdx$|^README/ {print}')" - fi - else - docs_files="$(git ls-files 'docs/**/*.md' 'README*.md')" - fi - - { - echo "base_sha=${base_sha}" - echo "docs_files<> "$GITHUB_OUTPUT" - - - name: Validate docs deploy contract - id: deploy_guard - shell: bash - env: - INPUT_DEPLOY_TARGET: ${{ github.event.inputs.deploy_target || '' }} - INPUT_PREVIEW_EVIDENCE_RUN_URL: ${{ github.event.inputs.preview_evidence_run_url || '' }} - INPUT_ROLLBACK_REF: ${{ github.event.inputs.rollback_ref || '' }} - run: | - set -euo pipefail - mkdir -p artifacts - python3 scripts/ci/docs_deploy_guard.py \ - --repo-root "$PWD" \ - --event-name "${GITHUB_EVENT_NAME}" \ - --git-ref "${GITHUB_REF}" \ - --git-sha "${GITHUB_SHA}" \ - --input-deploy-target "${INPUT_DEPLOY_TARGET}" \ - --input-preview-evidence-run-url "${INPUT_PREVIEW_EVIDENCE_RUN_URL}" \ - --input-rollback-ref "${INPUT_ROLLBACK_REF}" \ - --policy-file .github/release/docs-deploy-policy.json \ - --output-json artifacts/docs-deploy-guard.json \ - --output-md artifacts/docs-deploy-guard.md \ - --github-output-file "$GITHUB_OUTPUT" \ - --fail-on-violation - - - name: Emit docs deploy guard audit event - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/docs-deploy-guard.json ]; then - python3 scripts/ci/emit_audit_event.py \ - --event-type docs_deploy_guard \ - --input-json artifacts/docs-deploy-guard.json \ - --output-json artifacts/audit-event-docs-deploy-guard.json \ - --artifact-name docs-deploy-guard \ - --retention-days 21 - fi - - - name: Publish docs deploy guard summary - if: always() - shell: bash - run: | - set -euo pipefail - if [ -f artifacts/docs-deploy-guard.md ]; then - cat artifacts/docs-deploy-guard.md >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Upload docs deploy guard artifacts - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: docs-deploy-guard - path: | - artifacts/docs-deploy-guard.json - artifacts/docs-deploy-guard.md - artifacts/audit-event-docs-deploy-guard.json - if-no-files-found: ignore - retention-days: ${{ steps.deploy_guard.outputs.docs_guard_artifact_retention_days || 21 }} - - - name: Markdown quality gate - env: - BASE_SHA: ${{ steps.scope.outputs.base_sha }} - DOCS_FILES: ${{ steps.scope.outputs.docs_files }} - run: ./scripts/ci/docs_quality_gate.sh - - - name: Collect added links - id: links - if: github.event_name != 'workflow_dispatch' - shell: bash - env: - BASE_SHA: ${{ steps.scope.outputs.base_sha }} - DOCS_FILES: ${{ steps.scope.outputs.docs_files }} - run: | - set -euo pipefail - python3 ./scripts/ci/collect_changed_links.py \ - --base "$BASE_SHA" \ - --docs-files "$DOCS_FILES" \ - --output .ci-added-links.txt - count=$(wc -l < .ci-added-links.txt | tr -d ' ') - echo "count=$count" >> "$GITHUB_OUTPUT" - - - name: Link check (added links) - if: github.event_name != 'workflow_dispatch' && steps.links.outputs.count != '0' - uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2 - with: - fail: true - args: >- - --offline - --no-progress - --format detailed - .ci-added-links.txt - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Skip link check (none added) - if: github.event_name != 'workflow_dispatch' && steps.links.outputs.count == '0' - run: echo "No added links detected in changed docs lines." - - docs-preview: - name: Docs Preview Artifact - needs: [docs-quality] - if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_target == 'preview') - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 15 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Build preview bundle - shell: bash - run: | - set -euo pipefail - rm -rf site - mkdir -p site/docs - cp -R docs/. site/docs/ - cp README.md site/README.md - cat > site/index.md <<'EOF' - # ZeroClaw Docs Preview - - This preview bundle is produced by `.github/workflows/docs-deploy.yml`. - - - [Repository README](./README.md) - - [Docs Home](./docs/README.md) - EOF - - - name: Upload preview artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: docs-preview - path: site/** - if-no-files-found: error - retention-days: ${{ needs.docs-quality.outputs.docs_preview_retention_days || 14 }} - - docs-deploy: - name: Deploy Docs to GitHub Pages - needs: [docs-quality] - if: needs.docs-quality.outputs.deploy_target == 'production' && needs.docs-quality.outputs.ready_to_deploy == 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 - permissions: - contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.docs-quality.outputs.source_ref }} - - - name: Build deploy bundle - shell: bash - run: | - set -euo pipefail - rm -rf site - mkdir -p site/docs - cp -R docs/. site/docs/ - cp README.md site/README.md - cat > site/index.md <<'EOF' - # ZeroClaw Documentation - - This site is deployed automatically from `main` by `.github/workflows/docs-deploy.yml`. - - - [Repository README](./README.md) - - [Docs Home](./docs/README.md) - EOF - - - name: Publish deploy source summary - shell: bash - run: | - { - echo "## Docs Deploy Source" - echo "- Deploy mode: \`${{ needs.docs-quality.outputs.deploy_mode }}\`" - echo "- Source ref: \`${{ needs.docs-quality.outputs.source_ref }}\`" - echo "- Production branch ref: \`${{ needs.docs-quality.outputs.production_branch_ref }}\`" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Setup Pages - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 - with: - path: site - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/feature-matrix.yml b/.github/workflows/feature-matrix.yml deleted file mode 100644 index 80027243e..000000000 --- a/.github/workflows/feature-matrix.yml +++ /dev/null @@ -1,382 +0,0 @@ -name: Feature Matrix - -on: - push: - branches: [dev, main] - paths: - - "Cargo.toml" - - "Cargo.lock" - - "src/**" - - "crates/**" - - "tests/**" - - "scripts/ci/nightly_matrix_report.py" - - ".github/release/nightly-owner-routing.json" - - ".github/workflows/feature-matrix.yml" - pull_request: - branches: [dev, main] - paths: - - "Cargo.toml" - - "Cargo.lock" - - "src/**" - - "crates/**" - - "tests/**" - - "scripts/ci/nightly_matrix_report.py" - - ".github/release/nightly-owner-routing.json" - - ".github/workflows/feature-matrix.yml" - merge_group: - branches: [dev, main] - schedule: - - cron: "30 4 * * 1" # Weekly Monday 04:30 UTC - - cron: "15 3 * * *" # Daily 03:15 UTC (nightly profile) - workflow_dispatch: - inputs: - profile: - description: "compile = merge-gate matrix, nightly = integration-oriented lane commands" - required: true - default: compile - type: choice - options: - - compile - - nightly - fail_on_failure: - description: "Fail summary job when any lane fails" - required: true - default: true - type: boolean - -concurrency: - group: feature-matrix-${{ github.event.pull_request.number || github.ref || github.run_id }}-${{ github.event.inputs.profile || 'auto' }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - CARGO_TERM_COLOR: always - -jobs: - resolve-profile: - name: Resolve Matrix Profile - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - outputs: - profile: ${{ steps.resolve.outputs.profile }} - lane_job_prefix: ${{ steps.resolve.outputs.lane_job_prefix }} - summary_job_name: ${{ steps.resolve.outputs.summary_job_name }} - lane_retention_days: ${{ steps.resolve.outputs.lane_retention_days }} - lane_timeout_minutes: ${{ steps.resolve.outputs.lane_timeout_minutes }} - max_attempts: ${{ steps.resolve.outputs.max_attempts }} - summary_artifact_name: ${{ steps.resolve.outputs.summary_artifact_name }} - summary_json_name: ${{ steps.resolve.outputs.summary_json_name }} - summary_md_name: ${{ steps.resolve.outputs.summary_md_name }} - lane_artifact_prefix: ${{ steps.resolve.outputs.lane_artifact_prefix }} - fail_on_failure: ${{ steps.resolve.outputs.fail_on_failure }} - collect_history: ${{ steps.resolve.outputs.collect_history }} - steps: - - name: Resolve effective profile - id: resolve - shell: bash - run: | - set -euo pipefail - - profile="compile" - fail_on_failure="true" - lane_job_prefix="Matrix Lane" - summary_job_name="Feature Matrix Summary" - lane_retention_days="21" - lane_timeout_minutes="55" - max_attempts="1" - summary_artifact_name="feature-matrix-summary" - summary_json_name="feature-matrix-summary.json" - summary_md_name="feature-matrix-summary.md" - lane_artifact_prefix="feature-matrix" - collect_history="false" - - if [ "${GITHUB_EVENT_NAME}" = "schedule" ] && [ "${{ github.event.schedule }}" = "15 3 * * *" ]; then - profile="nightly" - elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - profile="${{ github.event.inputs.profile || 'compile' }}" - fail_on_failure="${{ github.event.inputs.fail_on_failure || 'true' }}" - fi - - if [ "$profile" = "nightly" ]; then - lane_job_prefix="Nightly Lane" - summary_job_name="Nightly Summary & Routing" - lane_retention_days="30" - lane_timeout_minutes="70" - max_attempts="2" - summary_artifact_name="nightly-all-features-summary" - summary_json_name="nightly-summary.json" - summary_md_name="nightly-summary.md" - lane_artifact_prefix="nightly-lane" - collect_history="true" - fi - - { - echo "profile=${profile}" - echo "lane_job_prefix=${lane_job_prefix}" - echo "summary_job_name=${summary_job_name}" - echo "lane_retention_days=${lane_retention_days}" - echo "lane_timeout_minutes=${lane_timeout_minutes}" - echo "max_attempts=${max_attempts}" - echo "summary_artifact_name=${summary_artifact_name}" - echo "summary_json_name=${summary_json_name}" - echo "summary_md_name=${summary_md_name}" - echo "lane_artifact_prefix=${lane_artifact_prefix}" - echo "fail_on_failure=${fail_on_failure}" - echo "collect_history=${collect_history}" - } >> "$GITHUB_OUTPUT" - - feature-check: - name: ${{ needs.resolve-profile.outputs.lane_job_prefix }} (${{ matrix.name }}) - needs: [resolve-profile] - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: ${{ fromJSON(needs.resolve-profile.outputs.lane_timeout_minutes) }} - strategy: - fail-fast: false - matrix: - include: - - name: default - compile_command: cargo check --locked - nightly_command: cargo test --locked --test agent_e2e --verbose - install_libudev: false - - name: whatsapp-web - compile_command: cargo check --locked --no-default-features --features whatsapp-web - nightly_command: cargo check --locked --no-default-features --features whatsapp-web --verbose - install_libudev: false - - name: browser-native - compile_command: cargo check --locked --no-default-features --features browser-native - nightly_command: cargo check --locked --no-default-features --features browser-native --verbose - install_libudev: false - - name: nightly-all-features - compile_command: cargo check --locked --all-features - nightly_command: cargo test --locked --all-features --test agent_e2e --verbose - install_libudev: true - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: 1.92.0 - - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 - with: - prefix-key: feature-matrix-${{ matrix.name }} - - - name: Ensure Linux deps for all-features lane - if: matrix.install_libudev - shell: bash - run: | - set -euo pipefail - - if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists libudev; then - echo "libudev development headers already available; skipping apt install." - exit 0 - fi - - echo "Installing missing libudev build dependencies..." - for attempt in 1 2 3; do - if sudo apt-get update -qq -o DPkg::Lock::Timeout=300 && \ - sudo apt-get install -y --no-install-recommends --no-upgrade -o DPkg::Lock::Timeout=300 libudev-dev pkg-config; then - echo "Dependency installation succeeded on attempt ${attempt}." - exit 0 - fi - if [ "$attempt" -eq 3 ]; then - echo "Failed to install libudev-dev/pkg-config after ${attempt} attempts." >&2 - exit 1 - fi - echo "Dependency installation failed on attempt ${attempt}; retrying in 10s..." - sleep 10 - done - - - name: Run matrix lane command - id: lane - shell: bash - run: | - set -euo pipefail - mkdir -p artifacts - - profile="${{ needs.resolve-profile.outputs.profile }}" - lane_command="${{ matrix.compile_command }}" - if [ "$profile" = "nightly" ]; then - lane_command="${{ matrix.nightly_command }}" - fi - - max_attempts="${{ needs.resolve-profile.outputs.max_attempts }}" - attempt=1 - status=1 - - started_at="$(date +%s)" - while [ "$attempt" -le "$max_attempts" ]; do - echo "Running lane command (attempt ${attempt}/${max_attempts}): ${lane_command}" - set +e - bash -lc "${lane_command}" - status=$? - set -e - if [ "$status" -eq 0 ]; then - break - fi - if [ "$attempt" -lt "$max_attempts" ]; then - sleep 5 - fi - attempt="$((attempt + 1))" - done - finished_at="$(date +%s)" - duration="$((finished_at - started_at))" - - lane_status="success" - if [ "$status" -ne 0 ]; then - lane_status="failure" - fi - - cat > "artifacts/nightly-result-${{ matrix.name }}.json" <> "$GITHUB_STEP_SUMMARY" - - echo "lane_status=${lane_status}" >> "$GITHUB_OUTPUT" - echo "lane_exit_code=${status}" >> "$GITHUB_OUTPUT" - - - name: Upload lane report - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: ${{ needs.resolve-profile.outputs.lane_artifact_prefix }}-${{ matrix.name }} - path: artifacts/nightly-result-${{ matrix.name }}.json - if-no-files-found: error - retention-days: ${{ fromJSON(needs.resolve-profile.outputs.lane_retention_days) }} - - - name: Enforce lane success - if: steps.lane.outputs.lane_status != 'success' - shell: bash - run: | - set -euo pipefail - code="${{ steps.lane.outputs.lane_exit_code }}" - if [[ "$code" =~ ^[0-9]+$ ]]; then - # shellcheck disable=SC2242 - exit "$code" - fi - echo "Invalid lane exit code: $code" >&2 - exit 1 - - summary: - name: ${{ needs.resolve-profile.outputs.summary_job_name }} - needs: [resolve-profile, feature-check] - if: always() - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Download lane reports - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - path: artifacts - - - name: Collect recent nightly history - if: needs.resolve-profile.outputs.collect_history == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require("fs"); - const path = require("path"); - - const workflowId = "feature-matrix.yml"; - const owner = context.repo.owner; - const repo = context.repo.repo; - - const events = ["schedule", "workflow_dispatch"]; - let runs = []; - for (const event of events) { - const resp = await github.rest.actions.listWorkflowRuns({ - owner, - repo, - workflow_id: workflowId, - branch: "dev", - event, - per_page: 20, - }); - runs = runs.concat(resp.data.workflow_runs || []); - } - - const currentRunId = context.runId; - runs = runs - .filter((run) => run.id !== currentRunId && run.status === "completed") - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .slice(0, 3) - .map((run) => ({ - run_id: run.id, - url: run.html_url, - event: run.event, - conclusion: run.conclusion || "unknown", - created_at: run.created_at, - head_sha: run.head_sha, - display_title: run.display_title || "", - })); - - fs.mkdirSync("artifacts", { recursive: true }); - fs.writeFileSync( - path.join("artifacts", "nightly-history.json"), - `${JSON.stringify(runs, null, 2)}\n`, - { encoding: "utf8" } - ); - - - name: Aggregate matrix summary - shell: bash - run: | - set -euo pipefail - args=( - --input-dir artifacts - --owners-file .github/release/nightly-owner-routing.json - --output-json "artifacts/${{ needs.resolve-profile.outputs.summary_json_name }}" - --output-md "artifacts/${{ needs.resolve-profile.outputs.summary_md_name }}" - ) - - if [ "${{ needs.resolve-profile.outputs.collect_history }}" = "true" ] && [ -f artifacts/nightly-history.json ]; then - args+=(--history-file artifacts/nightly-history.json) - fi - - if [ "${{ needs.resolve-profile.outputs.fail_on_failure }}" = "true" ]; then - args+=(--fail-on-failure) - fi - - python3 scripts/ci/nightly_matrix_report.py "${args[@]}" - - - name: Publish summary - shell: bash - run: | - set -euo pipefail - cat "artifacts/${{ needs.resolve-profile.outputs.summary_md_name }}" >> "$GITHUB_STEP_SUMMARY" - - - name: Upload summary artifact - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: ${{ needs.resolve-profile.outputs.summary_artifact_name }} - path: | - artifacts/${{ needs.resolve-profile.outputs.summary_json_name }} - artifacts/${{ needs.resolve-profile.outputs.summary_md_name }} - artifacts/nightly-history.json - if-no-files-found: error - retention-days: ${{ fromJSON(needs.resolve-profile.outputs.lane_retention_days) }} diff --git a/.github/workflows/main-branch-flow.md b/.github/workflows/main-branch-flow.md deleted file mode 100644 index 07cb1479c..000000000 --- a/.github/workflows/main-branch-flow.md +++ /dev/null @@ -1,266 +0,0 @@ -# Main Branch Delivery Flows - -This document explains what runs when code is proposed to `dev`/`main`, merged to `main`, and released. - -Use this with: - -- [`docs/ci-map.md`](../../docs/ci-map.md) -- [`docs/pr-workflow.md`](../../docs/pr-workflow.md) -- [`docs/release-process.md`](../../docs/release-process.md) - -## Event Summary - -| Event | Main workflows | -| --- | --- | -| PR activity (`pull_request_target`) | `pr-intake-checks.yml`, `pr-labeler.yml`, `pr-auto-response.yml` | -| PR activity (`pull_request`) | `ci-run.yml`, `sec-audit.yml`, plus path-scoped workflows | -| Push to `dev`/`main` | `ci-run.yml`, `sec-audit.yml`, plus path-scoped workflows | -| Tag push (`v*`) | `pub-release.yml` publish mode, `pub-docker-img.yml` publish job | -| Scheduled/manual | `pub-release.yml` verification mode, `sec-codeql.yml`, `feature-matrix.yml`, `test-fuzz.yml`, `pr-check-stale.yml`, `pr-check-status.yml`, `sync-contributors.yml`, `test-benchmarks.yml`, `test-e2e.yml` | - -## Runtime and Docker Matrix - -Observed averages below are from recent completed runs (sampled from GitHub Actions on February 17, 2026). Values are directional, not SLA. - -| Workflow | Typical trigger in main flow | Avg runtime | Docker build? | Docker run? | Docker push? | -| --- | --- | ---:| --- | --- | --- | -| `pr-intake-checks.yml` | PR open/update (`pull_request_target`) | 14.5s | No | No | No | -| `pr-labeler.yml` | PR open/update (`pull_request_target`) | 53.7s | No | No | No | -| `pr-auto-response.yml` | PR/issue automation | 24.3s | No | No | No | -| `ci-run.yml` | PR + push to `dev`/`main` | 74.7s | No | No | No | -| `sec-audit.yml` | PR + push to `dev`/`main` | 127.2s | No | No | No | -| `workflow-sanity.yml` | Workflow-file changes | 34.2s | No | No | No | -| `pr-label-policy-check.yml` | Label policy/automation changes | 14.7s | No | No | No | -| `pub-docker-img.yml` (`pull_request`) | Docker build-input PR changes | 240.4s | Yes | Yes | No | -| `pub-docker-img.yml` (`push`) | tag push `v*` | 139.9s | Yes | No | Yes | -| `pub-release.yml` | Tag push `v*` (publish) + manual/scheduled verification (no publish) | N/A in recent sample | No | No | No | - -Notes: - -1. `pub-docker-img.yml` is the only workflow in the main PR/push path that builds Docker images. -2. Container runtime verification (`docker run`) occurs in PR smoke only. -3. Container registry push occurs on tag pushes (`v*`) only. -4. `ci-run.yml` "Build (Smoke)" builds Rust binaries, not Docker images. - -## Step-By-Step - -### 1) PR from branch in this repository -> `dev` - -1. Contributor opens or updates PR against `dev`. -2. `pull_request_target` automation runs (typical runtime): - - `pr-intake-checks.yml` posts intake warnings/errors. - - `pr-labeler.yml` sets size/risk/scope labels. - - `pr-auto-response.yml` runs first-interaction and label routes. -3. `pull_request` CI workflows start: - - `ci-run.yml` - - `feature-matrix.yml` (Rust/workflow path scope) - - `sec-audit.yml` - - `sec-codeql.yml` (if Rust/codeql paths changed) - - path-scoped workflows if matching files changed: - - `pub-docker-img.yml` (Docker build-input paths only) - - `docs-deploy.yml` (docs + README markdown paths; deploy contract guard enforces promotion + rollback ref policy) - - `workflow-sanity.yml` (workflow files only) - - `pr-label-policy-check.yml` (label-policy files only) - - `ci-change-audit.yml` (CI/security path changes) - - `ci-provider-connectivity.yml` (probe config/script/workflow changes) - - `ci-reproducible-build.yml` (Rust/build reproducibility paths) -4. In `ci-run.yml`, `changes` computes: - - `docs_only` - - `docs_changed` - - `rust_changed` - - `workflow_changed` -5. `build` runs for Rust-impacting changes. -6. On PRs, full lint/test/docs checks run when PR has label `ci:full`: - - `lint` - - `lint-strict-delta` - - `test` - - `flake-probe` (single-retry telemetry; optional block via `CI_BLOCK_ON_FLAKE_SUSPECTED`) - - `docs-quality` -7. If `.github/workflows/**` changed, `workflow-owner-approval` must pass. -8. If root license files (`LICENSE-APACHE`, `LICENSE-MIT`) changed, `license-file-owner-guard` allows only PR author `willsarg`. -9. `lint-feedback` posts actionable comment if lint/docs gates fail. -10. `CI Required Gate` aggregates results to final pass/fail. -11. Maintainer merges PR once checks and review policy are satisfied. -12. Merge emits a `push` event on `dev` (see scenario 4). - -### 2) PR from fork -> `dev` - -1. External contributor opens PR from `fork/` into `zeroclaw:dev`. -2. Immediately on `opened`: - - `pull_request_target` workflows start with base-repo context and base-repo token: - - `pr-intake-checks.yml` - - `pr-labeler.yml` - - `pr-auto-response.yml` - - `pull_request` workflows are queued for the fork head commit: - - `ci-run.yml` - - `sec-audit.yml` - - path-scoped workflows (`pub-docker-img.yml`, `workflow-sanity.yml`, `pr-label-policy-check.yml`) if changed files match. -3. Fork-specific permission behavior in `pull_request` workflows: - - token is restricted (read-focused), so jobs that try to write PR comments/status extras can be limited. - - secrets from the base repo are not exposed to fork PR `pull_request` jobs. -4. Approval gate possibility: - - if Actions settings require maintainer approval for fork workflows, the `pull_request` run stays in `action_required`/waiting state until approved. -5. Event fan-out after labeling: - - `pr-labeler.yml` and manual label changes emit `labeled`/`unlabeled` events. - - those events retrigger `pull_request_target` automation (`pr-labeler.yml` and `pr-auto-response.yml`), creating extra run volume/noise. -6. When contributor pushes new commits to fork branch (`synchronize`): - - reruns: `pr-intake-checks.yml`, `pr-labeler.yml`, `ci-run.yml`, `sec-audit.yml`, and matching path-scoped PR workflows. - - does not rerun `pr-auto-response.yml` unless label/open events occur. -7. `ci-run.yml` execution details for fork PR: - - `changes` computes `docs_only`, `docs_changed`, `rust_changed`, `workflow_changed`. - - `build` runs for Rust-impacting changes. - - `lint`/`lint-strict-delta`/`test`/`docs-quality` run on PR when `ci:full` label exists. - - `workflow-owner-approval` runs when `.github/workflows/**` changed. - - `CI Required Gate` emits final pass/fail for the PR head. -8. Fork PR merge blockers to check first when diagnosing stalls: - - run approval pending for fork workflows. - - `workflow-owner-approval` failing on workflow-file changes. - - `license-file-owner-guard` failing when root license files are modified by non-owner PR author. - - `CI Required Gate` failure caused by upstream jobs. - - repeated `pull_request_target` reruns from label churn causing noisy signals. -9. After merge, normal `push` workflows on `dev` execute (scenario 4). - -### 3) PR to `main` (direct or from `dev`) - -1. Contributor or maintainer opens PR with base `main`. -2. `ci-run.yml` and `sec-audit.yml` run on the PR, plus any path-scoped workflows. -3. Maintainer merges PR once checks and review policy pass. -4. Merge emits a `push` event on `main`. - -### 4) Push/Merge Queue to `dev` or `main` (including after merge) - -1. Commit reaches `dev` or `main` (usually from a merged PR), or merge queue creates a `merge_group` validation commit. -2. `ci-run.yml` runs on `push` and `merge_group`. -3. `feature-matrix.yml` runs on `push` for Rust/workflow paths and on `merge_group`. -4. `sec-audit.yml` runs on `push` and `merge_group`. -5. `sec-codeql.yml` runs on `push`/`merge_group` when Rust/codeql paths change (path-scoped on push). -6. `ci-supply-chain-provenance.yml` runs on push when Rust/build provenance paths change. -7. Path-filtered workflows run only if touched files match their filters. -8. In `ci-run.yml`, push/merge-group behavior differs from PR behavior: - - Rust path: `lint`, `lint-strict-delta`, `test`, `build` are expected. - - Docs/non-rust paths: fast-path behavior applies. -9. `CI Required Gate` computes overall push/merge-group result. - -## Docker Publish Logic - -Workflow: `.github/workflows/pub-docker-img.yml` - -### PR behavior - -1. Triggered on `pull_request` to `dev` or `main` when Docker build-input paths change. -2. Runs `PR Docker Smoke` job: - - Builds local smoke image with Blacksmith builder. - - Verifies container with `docker run ... --version`. -3. Typical runtime in recent sample: ~240.4s. -4. No registry push happens on PR events. - -### Push behavior - -1. `publish` job runs on tag pushes `v*` only. -2. Workflow trigger includes semantic version tag pushes (`v*`) only. -3. Login to `ghcr.io` uses `${{ github.actor }}` and `${{ secrets.GITHUB_TOKEN }}`. -4. Tag computation includes semantic tag from pushed git tag (`vX.Y.Z`) + SHA tag (`sha-<12>`) + `latest`. -5. Multi-platform publish is used for tag pushes (`linux/amd64,linux/arm64`). -6. `scripts/ci/ghcr_publish_contract_guard.py` validates anonymous pullability and digest parity across `vX.Y.Z`, `sha-<12>`, and `latest`, then emits rollback candidate mapping evidence. -7. Trivy scans are emitted for version, SHA, and latest references. -8. `scripts/ci/ghcr_vulnerability_gate.py` validates Trivy JSON outputs against `.github/release/ghcr-vulnerability-policy.json` and emits audit-event evidence. -9. Typical runtime in recent sample: ~139.9s. -10. Result: pushed image tags under `ghcr.io//` with publish-contract + vulnerability-gate + scan artifacts. - -Important: Docker publish now requires a `v*` tag push; regular `dev`/`main` branch pushes do not publish images. - -## Release Logic - -Workflow: `.github/workflows/pub-release.yml` - -1. Trigger modes: - - Tag push `v*` -> publish mode. - - Manual dispatch -> verification-only or publish mode (input-driven). - - Weekly schedule -> verification-only mode. -2. `prepare` resolves release context (`release_ref`, `release_tag`, publish/draft mode) and runs `scripts/ci/release_trigger_guard.py`. - - publish mode enforces actor authorization, stable annotated tag policy, `origin/main` ancestry, and `release_tag` == `Cargo.toml` version at the tag commit. - - trigger provenance is emitted as `release-trigger-guard` artifacts. -3. `build-release` builds matrix artifacts across Linux/macOS/Windows targets. -4. `verify-artifacts` runs `scripts/ci/release_artifact_guard.py` against `.github/release/release-artifact-contract.json` in verify-stage mode (archive contract required; manifest/SBOM/notice checks intentionally skipped) and uploads `release-artifact-guard-verify` evidence. -5. In publish mode, workflow generates SBOM (`CycloneDX` + `SPDX`), `SHA256SUMS`, and a checksum provenance statement (`zeroclaw.sha256sums.intoto.json`) plus audit-event envelope. -6. In publish mode, after manifest generation, workflow reruns `release_artifact_guard.py` in full-contract mode and emits `release-artifact-guard.publish.json` plus `audit-event-release-artifact-guard-publish.json`. -7. In publish mode, workflow keyless-signs release artifacts and composes a supply-chain release-notes preface via `release_notes_with_supply_chain_refs.py`. -8. In publish mode, workflow verifies GHCR release-tag availability. -9. In publish mode, workflow creates/updates the GitHub Release for the resolved tag and commit-ish, combining generated supply-chain preface with GitHub auto-generated commit notes. - -Pre-release path: - -1. Pre-release tags (`vX.Y.Z-alpha.N`, `vX.Y.Z-beta.N`, `vX.Y.Z-rc.N`) trigger `.github/workflows/pub-prerelease.yml`. -2. `scripts/ci/prerelease_guard.py` enforces stage progression, `origin/main` ancestry, and Cargo version/tag alignment. -3. In publish mode, prerelease assets are attached to a GitHub prerelease for the stage tag. - -Canary policy lane: - -1. `.github/workflows/ci-canary-gate.yml` runs weekly or manually. -2. `scripts/ci/canary_guard.py` evaluates metrics against `.github/release/canary-policy.json`. -3. Decision output is explicit (`promote`, `hold`, `abort`) with auditable artifacts and optional dispatch signal. - -## Merge/Policy Notes - -1. Workflow-file changes (`.github/workflows/**`) activate owner-approval gate in `ci-run.yml`. -2. PR lint/test strictness is intentionally controlled by `ci:full` label. -3. `pr-intake-checks.yml` now blocks PRs missing a Linear issue key (`RMN-*`, `CDV-*`, `COM-*`) to keep execution mapped to Linear. -4. `sec-audit.yml` runs on PR/push/merge queue (`merge_group`), plus scheduled weekly. -5. `ci-change-audit.yml` enforces pinned `uses:` references for CI/security workflow changes. -6. `sec-audit.yml` includes deny policy hygiene checks (`deny_policy_guard.py`) before cargo-deny. -7. `sec-audit.yml` includes gitleaks allowlist governance checks (`secrets_governance_guard.py`) against `.github/security/gitleaks-allowlist-governance.json`. -8. `ci-reproducible-build.yml` and `ci-supply-chain-provenance.yml` provide scheduled supply-chain assurance signals outside release-only windows. -9. Some workflows are operational and non-merge-path (`pr-check-stale`, `pr-check-status`, `sync-contributors`, etc.). -10. Workflow-specific JavaScript helpers are organized under `.github/workflows/scripts/`. -11. `ci-run.yml` includes cache partitioning (`prefix-key`) across lint/test/build/flake-probe lanes to reduce cache contention. -12. `ci-rollback.yml` provides a guarded rollback planning lane (scheduled dry-run + manual execute controls) with audit artifacts. - -## Mermaid Diagrams - -### PR to Dev - -```mermaid -flowchart TD - A["PR opened or updated -> dev"] --> B["pull_request_target lane"] - B --> B1["pr-intake-checks.yml"] - B --> B2["pr-labeler.yml"] - B --> B3["pr-auto-response.yml"] - A --> C["pull_request CI lane"] - C --> C1["ci-run.yml"] - C --> C2["sec-audit.yml"] - C --> C3["pub-docker-img.yml (if Docker paths changed)"] - C --> C4["workflow-sanity.yml (if workflow files changed)"] - C --> C5["pr-label-policy-check.yml (if policy files changed)"] - C1 --> D["CI Required Gate"] - D --> E{"Checks + review policy pass?"} - E -->|No| F["PR stays open"] - E -->|Yes| G["Merge PR"] - G --> H["push event on dev"] -``` - -### Main Delivery and Release - -```mermaid -flowchart TD - D0["Commit reaches dev"] --> B0["ci-run.yml"] - D0 --> C0["sec-audit.yml"] - PRM["PR to main"] --> QM["ci-run.yml + sec-audit.yml (+ path-scoped)"] - QM --> M["Merge to main"] - M --> A["Commit reaches main"] - A --> B["ci-run.yml"] - A --> C["sec-audit.yml"] - A --> D["path-scoped workflows (if matched)"] - T["Tag push v*"] --> R["pub-release.yml"] - W["Manual/Scheduled release verify"] --> R - T --> DP["pub-docker-img.yml publish job"] - R --> R1["Artifacts + SBOM + checksums + signatures + GitHub Release"] - W --> R2["Verification build only (no GitHub Release publish)"] - DP --> P1["Push ghcr image tags (version + sha + latest)"] -``` - -## Quick Troubleshooting - -1. Unexpected skipped jobs: inspect `scripts/ci/detect_change_scope.sh` outputs. -2. Workflow-change PR blocked: verify `WORKFLOW_OWNER_LOGINS` and approvals. -3. Fork PR appears stalled: check whether Actions run approval is pending. -4. Docker not published: confirm a `v*` tag was pushed to the intended commit. diff --git a/.github/workflows/nightly-all-features.yml b/.github/workflows/nightly-all-features.yml deleted file mode 100644 index caee4a269..000000000 --- a/.github/workflows/nightly-all-features.yml +++ /dev/null @@ -1,187 +0,0 @@ -name: Nightly All-Features - -on: - schedule: - - cron: "15 3 * * *" # Daily 03:15 UTC - workflow_dispatch: - inputs: - fail_on_failure: - description: "Fail workflow when any nightly lane fails" - required: true - default: true - type: boolean - -concurrency: - group: nightly-all-features-${{ github.ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - CARGO_TERM_COLOR: always - -jobs: - nightly-lanes: - name: Nightly Lane (${{ matrix.name }}) - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 70 - strategy: - fail-fast: false - matrix: - include: - - name: default - command: cargo test --locked --test agent_e2e --verbose - install_libudev: false - - name: whatsapp-web - command: cargo check --locked --no-default-features --features whatsapp-web --verbose - install_libudev: false - - name: browser-native - command: cargo check --locked --no-default-features --features browser-native --verbose - install_libudev: false - - name: nightly-all-features - command: cargo test --locked --all-features --test agent_e2e --verbose - install_libudev: true - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: 1.92.0 - - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 - with: - prefix-key: nightly-all-features-${{ matrix.name }} - - - name: Ensure Linux deps for all-features lane - if: matrix.install_libudev - shell: bash - run: | - set -euo pipefail - - if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists libudev; then - echo "libudev development headers already available; skipping apt install." - exit 0 - fi - - echo "Installing missing libudev build dependencies..." - for attempt in 1 2 3; do - if sudo apt-get update -qq -o DPkg::Lock::Timeout=300 && \ - sudo apt-get install -y --no-install-recommends --no-upgrade -o DPkg::Lock::Timeout=300 libudev-dev pkg-config; then - echo "Dependency installation succeeded on attempt ${attempt}." - exit 0 - fi - if [ "$attempt" -eq 3 ]; then - echo "Failed to install libudev-dev/pkg-config after ${attempt} attempts." >&2 - exit 1 - fi - echo "Dependency installation failed on attempt ${attempt}; retrying in 10s..." - sleep 10 - done - - - name: Run nightly lane command - id: lane - shell: bash - run: | - set -euo pipefail - mkdir -p artifacts - started_at="$(date +%s)" - set +e - bash -lc "${{ matrix.command }}" - status=$? - set -e - finished_at="$(date +%s)" - duration="$((finished_at - started_at))" - - lane_status="success" - if [ "$status" -ne 0 ]; then - lane_status="failure" - fi - - cat > "artifacts/nightly-result-${{ matrix.name }}.json" <> "$GITHUB_STEP_SUMMARY" - - echo "lane_status=${lane_status}" >> "$GITHUB_OUTPUT" - echo "lane_exit_code=${status}" >> "$GITHUB_OUTPUT" - - - name: Upload nightly lane artifact - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: nightly-lane-${{ matrix.name }} - path: artifacts/nightly-result-${{ matrix.name }}.json - if-no-files-found: error - retention-days: 30 - - nightly-summary: - name: Nightly Summary & Routing - needs: [nightly-lanes] - if: always() - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Download nightly artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - path: artifacts - - - name: Aggregate nightly report - shell: bash - env: - FAIL_ON_FAILURE_INPUT: ${{ github.event.inputs.fail_on_failure || 'true' }} - run: | - set -euo pipefail - fail_on_failure="true" - if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then - fail_on_failure="${FAIL_ON_FAILURE_INPUT}" - fi - - args=() - if [ "$fail_on_failure" = "true" ]; then - args+=(--fail-on-failure) - fi - - python3 scripts/ci/nightly_matrix_report.py \ - --input-dir artifacts \ - --owners-file .github/release/nightly-owner-routing.json \ - --output-json artifacts/nightly-summary.json \ - --output-md artifacts/nightly-summary.md \ - "${args[@]}" - - - name: Publish nightly summary - shell: bash - run: | - set -euo pipefail - cat artifacts/nightly-summary.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload nightly summary artifacts - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: nightly-all-features-summary - path: | - artifacts/nightly-summary.json - artifacts/nightly-summary.md - if-no-files-found: error - retention-days: 30 diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml deleted file mode 100644 index 34fca0b01..000000000 --- a/.github/workflows/pages-deploy.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Deploy GitHub Pages - -on: - push: - branches: - - main - paths: - - site/** - - docs/** - - README.md - - .github/workflows/pages-deploy.yml - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: github-pages - cancel-in-progress: true - -jobs: - build: - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: site/package-lock.json - - - name: Install Dependencies - working-directory: site - run: npm ci - - - name: Build Site - working-directory: site - run: npm run build - - - name: Configure Pages - uses: actions/configure-pages@v5 - - - name: Upload Artifact - uses: actions/upload-pages-artifact@v3 - with: - path: gh-pages - - deploy: - needs: build - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pr-auto-response.yml b/.github/workflows/pr-auto-response.yml deleted file mode 100644 index 9cf1a7cd2..000000000 --- a/.github/workflows/pr-auto-response.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: PR Auto Responder - -on: - issues: - types: [opened, reopened, labeled, unlabeled] - pull_request_target: - branches: [dev, main] - types: [opened, labeled, unlabeled] - -permissions: {} - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - LABEL_POLICY_PATH: .github/label-policy.json - -jobs: - contributor-tier-issues: - if: >- - (github.event_name == 'issues' && - (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'labeled' || github.event.action == 'unlabeled')) || - (github.event_name == 'pull_request_target' && - (github.event.action == 'labeled' || github.event.action == 'unlabeled')) - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - permissions: - contents: read - issues: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Apply contributor tier label for issue author - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - LABEL_POLICY_PATH: .github/label-policy.json - with: - script: | - const script = require('./.github/workflows/scripts/pr_auto_response_contributor_tier.js'); - await script({ github, context, core }); - first-interaction: - if: github.event.action == 'opened' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - permissions: - issues: write - pull-requests: write - steps: - - name: Greet first-time contributors - uses: actions/first-interaction@a1db7729b356323c7988c20ed6f0d33fe31297be # v1 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - issue_message: | - Thanks for opening this issue. - - Before maintainers triage it, please confirm: - - Repro steps are complete and run on latest `main` - - Environment details are included (OS, Rust version, ZeroClaw version) - - Sensitive values are redacted - - This helps us keep issue throughput high and response latency low. - pr_message: | - Thanks for contributing to ZeroClaw. - - For faster review, please ensure: - - PR template sections are fully completed - - `cargo fmt --all -- --check`, `cargo clippy --all-targets -- -D warnings`, and `cargo test` are included - - If automation/agents were used heavily, add brief workflow notes - - Scope is focused (prefer one concern per PR) - - See `CONTRIBUTING.md` and `docs/pr-workflow.md` for full collaboration rules. - - labeled-routes: - if: github.event.action == 'labeled' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - permissions: - contents: read - issues: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Handle label-driven responses - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const script = require('./.github/workflows/scripts/pr_auto_response_labeled_routes.js'); - await script({ github, context, core }); diff --git a/.github/workflows/pr-check-stale.yml b/.github/workflows/pr-check-stale.yml deleted file mode 100644 index 7c2907778..000000000 --- a/.github/workflows/pr-check-stale.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: PR Check Stale - -on: - schedule: - - cron: "20 2 * * *" - workflow_dispatch: - -permissions: {} - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - -jobs: - stale: - permissions: - issues: write - pull-requests: write - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - steps: - - name: Mark stale issues and pull requests - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-issue-stale: 21 - days-before-issue-close: 7 - days-before-pr-stale: 14 - days-before-pr-close: 7 - stale-issue-label: stale - stale-pr-label: stale - exempt-issue-labels: security,pinned,no-stale,no-pr-hygiene,maintainer - exempt-pr-labels: no-stale,no-pr-hygiene,maintainer - remove-stale-when-updated: true - exempt-all-assignees: true - operations-per-run: 300 - stale-issue-message: | - This issue was automatically marked as stale due to inactivity. - Please provide an update, reproduction details, or current status to keep it open. - close-issue-message: | - Closing this issue due to inactivity. - If the problem still exists on the latest `main`, please open a new issue with fresh repro steps. - close-issue-reason: not_planned - stale-pr-message: | - This PR was automatically marked as stale due to inactivity. - Please rebase/update and post the latest validation results. - close-pr-message: | - Closing this PR due to inactivity. - Maintainers can reopen once the branch is updated and validation is provided. diff --git a/.github/workflows/pr-check-status.yml b/.github/workflows/pr-check-status.yml deleted file mode 100644 index 5fcdab22c..000000000 --- a/.github/workflows/pr-check-status.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: PR Check Status - -on: - schedule: - - cron: "15 8 * * *" # Once daily at 8:15am UTC - workflow_dispatch: - -permissions: {} - -concurrency: - group: pr-check-status - cancel-in-progress: true - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - -jobs: - nudge-stale-prs: - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - permissions: - contents: read - pull-requests: write - issues: write - env: - STALE_HOURS: "48" - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Nudge PRs that need rebase or CI refresh - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const script = require('./.github/workflows/scripts/pr_check_status_nudge.js'); - await script({ github, context, core }); diff --git a/.github/workflows/pr-intake-checks.yml b/.github/workflows/pr-intake-checks.yml deleted file mode 100644 index 1e84dcc18..000000000 --- a/.github/workflows/pr-intake-checks.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: PR Intake Checks - -on: - pull_request_target: - branches: [dev, main] - types: [opened, reopened, synchronize, edited, ready_for_review] - -concurrency: - group: pr-intake-checks-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - issues: write - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - intake: - name: Intake Checks - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Run safe PR intake checks - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const script = require('./.github/workflows/scripts/pr_intake_checks.js'); - await script({ github, context, core }); diff --git a/.github/workflows/pr-label-policy-check.yml b/.github/workflows/pr-label-policy-check.yml deleted file mode 100644 index 613071ff2..000000000 --- a/.github/workflows/pr-label-policy-check.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: PR Label Policy Check - -on: - pull_request: - paths: - - ".github/label-policy.json" - - ".github/workflows/pr-labeler.yml" - - ".github/workflows/pr-auto-response.yml" - push: - paths: - - ".github/label-policy.json" - - ".github/workflows/pr-labeler.yml" - - ".github/workflows/pr-auto-response.yml" - -concurrency: - group: pr-label-policy-check-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - contributor-tier-consistency: - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Verify shared label policy and workflow wiring - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - import json - import re - from pathlib import Path - - policy_path = Path('.github/label-policy.json') - policy = json.loads(policy_path.read_text(encoding='utf-8')) - color = str(policy.get('contributor_tier_color', '')).upper() - rules = policy.get('contributor_tiers', []) - if not re.fullmatch(r'[0-9A-F]{6}', color): - raise SystemExit('invalid contributor_tier_color in .github/label-policy.json') - if not rules: - raise SystemExit('contributor_tiers must not be empty in .github/label-policy.json') - - labels = set() - prev_min = None - for entry in rules: - label = str(entry.get('label', '')).strip().lower() - min_merged = int(entry.get('min_merged_prs', 0)) - if not label.endswith('contributor'): - raise SystemExit(f'invalid contributor tier label: {label}') - if label in labels: - raise SystemExit(f'duplicate contributor tier label: {label}') - if prev_min is not None and min_merged > prev_min: - raise SystemExit('contributor_tiers must be sorted descending by min_merged_prs') - labels.add(label) - prev_min = min_merged - - workflow_paths = [ - Path('.github/workflows/pr-labeler.yml'), - Path('.github/workflows/pr-auto-response.yml'), - ] - for workflow in workflow_paths: - text = workflow.read_text(encoding='utf-8') - if '.github/label-policy.json' not in text: - raise SystemExit(f'{workflow} must load .github/label-policy.json') - if re.search(r'contributorTierColor\s*=\s*"[0-9A-Fa-f]{6}"', text): - raise SystemExit(f'{workflow} contains hardcoded contributorTierColor') - - print('label policy file is valid and workflow consumers are wired to shared policy') - PY diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml deleted file mode 100644 index 2e609888a..000000000 --- a/.github/workflows/pr-labeler.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: PR Labeler - -on: - pull_request_target: - branches: [dev, main] - types: [opened, reopened, synchronize, edited, labeled, unlabeled] - workflow_dispatch: - inputs: - mode: - description: "Run mode for managed-label governance" - required: true - default: "audit" - type: choice - options: - - audit - - repair - -concurrency: - group: pr-labeler-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - issues: write - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - LABEL_POLICY_PATH: .github/label-policy.json - -jobs: - label: - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Apply path labels - if: github.event_name == 'pull_request_target' - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 - continue-on-error: true - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - sync-labels: true - - - name: Apply size/risk/module labels - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - continue-on-error: true - env: - LABEL_POLICY_PATH: .github/label-policy.json - with: - script: | - const script = require('./.github/workflows/scripts/pr_labeler.js'); - await script({ github, context, core }); diff --git a/.github/workflows/pub-docker-img.yml b/.github/workflows/pub-docker-img.yml index 094218275..1a6520e29 100644 --- a/.github/workflows/pub-docker-img.yml +++ b/.github/workflows/pub-docker-img.yml @@ -17,6 +17,11 @@ on: - "scripts/ci/ghcr_publish_contract_guard.py" - "scripts/ci/ghcr_vulnerability_gate.py" workflow_dispatch: + inputs: + release_tag: + description: "Existing release tag to publish (e.g. v0.2.0). Leave empty for smoke-only run." + required: false + type: string concurrency: group: docker-${{ github.event.pull_request.number || github.ref }} @@ -32,7 +37,7 @@ env: jobs: pr-smoke: name: PR Docker Smoke - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && inputs.release_tag == '') runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] timeout-minutes: 25 permissions: @@ -41,6 +46,20 @@ jobs: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Resolve Docker API version + shell: bash + run: | + set -euo pipefail + server_api="$(docker version --format '{{.Server.APIVersion}}')" + min_api="$(docker version --format '{{.Server.MinAPIVersion}}' 2>/dev/null || true)" + if [[ -z "${server_api}" || "${server_api}" == "" ]]; then + echo "::error::Unable to detect Docker server API version." + docker version || true + exit 1 + fi + echo "DOCKER_API_VERSION=${server_api}" >> "$GITHUB_ENV" + echo "Using Docker API version ${server_api} (server min: ${min_api:-unknown})" + - name: Setup Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 @@ -72,9 +91,9 @@ jobs: publish: name: Build and Push Docker Image - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'zeroclaw-labs/zeroclaw' + if: github.repository == 'zeroclaw-labs/zeroclaw' && ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '')) runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 45 + timeout-minutes: 90 permissions: contents: read packages: write @@ -82,6 +101,22 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }} + + - name: Resolve Docker API version + shell: bash + run: | + set -euo pipefail + server_api="$(docker version --format '{{.Server.APIVersion}}')" + min_api="$(docker version --format '{{.Server.MinAPIVersion}}' 2>/dev/null || true)" + if [[ -z "${server_api}" || "${server_api}" == "" ]]; then + echo "::error::Unable to detect Docker server API version." + docker version || true + exit 1 + fi + echo "DOCKER_API_VERSION=${server_api}" >> "$GITHUB_ENV" + echo "Using Docker API version ${server_api} (server min: ${min_api:-unknown})" - name: Setup Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 @@ -99,22 +134,42 @@ jobs: run: | set -euo pipefail IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - SHA_SUFFIX="sha-${GITHUB_SHA::12}" + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then + echo "::error::Docker publish is restricted to v* tag pushes." + exit 1 + fi + RELEASE_TAG="${GITHUB_REF#refs/tags/}" + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + RELEASE_TAG="${{ inputs.release_tag }}" + if [[ -z "${RELEASE_TAG}" ]]; then + echo "::error::workflow_dispatch publish requires inputs.release_tag" + exit 1 + fi + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::release_tag must be vX.Y.Z or vX.Y.Z-suffix (received: ${RELEASE_TAG})" + exit 1 + fi + if ! git rev-parse --verify "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + echo "::error::release tag not found in checkout: ${RELEASE_TAG}" + exit 1 + fi + else + echo "::error::Unsupported event for publish: ${GITHUB_EVENT_NAME}" + exit 1 + fi + RELEASE_SHA="$(git rev-parse HEAD)" + SHA_SUFFIX="sha-${RELEASE_SHA::12}" SHA_TAG="${IMAGE}:${SHA_SUFFIX}" LATEST_SUFFIX="latest" LATEST_TAG="${IMAGE}:${LATEST_SUFFIX}" - if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then - echo "::error::Docker publish is restricted to v* tag pushes." - exit 1 - fi - - RELEASE_TAG="${GITHUB_REF#refs/tags/}" VERSION_TAG="${IMAGE}:${RELEASE_TAG}" TAGS="${VERSION_TAG},${SHA_TAG},${LATEST_TAG}" { echo "tags=${TAGS}" echo "release_tag=${RELEASE_TAG}" + echo "release_sha=${RELEASE_SHA}" echo "sha_tag=${SHA_SUFFIX}" echo "latest_tag=${LATEST_SUFFIX}" } >> "$GITHUB_OUTPUT" @@ -124,6 +179,8 @@ jobs: with: context: . push: true + build-args: | + ZEROCLAW_CARGO_ALL_FEATURES=true tags: ${{ steps.meta.outputs.tags }} platforms: linux/amd64,linux/arm64 cache-from: type=gha @@ -173,7 +230,7 @@ jobs: python3 scripts/ci/ghcr_publish_contract_guard.py \ --repository "${GITHUB_REPOSITORY,,}" \ --release-tag "${{ steps.meta.outputs.release_tag }}" \ - --sha "${GITHUB_SHA}" \ + --sha "${{ steps.meta.outputs.release_sha }}" \ --policy-file .github/release/ghcr-tag-policy.json \ --output-json artifacts/ghcr-publish-contract.json \ --output-md artifacts/ghcr-publish-contract.md \ @@ -328,11 +385,25 @@ jobs: if-no-files-found: ignore retention-days: 21 - - name: Upload Trivy SARIF + - name: Detect Trivy SARIF report + id: trivy-sarif if: always() + shell: bash + run: | + set -euo pipefail + sarif_path="artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif" + if [ -f "${sarif_path}" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::notice::Trivy SARIF report not found at ${sarif_path}; skipping SARIF upload." + fi + + - name: Upload Trivy SARIF + if: always() && steps.trivy-sarif.outputs.exists == 'true' uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 with: - sarif_file: artifacts/trivy-${{ github.ref_name }}.sarif + sarif_file: artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif category: ghcr-trivy - name: Upload Trivy report artifacts @@ -341,9 +412,9 @@ jobs: with: name: ghcr-trivy-report path: | - artifacts/trivy-${{ github.ref_name }}.sarif - artifacts/trivy-${{ github.ref_name }}.txt - artifacts/trivy-${{ github.ref_name }}.json + artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif + artifacts/trivy-${{ steps.meta.outputs.release_tag }}.txt + artifacts/trivy-${{ steps.meta.outputs.release_tag }}.json artifacts/trivy-sha-*.txt artifacts/trivy-sha-*.json artifacts/trivy-latest.txt diff --git a/.github/workflows/pub-prerelease.yml b/.github/workflows/pub-prerelease.yml deleted file mode 100644 index 01c0830e8..000000000 --- a/.github/workflows/pub-prerelease.yml +++ /dev/null @@ -1,259 +0,0 @@ -name: Pub Pre-release - -on: - push: - tags: - - "v*-alpha.*" - - "v*-beta.*" - - "v*-rc.*" - workflow_dispatch: - inputs: - tag: - description: "Existing pre-release tag (e.g. v0.1.8-rc.1)" - required: true - default: "" - type: string - mode: - description: "dry-run validates/builds only; publish creates prerelease" - required: true - default: dry-run - type: choice - options: - - dry-run - - publish - draft: - description: "Create prerelease as draft" - required: true - default: true - type: boolean - -concurrency: - group: prerelease-${{ github.ref || github.run_id }} - cancel-in-progress: false - -permissions: - contents: write - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - CARGO_TERM_COLOR: always - -jobs: - prerelease-guard: - name: Pre-release Guard - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 - outputs: - release_tag: ${{ steps.vars.outputs.release_tag }} - mode: ${{ steps.vars.outputs.mode }} - draft: ${{ steps.vars.outputs.draft }} - ready_to_publish: ${{ steps.extract.outputs.ready_to_publish }} - stage: ${{ steps.extract.outputs.stage }} - transition_outcome: ${{ steps.extract.outputs.transition_outcome }} - latest_stage: ${{ steps.extract.outputs.latest_stage }} - latest_stage_tag: ${{ steps.extract.outputs.latest_stage_tag }} - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - name: Resolve prerelease inputs - id: vars - shell: bash - run: | - set -euo pipefail - if [ "${GITHUB_EVENT_NAME}" = "push" ]; then - release_tag="${GITHUB_REF_NAME}" - mode="publish" - draft="false" - else - release_tag="${{ inputs.tag }}" - mode="${{ inputs.mode }}" - draft="${{ inputs.draft }}" - fi - - { - echo "release_tag=${release_tag}" - echo "mode=${mode}" - echo "draft=${draft}" - } >> "$GITHUB_OUTPUT" - - - name: Validate prerelease stage gate - shell: bash - run: | - set -euo pipefail - mkdir -p artifacts - python3 scripts/ci/prerelease_guard.py \ - --repo-root . \ - --tag "${{ steps.vars.outputs.release_tag }}" \ - --stage-config-file .github/release/prerelease-stage-gates.json \ - --mode "${{ steps.vars.outputs.mode }}" \ - --output-json artifacts/prerelease-guard.json \ - --output-md artifacts/prerelease-guard.md \ - --fail-on-violation - - - name: Extract prerelease outputs - id: extract - shell: bash - run: | - set -euo pipefail - ready_to_publish="$(python3 - <<'PY' - import json - data = json.load(open('artifacts/prerelease-guard.json', encoding='utf-8')) - print(str(bool(data.get('ready_to_publish', False))).lower()) - PY - )" - stage="$(python3 - <<'PY' - import json - data = json.load(open('artifacts/prerelease-guard.json', encoding='utf-8')) - print(data.get('stage', 'unknown')) - PY - )" - transition_outcome="$(python3 - <<'PY' - import json - data = json.load(open('artifacts/prerelease-guard.json', encoding='utf-8')) - transition = data.get('transition') or {} - print(transition.get('outcome', 'unknown')) - PY - )" - latest_stage="$(python3 - <<'PY' - import json - data = json.load(open('artifacts/prerelease-guard.json', encoding='utf-8')) - history = data.get('stage_history') or {} - print(history.get('latest_stage', 'unknown')) - PY - )" - latest_stage_tag="$(python3 - <<'PY' - import json - data = json.load(open('artifacts/prerelease-guard.json', encoding='utf-8')) - history = data.get('stage_history') or {} - print(history.get('latest_tag', 'unknown')) - PY - )" - { - echo "ready_to_publish=${ready_to_publish}" - echo "stage=${stage}" - echo "transition_outcome=${transition_outcome}" - echo "latest_stage=${latest_stage}" - echo "latest_stage_tag=${latest_stage_tag}" - } >> "$GITHUB_OUTPUT" - - - name: Emit prerelease audit event - if: always() - shell: bash - run: | - set -euo pipefail - python3 scripts/ci/emit_audit_event.py \ - --event-type prerelease_guard \ - --input-json artifacts/prerelease-guard.json \ - --output-json artifacts/audit-event-prerelease-guard.json \ - --artifact-name prerelease-guard \ - --retention-days 21 - - - name: Publish prerelease summary - if: always() - shell: bash - run: | - set -euo pipefail - cat artifacts/prerelease-guard.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload prerelease guard artifacts - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: prerelease-guard - path: | - artifacts/prerelease-guard.json - artifacts/prerelease-guard.md - artifacts/audit-event-prerelease-guard.json - if-no-files-found: error - retention-days: 21 - - build-prerelease: - name: Build Pre-release Artifact - needs: [prerelease-guard] - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 45 - steps: - - name: Checkout tag - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.prerelease-guard.outputs.release_tag }} - - - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: 1.92.0 - - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 - with: - prefix-key: prerelease-${{ needs.prerelease-guard.outputs.release_tag }} - cache-targets: true - - - name: Build release-fast binary - shell: bash - run: | - set -euo pipefail - cargo build --profile release-fast --locked --target x86_64-unknown-linux-gnu - - - name: Package prerelease artifact - shell: bash - run: | - set -euo pipefail - mkdir -p artifacts - cp target/x86_64-unknown-linux-gnu/release-fast/zeroclaw artifacts/zeroclaw - tar czf artifacts/zeroclaw-x86_64-unknown-linux-gnu.tar.gz -C artifacts zeroclaw - rm artifacts/zeroclaw - - - name: Generate manifest + checksums - shell: bash - run: | - set -euo pipefail - python3 scripts/ci/release_manifest.py \ - --artifacts-dir artifacts \ - --release-tag "${{ needs.prerelease-guard.outputs.release_tag }}" \ - --output-json artifacts/prerelease-manifest.json \ - --output-md artifacts/prerelease-manifest.md \ - --checksums-path artifacts/SHA256SUMS \ - --fail-empty - - - name: Publish prerelease build summary - shell: bash - run: | - set -euo pipefail - cat artifacts/prerelease-manifest.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload prerelease build artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: prerelease-artifacts - path: artifacts/* - if-no-files-found: error - retention-days: 14 - - publish-prerelease: - name: Publish GitHub Pre-release - needs: [prerelease-guard, build-prerelease] - if: needs.prerelease-guard.outputs.ready_to_publish == 'true' - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 15 - steps: - - name: Download prerelease artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: prerelease-artifacts - path: artifacts - - - name: Create or update GitHub pre-release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 - with: - tag_name: ${{ needs.prerelease-guard.outputs.release_tag }} - prerelease: true - draft: ${{ needs.prerelease-guard.outputs.draft == 'true' }} - generate_release_notes: true - files: | - artifacts/**/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pub-release.yml b/.github/workflows/pub-release.yml index db0ec27b6..eeda65d39 100644 --- a/.github/workflows/pub-release.yml +++ b/.github/workflows/pub-release.yml @@ -25,9 +25,6 @@ on: 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 }} @@ -47,6 +44,7 @@ env: jobs: prepare: name: Prepare Release Context + if: github.event_name != 'push' || !contains(github.ref_name, '-') runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] outputs: release_ref: ${{ steps.vars.outputs.release_ref }} @@ -106,7 +104,35 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install gh CLI + shell: bash + run: | + set -euo pipefail + if command -v gh &>/dev/null; then + echo "gh already available: $(gh --version | head -1)" + exit 0 + fi + echo "Installing gh CLI..." + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + for i in {1..60}; do + if sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1 \ + || sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ + || sudo fuser /var/lib/dpkg/lock >/dev/null 2>&1; then + echo "apt/dpkg locked; waiting ($i/60)..." + sleep 5 + else + break + fi + done + sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 update -qq + sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y gh + env: + GH_TOKEN: ${{ github.token }} - name: Validate release trigger and authorization guard shell: bash @@ -121,12 +147,14 @@ jobs: --release-ref "${{ steps.vars.outputs.release_ref }}" \ --release-tag "${{ steps.vars.outputs.release_tag }}" \ --publish-release "${{ steps.vars.outputs.publish_release }}" \ - --authorized-actors "${{ vars.RELEASE_AUTHORIZED_ACTORS || 'willsarg,theonlyhennygod,chumyin' }}" \ - --authorized-tagger-emails "${{ vars.RELEASE_AUTHORIZED_TAGGER_EMAILS || '' }}" \ + --authorized-actors "${{ vars.RELEASE_AUTHORIZED_ACTORS || 'theonlyhennygod,JordanTheJet' }},github-actions[bot]" \ + --authorized-tagger-emails "${{ vars.RELEASE_AUTHORIZED_TAGGER_EMAILS || '' }},41898282+github-actions[bot]@users.noreply.github.com" \ --require-annotated-tag true \ --output-json artifacts/release-trigger-guard.json \ --output-md artifacts/release-trigger-guard.md \ --fail-on-violation + env: + GH_TOKEN: ${{ github.token }} - name: Emit release trigger audit event if: always() @@ -164,6 +192,10 @@ jobs: needs: [prepare] runs-on: ${{ matrix.os }} timeout-minutes: 40 + env: + CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.target }}/cargo + RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.target }}/rustup + CARGO_TARGET_DIR: ${{ github.workspace }}/target strategy: fail-fast: false matrix: @@ -233,21 +265,21 @@ jobs: linker_env: "" linker: "" use_cross: true - - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + - os: macos-15-intel target: x86_64-apple-darwin artifact: zeroclaw archive_ext: tar.gz cross_compiler: "" linker_env: "" linker: "" - - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + - os: macos-14 target: aarch64-apple-darwin artifact: zeroclaw archive_ext: tar.gz cross_compiler: "" linker_env: "" linker: "" - - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + - os: windows-latest target: x86_64-pc-windows-msvc artifact: zeroclaw.exe archive_ext: zip @@ -260,24 +292,52 @@ jobs: with: ref: ${{ needs.prepare.outputs.release_ref }} + - 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 targets: ${{ matrix.target }} - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 if: runner.os != 'Windows' - name: Install cross for cross-built targets if: matrix.use_cross + shell: bash run: | - cargo install cross --git https://github.com/cross-rs/cross + set -euo pipefail + echo "${CARGO_HOME:-$HOME/.cargo}/bin" >> "$GITHUB_PATH" + cargo install cross --locked --version 0.2.5 + command -v cross + cross --version - 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 }}" + set -euo pipefail + for i in {1..60}; do + if sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1 \ + || sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ + || sudo fuser /var/lib/dpkg/lock >/dev/null 2>&1; then + echo "apt/dpkg locked; waiting ($i/60)..." + sleep 5 + else + break + fi + done + sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 update -qq + sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y "${{ matrix.cross_compiler }}" + # Install matching libc dev headers for cross targets + # (required by ring/aws-lc-sys C compilation) + case "${{ matrix.target }}" in + armv7-unknown-linux-gnueabihf) + sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y libc6-dev-armhf-cross ;; + aarch64-unknown-linux-gnu) + sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y libc6-dev-arm64-cross ;; + esac - name: Setup Android NDK if: matrix.android_ndk @@ -290,8 +350,18 @@ jobs: NDK_ROOT="${RUNNER_TEMP}/android-ndk" NDK_HOME="${NDK_ROOT}/android-ndk-${NDK_VERSION}" - sudo apt-get update -qq - sudo apt-get install -y unzip + for i in {1..60}; do + if sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1 \ + || sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ + || sudo fuser /var/lib/dpkg/lock >/dev/null 2>&1; then + echo "apt/dpkg locked; waiting ($i/60)..." + sleep 5 + else + break + fi + done + sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 update -qq + sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y unzip mkdir -p "${NDK_ROOT}" curl -fsSL "${NDK_URL}" -o "${RUNNER_TEMP}/${NDK_ZIP}" @@ -362,6 +432,10 @@ jobs: - name: Check binary size (Unix) if: runner.os != 'Windows' + env: + BINARY_SIZE_HARD_LIMIT_MB: 28 + BINARY_SIZE_ADVISORY_MB: 20 + BINARY_SIZE_TARGET_MB: 5 run: bash scripts/ci/check_binary_size.sh "target/${{ matrix.target }}/release-fast/${{ matrix.artifact }}" "${{ matrix.target }}" - name: Package (Unix) diff --git a/.github/workflows/sec-audit.yml b/.github/workflows/sec-audit.yml index 9c1b031e1..3ba0d050f 100644 --- a/.github/workflows/sec-audit.yml +++ b/.github/workflows/sec-audit.yml @@ -15,6 +15,9 @@ on: - ".github/security/unsafe-audit-governance.json" - "scripts/ci/install_gitleaks.sh" - "scripts/ci/install_syft.sh" + - "scripts/ci/ensure_c_toolchain.sh" + - "scripts/ci/ensure_cargo_component.sh" + - "scripts/ci/self_heal_rust_toolchain.sh" - "scripts/ci/deny_policy_guard.py" - "scripts/ci/secrets_governance_guard.py" - "scripts/ci/unsafe_debt_audit.py" @@ -22,29 +25,12 @@ on: - "scripts/ci/config/unsafe_debt_policy.toml" - "scripts/ci/emit_audit_event.py" - "scripts/ci/security_regression_tests.sh" + - "scripts/ci/ensure_cc.sh" - ".github/workflows/sec-audit.yml" pull_request: branches: [dev, main] - paths: - - "Cargo.toml" - - "Cargo.lock" - - "src/**" - - "crates/**" - - "deny.toml" - - ".gitleaks.toml" - - ".github/security/gitleaks-allowlist-governance.json" - - ".github/security/deny-ignore-governance.json" - - ".github/security/unsafe-audit-governance.json" - - "scripts/ci/install_gitleaks.sh" - - "scripts/ci/install_syft.sh" - - "scripts/ci/deny_policy_guard.py" - - "scripts/ci/secrets_governance_guard.py" - - "scripts/ci/unsafe_debt_audit.py" - - "scripts/ci/unsafe_policy_guard.py" - - "scripts/ci/config/unsafe_debt_policy.toml" - - "scripts/ci/emit_audit_event.py" - - "scripts/ci/security_regression_tests.sh" - - ".github/workflows/sec-audit.yml" + # Do not gate pull_request by paths: main branch protection requires + # "Security Required Gate" to always report a status on PRs. merge_group: branches: [dev, main] schedule: @@ -87,13 +73,33 @@ jobs: audit: name: Security Audit runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 + timeout-minutes: 45 + 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 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Self-heal Rust toolchain cache + shell: bash + run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0 + + - name: Ensure C toolchain + shell: bash + run: bash ./scripts/ci/ensure_c_toolchain.sh + - 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 + env: + ENSURE_CARGO_COMPONENT_STRICT: "true" + run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0 - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 with: @@ -103,9 +109,26 @@ jobs: name: License & Supply Chain runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] timeout-minutes: 20 + 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 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Ensure C toolchain + shell: bash + run: bash ./scripts/ci/ensure_c_toolchain.sh + + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.92.0 + - name: Ensure cargo component + shell: bash + env: + ENSURE_CARGO_COMPONENT_STRICT: "true" + run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0 + - name: Enforce deny policy hygiene shell: bash run: | @@ -118,9 +141,46 @@ jobs: --output-md artifacts/deny-policy-guard.md \ --fail-on-violation - - uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2 - with: - command: check advisories licenses sources + - name: Install cargo-deny + shell: bash + run: | + set -euo pipefail + version="0.19.0" + arch="$(uname -m)" + case "${arch}" in + x86_64|amd64) + target="x86_64-unknown-linux-musl" + expected_sha256="0e8c2aa59128612c90d9e09c02204e912f29a5b8d9a64671b94608cbe09e064f" + ;; + aarch64|arm64) + target="aarch64-unknown-linux-musl" + expected_sha256="2b3567a60b7491c159d1cef8b7d8479d1ad2a31e29ef49462634ad4552fcc77d" + ;; + *) + echo "Unsupported runner architecture for cargo-deny: ${arch}" >&2 + exit 1 + ;; + esac + install_dir="${RUNNER_TEMP}/cargo-deny-${version}" + archive="${RUNNER_TEMP}/cargo-deny-${version}-${target}.tar.gz" + mkdir -p "${install_dir}" + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + --output "${archive}" \ + "https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-${target}.tar.gz" + actual_sha256="$(sha256sum "${archive}" | awk '{print $1}')" + if [ "${actual_sha256}" != "${expected_sha256}" ]; then + echo "Checksum mismatch for cargo-deny ${version} (${target})" >&2 + echo "Expected: ${expected_sha256}" >&2 + echo "Actual: ${actual_sha256}" >&2 + exit 1 + fi + tar -xzf "${archive}" -C "${install_dir}" --strip-components=1 + echo "${install_dir}" >> "${GITHUB_PATH}" + "${install_dir}/cargo-deny" --version + + - name: Run cargo-deny checks + shell: bash + run: cargo-deny check advisories licenses sources - name: Emit deny audit event if: always() @@ -158,21 +218,40 @@ jobs: name: Security Regression Tests runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] timeout-minutes: 30 + 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 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 + - name: Ensure C toolchain for Rust builds + run: ./scripts/ci/ensure_cc.sh + - name: Ensure cargo component + shell: bash + env: + ENSURE_CARGO_COMPONENT_STRICT: "true" + run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 with: prefix-key: sec-audit-security-regressions + cache-bin: false - name: Run security regression suite shell: bash run: ./scripts/ci/security_regression_tests.sh secrets: name: Secrets Governance (Gitleaks) - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] timeout-minutes: 20 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -367,7 +446,7 @@ jobs: sbom: name: SBOM Snapshot - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] timeout-minutes: 20 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -432,11 +511,17 @@ jobs: unsafe-debt: name: Unsafe Debt Audit - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] timeout-minutes: 20 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Setup Python 3.11 + shell: bash + run: | + set -euo pipefail + python3 --version + - name: Enforce unsafe policy governance shell: bash run: | @@ -571,7 +656,7 @@ jobs: name: Security Required Gate if: always() && (github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'merge_group') needs: [audit, deny, security-regressions, secrets, sbom, unsafe-debt] - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] steps: - name: Enforce security gate shell: bash diff --git a/.github/workflows/sec-codeql.yml b/.github/workflows/sec-codeql.yml index a2bc484c4..01bec0567 100644 --- a/.github/workflows/sec-codeql.yml +++ b/.github/workflows/sec-codeql.yml @@ -8,7 +8,11 @@ on: - "Cargo.lock" - "src/**" - "crates/**" + - "scripts/ci/ensure_c_toolchain.sh" + - "scripts/ci/ensure_cargo_component.sh" - ".github/codeql/**" + - "scripts/ci/self_heal_rust_toolchain.sh" + - "scripts/ci/ensure_cc.sh" - ".github/workflows/sec-codeql.yml" pull_request: branches: [dev, main] @@ -17,7 +21,11 @@ on: - "Cargo.lock" - "src/**" - "crates/**" + - "scripts/ci/ensure_c_toolchain.sh" + - "scripts/ci/ensure_cargo_component.sh" - ".github/codeql/**" + - "scripts/ci/self_heal_rust_toolchain.sh" + - "scripts/ci/ensure_cc.sh" - ".github/workflows/sec-codeql.yml" merge_group: branches: [dev, main] @@ -41,16 +49,46 @@ env: jobs: + select-runner: + name: Select CodeQL Runner Lane + runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40] + outputs: + labels: ${{ steps.lane.outputs.labels }} + lane: ${{ steps.lane.outputs.lane }} + steps: + - name: Resolve branch lane + id: lane + shell: bash + run: | + set -euo pipefail + branch="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" + if [[ "$branch" == release/* ]]; then + echo 'labels=["self-hosted","Linux","X64","hetzner","codeql"]' >> "$GITHUB_OUTPUT" + echo 'lane=release' >> "$GITHUB_OUTPUT" + else + echo 'labels=["self-hosted","Linux","X64","hetzner","codeql","codeql-general"]' >> "$GITHUB_OUTPUT" + echo 'lane=general' >> "$GITHUB_OUTPUT" + fi + codeql: name: CodeQL Analysis - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 30 + needs: [select-runner] + runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }} + 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 steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 + - name: Ensure C toolchain + shell: bash + run: bash ./scripts/ci/ensure_c_toolchain.sh + - name: Initialize CodeQL uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 with: @@ -59,10 +97,26 @@ jobs: queries: security-and-quality - name: Set up Rust + shell: bash + run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0 + + - name: Install Rust toolchain 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 + + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + with: + prefix-key: sec-codeql-build + cache-targets: true + cache-bin: false + - name: Build run: cargo build --workspace --all-targets --locked @@ -70,3 +124,14 @@ jobs: uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 with: category: "/language:rust" + + - name: Summarize lane + if: always() + shell: bash + run: | + { + echo "### CodeQL Runner Lane" + echo "- Branch: \`${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}\`" + echo "- Lane: \`${{ needs.select-runner.outputs.lane }}\`" + echo "- Labels: \`${{ needs.select-runner.outputs.labels }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sec-vorpal-reviewdog.yml b/.github/workflows/sec-vorpal-reviewdog.yml deleted file mode 100644 index 618755038..000000000 --- a/.github/workflows/sec-vorpal-reviewdog.yml +++ /dev/null @@ -1,191 +0,0 @@ -name: Sec Vorpal Reviewdog - -on: - workflow_dispatch: - inputs: - scan_scope: - description: "File selection mode when source_path is empty" - required: true - type: choice - default: changed - options: - - changed - - all - base_ref: - description: "Base branch/ref for changed diff mode" - required: true - type: string - default: main - source_path: - description: "Optional comma-separated file paths to scan (overrides scan_scope)" - required: false - type: string - include_tests: - description: "Include test/fixture files in scan selection" - required: true - type: choice - default: "false" - options: - - "false" - - "true" - folders_to_ignore: - description: "Optional comma-separated path prefixes to ignore" - required: false - type: string - default: target,node_modules,web/dist,.venv,venv - reporter: - description: "Reviewdog reporter mode" - required: true - type: choice - default: github-pr-check - options: - - github-pr-check - - github-pr-review - filter_mode: - description: "Reviewdog filter mode" - required: true - type: choice - default: file - options: - - added - - diff_context - - file - - nofilter - level: - description: "Reviewdog severity level" - required: true - type: choice - default: error - options: - - info - - warning - - error - fail_on_error: - description: "Fail workflow when Vorpal reports findings" - required: true - type: choice - default: "false" - options: - - "false" - - "true" - reviewdog_flags: - description: "Optional extra reviewdog flags" - required: false - type: string - -concurrency: - group: sec-vorpal-reviewdog-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - checks: write - pull-requests: write - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - vorpal: - name: Vorpal Reviewdog Scan - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Resolve source paths - id: sources - shell: bash - env: - INPUT_SOURCE_PATH: ${{ inputs.source_path }} - INPUT_SCAN_SCOPE: ${{ inputs.scan_scope }} - INPUT_BASE_REF: ${{ inputs.base_ref }} - INPUT_INCLUDE_TESTS: ${{ inputs.include_tests }} - run: | - set -euo pipefail - - strip_space() { - local value="$1" - value="${value//$'\n'/}" - value="${value//$'\r'/}" - value="${value// /}" - echo "$value" - } - - source_override="$(strip_space "${INPUT_SOURCE_PATH}")" - if [ -n "${source_override}" ]; then - normalized="$(echo "${INPUT_SOURCE_PATH}" | tr '\n' ',' | sed -E 's/[[:space:]]+//g; s/,+/,/g; s/^,|,$//g')" - if [ -n "${normalized}" ]; then - { - echo "scan=true" - echo "source_path=${normalized}" - echo "selection=manual" - } >> "${GITHUB_OUTPUT}" - exit 0 - fi - fi - - include_ext='\.(py|js|jsx|ts|tsx)$' - exclude_paths='^(target/|node_modules/|web/node_modules/|dist/|web/dist/|\.venv/|venv/)' - exclude_tests='(^|/)(test|tests|__tests__|fixtures|mocks|examples)/|(^|/)test_helpers/|(_test\.py$)|(^|/)test_.*\.py$|(\.spec\.(ts|tsx|js|jsx)$)|(\.test\.(ts|tsx|js|jsx)$)' - - if [ "${INPUT_SCAN_SCOPE}" = "all" ]; then - candidate_files="$(git ls-files)" - else - base_ref="${INPUT_BASE_REF#refs/heads/}" - base_ref="${base_ref#origin/}" - if git fetch --no-tags --depth=1 origin "${base_ref}" >/dev/null 2>&1; then - if merge_base="$(git merge-base HEAD "origin/${base_ref}" 2>/dev/null)"; then - candidate_files="$(git diff --name-only --diff-filter=ACMR "${merge_base}"...HEAD)" - else - echo "Unable to resolve merge-base for origin/${base_ref}; falling back to tracked files." - candidate_files="$(git ls-files)" - fi - else - echo "Unable to fetch origin/${base_ref}; falling back to tracked files." - candidate_files="$(git ls-files)" - fi - fi - - source_files="$(printf '%s\n' "${candidate_files}" | sed '/^$/d' | grep -E "${include_ext}" | grep -Ev "${exclude_paths}" || true)" - if [ "${INPUT_INCLUDE_TESTS}" != "true" ] && [ -n "${source_files}" ]; then - source_files="$(printf '%s\n' "${source_files}" | grep -Ev "${exclude_tests}" || true)" - fi - if [ -z "${source_files}" ]; then - { - echo "scan=false" - echo "source_path=" - echo "selection=none" - } >> "${GITHUB_OUTPUT}" - exit 0 - fi - - source_path="$(printf '%s\n' "${source_files}" | paste -sd, -)" - { - echo "scan=true" - echo "source_path=${source_path}" - echo "selection=auto-${INPUT_SCAN_SCOPE}" - } >> "${GITHUB_OUTPUT}" - - - name: No supported files to scan - if: steps.sources.outputs.scan != 'true' - shell: bash - run: | - echo "No supported files selected for Vorpal scan (extensions: .py .js .jsx .ts .tsx)." - - - name: Run Vorpal with reviewdog - if: steps.sources.outputs.scan == 'true' - uses: Checkmarx/vorpal-reviewdog-github-action@8cc292f337a2f1dea581b4f4bd73852e7becb50d # v1.2.0 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - source_path: ${{ steps.sources.outputs.source_path }} - folders_to_ignore: ${{ inputs.folders_to_ignore }} - reporter: ${{ inputs.reporter }} - filter_mode: ${{ inputs.filter_mode }} - level: ${{ inputs.level }} - fail_on_error: ${{ inputs.fail_on_error }} - reviewdog_flags: ${{ inputs.reviewdog_flags }} diff --git a/.github/workflows/sync-contributors.yml b/.github/workflows/sync-contributors.yml deleted file mode 100644 index 3dc0483fe..000000000 --- a/.github/workflows/sync-contributors.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Sync Contributors - -on: - workflow_dispatch: - schedule: - # Run every Sunday at 00:00 UTC - - cron: '0 0 * * 0' - -concurrency: - group: update-notice-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: write - pull-requests: write - -jobs: - update-notice: - name: Update NOTICE with new contributors - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Fetch contributors - id: contributors - env: - GH_TOKEN: ${{ github.token }} - run: | - # Fetch all contributors (excluding bots) - gh api \ - --paginate \ - "repos/${{ github.repository }}/contributors" \ - --jq '.[] | select(.type != "Bot") | .login' > /tmp/contributors_raw.txt - - # Sort alphabetically and filter - sort -f < /tmp/contributors_raw.txt > contributors.txt - - # Count contributors - count=$(wc -l < contributors.txt | tr -d ' ') - echo "count=$count" >> "$GITHUB_OUTPUT" - - - name: Generate new NOTICE file - run: | - cat > NOTICE << 'EOF' - ZeroClaw - Copyright 2025 ZeroClaw Labs - - This product includes software developed at ZeroClaw Labs (https://github.com/zeroclaw-labs). - - Contributors - ============ - - The following individuals have contributed to ZeroClaw: - - EOF - - # Append contributors in alphabetical order - sed 's/^/- /' contributors.txt >> NOTICE - - # Add third-party dependencies section - cat >> NOTICE << 'EOF' - - - Third-Party Dependencies - ========================= - - This project uses the following third-party libraries and components, - each licensed under their respective terms: - - See Cargo.lock for a complete list of dependencies and their licenses. - EOF - - - name: Check if NOTICE changed - id: check_diff - run: | - if git diff --quiet NOTICE; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Create Pull Request - if: steps.check_diff.outputs.changed == 'true' - env: - GH_TOKEN: ${{ github.token }} - COUNT: ${{ steps.contributors.outputs.count }} - run: | - branch_name="auto/update-notice-$(date +%Y%m%d)" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git checkout -b "$branch_name" - git add NOTICE - git commit -m "chore(notice): update contributor list" - git push origin "$branch_name" - - gh pr create \ - --title "chore(notice): update contributor list" \ - --body "Auto-generated update to NOTICE file with $COUNT contributors." \ - --label "chore" \ - --label "docs" \ - --draft || true - - - name: Summary - run: | - echo "## NOTICE Update Results" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - if [ "${{ steps.check_diff.outputs.changed }}" = "true" ]; then - echo "✅ PR created to update NOTICE" >> "$GITHUB_STEP_SUMMARY" - else - echo "✓ NOTICE file is up to date" >> "$GITHUB_STEP_SUMMARY" - fi - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "**Contributors:** ${{ steps.contributors.outputs.count }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/test-benchmarks.yml b/.github/workflows/test-benchmarks.yml deleted file mode 100644 index 9654d9cdb..000000000 --- a/.github/workflows/test-benchmarks.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Test Benchmarks - -on: - schedule: - - cron: "0 3 * * 1" # Weekly Monday 3am UTC - workflow_dispatch: - -concurrency: - group: bench-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - CARGO_TERM_COLOR: always - -jobs: - benchmarks: - name: Criterion Benchmarks - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 30 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: 1.92.0 - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 - - - name: Run benchmarks - run: cargo bench --locked 2>&1 | tee benchmark_output.txt - - - name: Upload benchmark results - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: benchmark-results - path: | - target/criterion/ - benchmark_output.txt - retention-days: 7 - - - name: Post benchmark summary on PR - if: github.event_name == 'pull_request' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const script = require('./.github/workflows/scripts/test_benchmarks_pr_comment.js'); - await script({ github, context, core }); diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 97dabf726..595e97e1f 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -3,10 +3,19 @@ name: Test E2E on: push: branches: [dev, main] + paths: + - "Cargo.toml" + - "Cargo.lock" + - "src/**" + - "crates/**" + - "tests/**" + - "scripts/**" + - "scripts/ci/ensure_cc.sh" + - ".github/workflows/test-e2e.yml" workflow_dispatch: concurrency: - group: e2e-${{ github.event.pull_request.number || github.sha }} + group: test-e2e-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.sha }} cancel-in-progress: true permissions: @@ -28,6 +37,30 @@ jobs: - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: toolchain: 1.92.0 - - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 + - name: Ensure cargo component + shell: bash + env: + ENSURE_CARGO_COMPONENT_STRICT: "true" + run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0 + - name: Ensure C toolchain for Rust builds + run: ./scripts/ci/ensure_cc.sh + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3 + - name: Runner preflight (compiler + disk) + shell: bash + run: | + set -euo pipefail + echo "Runner: ${RUNNER_NAME:-unknown} (${RUNNER_OS:-unknown}/${RUNNER_ARCH:-unknown})" + if ! command -v cc >/dev/null 2>&1; then + echo "::error::Missing 'cc' compiler on runner. Install build-essential (Debian/Ubuntu) or equivalent." + exit 1 + fi + cc --version | head -n1 + free_kb="$(df -Pk . | awk 'NR==2 {print $4}')" + min_kb=$((10 * 1024 * 1024)) + if [ "${free_kb}" -lt "${min_kb}" ]; then + echo "::error::Insufficient disk space on runner (<10 GiB free)." + df -h . + exit 1 + fi - name: Run integration / E2E tests run: cargo test --test agent_e2e --locked --verbose diff --git a/.github/workflows/test-fuzz.yml b/.github/workflows/test-fuzz.yml deleted file mode 100644 index 809672a36..000000000 --- a/.github/workflows/test-fuzz.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Test Fuzz - -on: - schedule: - - cron: "0 2 * * 0" # Weekly Sunday 2am UTC - workflow_dispatch: - inputs: - fuzz_seconds: - description: "Seconds to run each fuzz target" - required: false - default: "300" - -concurrency: - group: fuzz-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - issues: write - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - CARGO_TERM_COLOR: always - -jobs: - fuzz: - name: Fuzz (${{ matrix.target }}) - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - target: - - fuzz_config_parse - - fuzz_tool_params - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - with: - toolchain: nightly - components: llvm-tools-preview - - - name: Install cargo-fuzz - run: cargo install cargo-fuzz --locked - - - name: Run fuzz target - run: | - SECONDS="${{ github.event.inputs.fuzz_seconds || '300' }}" - echo "Fuzzing ${{ matrix.target }} for ${SECONDS}s" - cargo +nightly fuzz run ${{ matrix.target }} -- \ - -max_total_time="${SECONDS}" \ - -max_len=4096 - continue-on-error: true - id: fuzz - - - name: Upload crash artifacts - if: failure() || steps.fuzz.outcome == 'failure' - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: fuzz-crashes-${{ matrix.target }} - path: fuzz/artifacts/${{ matrix.target }}/ - retention-days: 30 - if-no-files-found: ignore - - - name: Report fuzz results - run: | - echo "### Fuzz: ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY" - if [ "${{ steps.fuzz.outcome }}" = "failure" ]; then - echo "- :x: Crashes found — see artifacts" >> "$GITHUB_STEP_SUMMARY" - else - echo "- :white_check_mark: No crashes found" >> "$GITHUB_STEP_SUMMARY" - fi diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml deleted file mode 100644 index da9d7f3f5..000000000 --- a/.github/workflows/workflow-sanity.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: Workflow Sanity - -on: - pull_request: - paths: - - ".github/workflows/**" - - ".github/*.yml" - - ".github/*.yaml" - push: - paths: - - ".github/workflows/**" - - ".github/*.yml" - - ".github/*.yaml" - -concurrency: - group: workflow-sanity-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read - -env: - GIT_CONFIG_COUNT: "1" - GIT_CONFIG_KEY_0: core.hooksPath - GIT_CONFIG_VALUE_0: /dev/null - - -jobs: - no-tabs: - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 10 - steps: - - name: Normalize git global hooks config - shell: bash - run: | - set -euo pipefail - git config --global --unset-all core.hooksPath || true - - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Fail on tabs in workflow files - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from __future__ import annotations - - import pathlib - import sys - - root = pathlib.Path(".github/workflows") - bad: list[str] = [] - for path in sorted(root.rglob("*.yml")): - if b"\t" in path.read_bytes(): - bad.append(str(path)) - for path in sorted(root.rglob("*.yaml")): - if b"\t" in path.read_bytes(): - bad.append(str(path)) - - if bad: - print("Tabs found in workflow file(s):") - for path in bad: - print(f"- {path}") - sys.exit(1) - PY - - actionlint: - runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner] - timeout-minutes: 10 - steps: - - name: Normalize git global hooks config - shell: bash - run: | - set -euo pipefail - git config --global --unset-all core.hooksPath || true - - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install actionlint binary - shell: bash - run: | - set -euo pipefail - version="1.7.11" - arch="$(uname -m)" - case "$arch" in - x86_64|amd64) archive="actionlint_${version}_linux_amd64.tar.gz" ;; - aarch64|arm64) archive="actionlint_${version}_linux_arm64.tar.gz" ;; - *) - echo "::error::Unsupported architecture: ${arch}" - exit 1 - ;; - esac - - curl -fsSL \ - -o "$RUNNER_TEMP/actionlint.tgz" \ - "https://github.com/rhysd/actionlint/releases/download/v${version}/${archive}" - tar -xzf "$RUNNER_TEMP/actionlint.tgz" -C "$RUNNER_TEMP" actionlint - chmod +x "$RUNNER_TEMP/actionlint" - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - "$RUNNER_TEMP/actionlint" -version - - - name: Lint GitHub workflows - shell: bash - run: actionlint -color