diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd08ba25409..9459145fb91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,71 +57,7 @@ jobs: BASE="${{ github.event.pull_request.base.sha }}" fi - CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")" - if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then - # Fail-safe: run broad checks if detection fails. - echo "run_node=true" >> "$GITHUB_OUTPUT" - echo "run_macos=true" >> "$GITHUB_OUTPUT" - echo "run_android=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - run_node=false - run_macos=false - run_android=false - has_non_docs=false - has_non_native_non_docs=false - - while IFS= read -r path; do - [ -z "$path" ] && continue - case "$path" in - docs/*|*.md|*.mdx) - continue - ;; - *) - has_non_docs=true - ;; - esac - - case "$path" in - # Generated protocol models are already covered by protocol:check and - # should not force the full native macOS lane. - apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*) - ;; - apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*) - run_macos=true - ;; - esac - - case "$path" in - apps/android/*|apps/shared/*) - run_android=true - ;; - esac - - case "$path" in - src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc) - run_node=true - ;; - esac - - case "$path" in - apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml) - ;; - *) - has_non_native_non_docs=true - ;; - esac - done <<< "$CHANGED" - - # If there are non-doc files outside native app trees, keep Node checks enabled. - if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then - run_node=true - fi - - echo "run_node=${run_node}" >> "$GITHUB_OUTPUT" - echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT" - echo "run_android=${run_android}" >> "$GITHUB_OUTPUT" + node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD # Build dist once for Node-relevant changes and share it with downstream jobs. build-artifacts: diff --git a/docs/ci.md b/docs/ci.md index dc67454d2a3..0b6de6d21a5 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -36,6 +36,8 @@ Jobs are ordered so cheap checks fail before expensive ones run: 2. `build-artifacts` (blocked on above) 3. `checks`, `checks-windows`, `macos`, `android` (blocked on build) +Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. + ## Runners | Runner | Jobs | diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs new file mode 100644 index 00000000000..84e0a508ee9 --- /dev/null +++ b/scripts/ci-changed-scope.mjs @@ -0,0 +1,133 @@ +import { execSync } from "node:child_process"; +import { appendFileSync } from "node:fs"; + +/** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean }} ChangedScope */ + +const DOCS_PATH_RE = /^(docs\/|.*\.mdx?$)/; +const MACOS_PROTOCOL_GEN_RE = + /^(apps\/macos\/Sources\/OpenClawProtocol\/|apps\/shared\/OpenClawKit\/Sources\/OpenClawProtocol\/)/; +const MACOS_NATIVE_RE = /^(apps\/macos\/|apps\/ios\/|apps\/shared\/|Swabble\/)/; +const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/; +const NODE_SCOPE_RE = + /^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/; +const NATIVE_ONLY_RE = + /^(apps\/android\/|apps\/ios\/|apps\/macos\/|apps\/shared\/|Swabble\/|appcast\.xml$)/; + +/** + * @param {string[]} changedPaths + * @returns {ChangedScope} + */ +export function detectChangedScope(changedPaths) { + if (!Array.isArray(changedPaths) || changedPaths.length === 0) { + return { runNode: true, runMacos: true, runAndroid: true }; + } + + let runNode = false; + let runMacos = false; + let runAndroid = false; + let hasNonDocs = false; + let hasNonNativeNonDocs = false; + + for (const rawPath of changedPaths) { + const path = String(rawPath).trim(); + if (!path) { + continue; + } + + if (DOCS_PATH_RE.test(path)) { + continue; + } + + hasNonDocs = true; + + if (!MACOS_PROTOCOL_GEN_RE.test(path) && MACOS_NATIVE_RE.test(path)) { + runMacos = true; + } + + if (ANDROID_NATIVE_RE.test(path)) { + runAndroid = true; + } + + if (NODE_SCOPE_RE.test(path)) { + runNode = true; + } + + if (!NATIVE_ONLY_RE.test(path)) { + hasNonNativeNonDocs = true; + } + } + + if (!runNode && hasNonDocs && hasNonNativeNonDocs) { + runNode = true; + } + + return { runNode, runMacos, runAndroid }; +} + +/** + * @param {string} base + * @param {string} [head] + * @returns {string[]} + */ +export function listChangedPaths(base, head = "HEAD") { + if (!base) { + return []; + } + const output = execSync(`git diff --name-only ${base} ${head}`, { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** + * @param {ChangedScope} scope + * @param {string} [outputPath] + */ +export function writeGitHubOutput(scope, outputPath = process.env.GITHUB_OUTPUT) { + if (!outputPath) { + throw new Error("GITHUB_OUTPUT is required"); + } + appendFileSync(outputPath, `run_node=${scope.runNode}\n`, "utf8"); + appendFileSync(outputPath, `run_macos=${scope.runMacos}\n`, "utf8"); + appendFileSync(outputPath, `run_android=${scope.runAndroid}\n`, "utf8"); +} + +function isDirectRun() { + const direct = process.argv[1]; + return Boolean(direct && import.meta.url.endsWith(direct)); +} + +/** @param {string[]} argv */ +function parseArgs(argv) { + const args = { base: "", head: "HEAD" }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--base") { + args.base = argv[i + 1] ?? ""; + i += 1; + continue; + } + if (argv[i] === "--head") { + args.head = argv[i + 1] ?? "HEAD"; + i += 1; + } + } + return args; +} + +if (isDirectRun()) { + const args = parseArgs(process.argv.slice(2)); + try { + const changedPaths = listChangedPaths(args.base, args.head); + if (changedPaths.length === 0) { + writeGitHubOutput({ runNode: true, runMacos: true, runAndroid: true }); + process.exit(0); + } + writeGitHubOutput(detectChangedScope(changedPaths)); + } catch { + writeGitHubOutput({ runNode: true, runMacos: true, runAndroid: true }); + } +} diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts new file mode 100644 index 00000000000..70c00559594 --- /dev/null +++ b/src/scripts/ci-changed-scope.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { detectChangedScope } from "../../scripts/ci-changed-scope.mjs"; + +describe("detectChangedScope", () => { + it("fails safe when no paths are provided", () => { + expect(detectChangedScope([])).toEqual({ + runNode: true, + runMacos: true, + runAndroid: true, + }); + }); + + it("keeps all lanes off for docs-only changes", () => { + expect(detectChangedScope(["docs/ci.md", "README.md"])).toEqual({ + runNode: false, + runMacos: false, + runAndroid: false, + }); + }); + + it("enables node lane for node-relevant files", () => { + expect(detectChangedScope(["src/plugins/runtime/index.ts"])).toEqual({ + runNode: true, + runMacos: false, + runAndroid: false, + }); + }); + + it("keeps node lane off for native-only changes", () => { + expect(detectChangedScope(["apps/macos/Sources/Foo.swift"])).toEqual({ + runNode: false, + runMacos: true, + runAndroid: false, + }); + expect(detectChangedScope(["apps/shared/OpenClawKit/Sources/Foo.swift"])).toEqual({ + runNode: false, + runMacos: true, + runAndroid: true, + }); + }); + + it("does not force macOS for generated protocol model-only changes", () => { + expect(detectChangedScope(["apps/macos/Sources/OpenClawProtocol/GatewayModels.swift"])).toEqual( + { + runNode: false, + runMacos: false, + runAndroid: false, + }, + ); + }); + + it("enables node lane for non-native non-doc files by fallback", () => { + expect(detectChangedScope(["README.md"])).toEqual({ + runNode: false, + runMacos: false, + runAndroid: false, + }); + + expect(detectChangedScope(["assets/icon.png"])).toEqual({ + runNode: true, + runMacos: false, + runAndroid: false, + }); + }); +});