ci: speed up scoped workflow lanes

This commit is contained in:
Peter Steinberger 2026-03-13 19:42:08 +00:00
parent a423b1d936
commit d17490ff54
4 changed files with 59 additions and 29 deletions

View File

@ -7,7 +7,7 @@ on:
concurrency: concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }} cancel-in-progress: true
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@ -38,9 +38,8 @@ jobs:
id: check id: check
uses: ./.github/actions/detect-docs-changes uses: ./.github/actions/detect-docs-changes
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs. # Detect which heavy areas are touched so CI can skip unrelated expensive jobs.
# Push to main keeps broad coverage, but this job still needs to run so # Fail-safe: if detection fails, downstream jobs run.
# downstream jobs that list it in `needs` are not skipped.
changed-scope: changed-scope:
needs: [docs-scope] needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true' if: needs.docs-scope.outputs.docs_only != 'true'
@ -82,7 +81,7 @@ jobs:
# Build dist once for Node-relevant changes and share it with downstream jobs. # Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts: build-artifacts:
needs: [docs-scope, changed-scope] needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404 runs-on: blacksmith-16vcpu-ubuntu-2404
steps: steps:
- name: Checkout - name: Checkout
@ -141,7 +140,7 @@ jobs:
checks: checks:
needs: [docs-scope, changed-scope] needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404 runs-on: blacksmith-16vcpu-ubuntu-2404
strategy: strategy:
fail-fast: false fail-fast: false
@ -149,6 +148,13 @@ jobs:
include: include:
- runtime: node - runtime: node
task: test task: test
shard_index: 1
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 2
command: pnpm canvas:a2ui:bundle && pnpm test command: pnpm canvas:a2ui:bundle && pnpm test
- runtime: node - runtime: node
task: extensions task: extensions
@ -179,11 +185,18 @@ jobs:
- name: Configure Node test resources - name: Configure Node test resources
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
env:
SHARD_COUNT: ${{ matrix.shard_count || '' }}
SHARD_INDEX: ${{ matrix.shard_index || '' }}
run: | run: |
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes. # `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB). # Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV" echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV" echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then
echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
fi
- name: Run ${{ matrix.task }} (${{ matrix.runtime }}) - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
if: matrix.runtime != 'bun' || github.event_name != 'push' if: matrix.runtime != 'bun' || github.event_name != 'push'
@ -193,7 +206,7 @@ jobs:
check: check:
name: "check" name: "check"
needs: [docs-scope, changed-scope] needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404 runs-on: blacksmith-16vcpu-ubuntu-2404
steps: steps:
- name: Checkout - name: Checkout
@ -239,7 +252,7 @@ jobs:
compat-node22: compat-node22:
name: "compat-node22" name: "compat-node22"
needs: [docs-scope, changed-scope] needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404 runs-on: blacksmith-16vcpu-ubuntu-2404
steps: steps:
- name: Checkout - name: Checkout
@ -272,7 +285,7 @@ jobs:
skills-python: skills-python:
needs: [docs-scope, changed-scope] needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true' || needs.changed-scope.outputs.run_skills_python == 'true') if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_skills_python == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404 runs-on: blacksmith-16vcpu-ubuntu-2404
steps: steps:
- name: Checkout - name: Checkout
@ -365,7 +378,7 @@ jobs:
checks-windows: checks-windows:
needs: [docs-scope, changed-scope] needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_windows == 'true') if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_windows == 'true'
runs-on: blacksmith-32vcpu-windows-2025 runs-on: blacksmith-32vcpu-windows-2025
timeout-minutes: 45 timeout-minutes: 45
env: env:
@ -727,7 +740,7 @@ jobs:
android: android:
needs: [docs-scope, changed-scope] needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true') if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_android == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404 runs-on: blacksmith-16vcpu-ubuntu-2404
strategy: strategy:
fail-fast: false fail-fast: false

View File

@ -9,32 +9,32 @@ read_when:
# CI Pipeline # CI Pipeline
The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only docs or native code changed. The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only unrelated areas changed.
## Job Overview ## Job Overview
| Job | Purpose | When it runs | | Job | Purpose | When it runs |
| ----------------- | ------------------------------------------------------- | ------------------------------------------------- | | ----------------- | ------------------------------------------------------- | ---------------------------------- |
| `docs-scope` | Detect docs-only changes | Always | | `docs-scope` | Detect docs-only changes | Always |
| `changed-scope` | Detect which areas changed (node/macos/android/windows) | Non-docs PRs | | `changed-scope` | Detect which areas changed (node/macos/android/windows) | Non-doc changes |
| `check` | TypeScript types, lint, format | Push to `main`, or PRs with Node-relevant changes | | `check` | TypeScript types, lint, format | Non-docs, node changes |
| `check-docs` | Markdown lint + broken link check | Docs changed | | `check-docs` | Markdown lint + broken link check | Docs changed |
| `code-analysis` | LOC threshold check (1000 lines) | PRs only | | `code-analysis` | LOC threshold check (1000 lines) | PRs only |
| `secrets` | Detect leaked secrets | Always | | `secrets` | Detect leaked secrets | Always |
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes | | `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
| `release-check` | Validate npm pack contents | After build | | `release-check` | Validate npm pack contents | After build |
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes | | `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
| `checks-windows` | Windows-specific tests | Non-docs, windows-relevant changes | | `checks-windows` | Windows-specific tests | Non-docs, windows-relevant changes |
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes | | `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
| `android` | Gradle build + tests | Non-docs, android changes | | `android` | Gradle build + tests | Non-docs, android changes |
## Fail-Fast Order ## Fail-Fast Order
Jobs are ordered so cheap checks fail before expensive ones run: Jobs are ordered so cheap checks fail before expensive ones run:
1. `docs-scope` + `code-analysis` + `check` (parallel, ~1-2 min) 1. `docs-scope` + `changed-scope` + `check` + `secrets` (parallel, cheap gates first)
2. `build-artifacts` (blocked on above) 2. `build-artifacts` + `release-check`
3. `checks`, `checks-windows`, `macos`, `android` (blocked on build) 3. `checks` (Linux Node test split into 2 shards), `checks-windows`, `macos`, `android`
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`.

View File

@ -5,6 +5,7 @@ import { appendFileSync } from "node:fs";
const DOCS_PATH_RE = /^(docs\/|.*\.mdx?$)/; const DOCS_PATH_RE = /^(docs\/|.*\.mdx?$)/;
const SKILLS_PYTHON_SCOPE_RE = /^skills\//; const SKILLS_PYTHON_SCOPE_RE = /^skills\//;
const CI_WORKFLOW_SCOPE_RE = /^\.github\/workflows\/ci\.yml$/;
const MACOS_PROTOCOL_GEN_RE = const MACOS_PROTOCOL_GEN_RE =
/^(apps\/macos\/Sources\/OpenClawProtocol\/|apps\/shared\/OpenClawKit\/Sources\/OpenClawProtocol\/)/; /^(apps\/macos\/Sources\/OpenClawProtocol\/|apps\/shared\/OpenClawKit\/Sources\/OpenClawProtocol\/)/;
const MACOS_NATIVE_RE = /^(apps\/macos\/|apps\/ios\/|apps\/shared\/|Swabble\/)/; const MACOS_NATIVE_RE = /^(apps\/macos\/|apps\/ios\/|apps\/shared\/|Swabble\/)/;
@ -55,6 +56,12 @@ export function detectChangedScope(changedPaths) {
runSkillsPython = true; runSkillsPython = true;
} }
if (CI_WORKFLOW_SCOPE_RE.test(path)) {
runMacos = true;
runAndroid = true;
runSkillsPython = true;
}
if (!MACOS_PROTOCOL_GEN_RE.test(path) && MACOS_NATIVE_RE.test(path)) { if (!MACOS_PROTOCOL_GEN_RE.test(path) && MACOS_NATIVE_RE.test(path)) {
runMacos = true; runMacos = true;
} }

View File

@ -124,6 +124,16 @@ describe("detectChangedScope", () => {
}); });
}); });
it("runs platform lanes when the CI workflow changes", () => {
expect(detectChangedScope([".github/workflows/ci.yml"])).toEqual({
runNode: true,
runMacos: true,
runAndroid: true,
runWindows: true,
runSkillsPython: true,
});
});
it("treats base and head as literal git args", () => { it("treats base and head as literal git args", () => {
const markerPath = path.join( const markerPath = path.join(
os.tmpdir(), os.tmpdir(),