fix(ci): stop unrelated main updates from forcing PR rebases (#107050)

* fix(ci): stop unrelated main updates from forcing PR rebases

* docs(ci): document reusable aggregate proof

* docs(ci): clarify immutable gate success

* chore(ci): refresh stalled PR head
This commit is contained in:
Peter Steinberger
2026-07-14 00:01:01 -07:00
committed by GitHub
parent b58a75d726
commit 606e2f5dba
3 changed files with 201 additions and 4 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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();