diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c546582e078d..c76e3053a12c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,8 +50,6 @@ on: - "docs/**" pull_request: types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - paths-ignore: - - "CHANGELOG.md" permissions: contents: read @@ -2777,6 +2775,97 @@ jobs: ;; esac + ci-gate: + permissions: + contents: read + name: openclaw/ci-gate + needs: + - runner-admission + - preflight + - security-fast + - pnpm-store-warmup + - build-artifacts + - native-i18n + - checks-ui + - control-ui-i18n + - checks-fast-core + - qa-smoke-ci-profile + - checks-fast-plugin-contracts-shard + - checks-fast-channel-contracts-shard + - checks-node-compat + - checks-node-core-test-nondist-shard + - check-shard + - check-additional-shard + - check-docs + - skills-python + - checks-windows + - macos-node + - macos-swift + - ios-build + - android + if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Verify selected CI lanes + shell: bash + env: + REQUIRED_RESULTS: | + runner-admission=${{ needs.runner-admission.result }} + preflight=${{ needs.preflight.result }} + security-fast=${{ needs.security-fast.result }} + SELECTED_RESULTS: | + pnpm-store-warmup=${{ needs.pnpm-store-warmup.result }} + build-artifacts=${{ needs.build-artifacts.result }} + native-i18n=${{ needs.native-i18n.result }} + checks-ui=${{ needs.checks-ui.result }} + control-ui-i18n=${{ needs.control-ui-i18n.result }} + checks-fast-core=${{ needs.checks-fast-core.result }} + qa-smoke-ci-profile=${{ needs.qa-smoke-ci-profile.result }} + checks-fast-plugin-contracts-shard=${{ needs.checks-fast-plugin-contracts-shard.result }} + checks-fast-channel-contracts-shard=${{ needs.checks-fast-channel-contracts-shard.result }} + checks-node-compat=${{ needs.checks-node-compat.result }} + checks-node-core-test-nondist-shard=${{ needs.checks-node-core-test-nondist-shard.result }} + check-shard=${{ needs.check-shard.result }} + check-additional-shard=${{ needs.check-additional-shard.result }} + check-docs=${{ needs.check-docs.result }} + skills-python=${{ needs.skills-python.result }} + checks-windows=${{ needs.checks-windows.result }} + macos-node=${{ needs.macos-node.result }} + macos-swift=${{ needs.macos-swift.result }} + ios-build=${{ needs.ios-build.result }} + android=${{ needs.android.result }} + run: | + set -euo pipefail + + failures=0 + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + name="${entry%%=*}" + result="${entry#*=}" + echo "${name}: ${result}" + if [[ "$result" != "success" ]]; then + echo "::error title=Required CI job did not succeed::${name} finished with ${result}" + failures=1 + fi + done <<< "$REQUIRED_RESULTS" + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + name="${entry%%=*}" + result="${entry#*=}" + echo "${name}: ${result}" + case "$result" in + success | skipped) ;; + *) + echo "::error title=Selected CI job did not succeed::${name} finished with ${result}" + failures=1 + ;; + esac + done <<< "$SELECTED_RESULTS" + + exit "$failures" + ci-timings-summary: permissions: actions: read diff --git a/docs/ci.md b/docs/ci.md index 8f28cf68dc28..baffe9bc9695 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -9,8 +9,8 @@ read_when: --- OpenClaw CI runs on pushes to `main` (Markdown and `docs/**` paths are ignored -at the trigger), on non-draft pull requests (CHANGELOG-only diffs are ignored), -and on manual dispatch. Canonical `main` pushes first pass through a 90-second +at the trigger), on every non-draft pull request, and on manual dispatch. +Canonical `main` pushes first pass through a 90-second hosted-runner admission window; the `CI` concurrency group cancels that waiting run when a newer commit lands, so sequential merges do not each register a full Blacksmith matrix. Pull requests and manual dispatches skip the wait. The @@ -49,6 +49,7 @@ dispatch. | `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes | | `ios-build` | Xcode project generation plus the iOS app simulator build | iOS app, shared app kit, or Swabble changes | | `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes | +| `openclaw/ci-gate` | Final aggregate: requires admission, preflight, and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run | | `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | | `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch | @@ -61,6 +62,14 @@ Standalone Periphery workflows enforce zero dead-code findings for the iOS and m 3. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs. 4. `build-artifacts` and the advisory `control-ui-i18n` check overlap with the fast Linux lanes. Generated locale drift stays visible while the standalone refresh workflow repairs it in the background. 5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`. +6. `openclaw/ci-gate` waits for every selected lane. Admission, preflight, and security must succeed; downstream jobs may skip only when the manifest did not select them. A failed or canceled selected lane fails the aggregate. + +The merge coordinator may reuse an authenticated successful `openclaw/ci-gate` +for the same pull-request head for up to 24 hours. This avoids rewriting a +contributor branch after unrelated `main` changes. The reusable result does not +replace the separate strict, App-owned test-merge check against current `main`. +A later pending or failed rerun does not erase an earlier successful result for +that unchanged head during the freshness window. GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs. The plugin-list startup-memory guard keeps a 350 MiB ceiling on self-hosted Blacksmith Linux and allows 425 MiB on GitHub-hosted Linux, whose RSS baseline is higher for the same built CLI. diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 3c61c11a626c..61b523c97281 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -53,6 +53,20 @@ function readCiWorkflow() { return parse(readFileSync(".github/workflows/ci.yml", "utf8")); } +function runCiGateFixture(requiredResults: string, selectedResults: string) { + const gateStep = readCiWorkflow().jobs["ci-gate"].steps.find( + (step: WorkflowStep) => step.name === "Verify selected CI lanes", + ); + return spawnSync("bash", ["-c", gateStep.run], { + encoding: "utf8", + env: { + ...process.env, + REQUIRED_RESULTS: requiredResults, + SELECTED_RESULTS: selectedResults, + }, + }); +} + function runCiManifestFixture(options: { bundledPlanner: boolean; changedPlannerImportFails?: boolean; @@ -2677,6 +2691,91 @@ describe("ci workflow guards", () => { }); }); + it("emits one final CI gate after every selected lane", () => { + const workflow = readCiWorkflow(); + const gate = workflow.jobs["ci-gate"]; + const requiredJobs = ["runner-admission", "preflight", "security-fast"]; + const selectedJobs = [ + "pnpm-store-warmup", + "build-artifacts", + "native-i18n", + "checks-ui", + "control-ui-i18n", + "checks-fast-core", + "qa-smoke-ci-profile", + "checks-fast-plugin-contracts-shard", + "checks-fast-channel-contracts-shard", + "checks-node-compat", + "checks-node-core-test-nondist-shard", + "check-shard", + "check-additional-shard", + "check-docs", + "skills-python", + "checks-windows", + "macos-node", + "macos-swift", + "ios-build", + "android", + ]; + + expect(workflow.on.pull_request).not.toHaveProperty("paths-ignore"); + expect(gate.name).toBe("openclaw/ci-gate"); + expect(gate.needs).toEqual([...requiredJobs, ...selectedJobs]); + expect(gate.needs.toSorted()).toEqual( + Object.keys(workflow.jobs) + .filter((job) => job !== "ci-gate" && job !== "ci-timings-summary") + .toSorted(), + ); + expect(gate.if).toBe( + "${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}", + ); + expect(gate["runs-on"]).toBe("ubuntu-24.04"); + expect(gate.permissions).toEqual({ contents: "read" }); + + const verifyStep = gate.steps.find( + (step: WorkflowStep) => step.name === "Verify selected CI lanes", + ); + expect(Object.keys(verifyStep.env).toSorted()).toEqual([ + "REQUIRED_RESULTS", + "SELECTED_RESULTS", + ]); + for (const job of requiredJobs) { + expect(verifyStep.env.REQUIRED_RESULTS).toContain(`${job}=\${{ needs.${job}.result }}`); + } + for (const job of selectedJobs) { + expect(verifyStep.env.SELECTED_RESULTS).toContain(`${job}=\${{ needs.${job}.result }}`); + } + expect(verifyStep.run).toContain("Required CI job did not succeed"); + expect(verifyStep.run).toContain("success | skipped"); + expect(verifyStep.run).toContain("Selected CI job did not succeed"); + }); + + it.skipIf(process.platform === "win32")( + "accepts only successful required jobs and successful or skipped selected jobs", + () => { + const passing = runCiGateFixture( + "runner-admission=success\npreflight=success\nsecurity-fast=success", + "checks-ui=success\nmacos-swift=skipped", + ); + expect(passing.status, `${passing.stdout}\n${passing.stderr}`).toBe(0); + + const skippedRequired = runCiGateFixture( + "runner-admission=success\npreflight=skipped\nsecurity-fast=success", + "checks-ui=skipped", + ); + expect(skippedRequired.status).not.toBe(0); + expect(skippedRequired.stdout).toContain("preflight finished with skipped"); + + const failedSelected = runCiGateFixture( + "runner-admission=success\npreflight=success\nsecurity-fast=success", + "checks-ui=failure\nmacos-swift=cancelled", + ); + expect(failedSelected.status).not.toBe(0); + expect(failedSelected.stdout).toContain("checks-ui finished with failure"); + expect(failedSelected.stdout).toContain("macos-swift finished with cancelled"); + }, + ); + it("keeps maturity scorecard generated QA evidence handoff strict", () => { const maturityWorkflow = readMaturityScorecardWorkflow(); const qaEvidenceWorkflow = readQaProfileEvidenceWorkflow();