diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 978ca7e7..eb08c40c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,48 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + changes: + name: changes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + continuity: ${{ steps.classify.outputs.continuity }} + tooling: ${{ steps.classify.outputs.tooling }} + android_debug: ${{ steps.classify.outputs.android_debug }} + flutter: ${{ steps.classify.outputs.flutter }} + flutter_android: ${{ steps.classify.outputs.flutter_android }} + flutter_apple: ${{ steps.classify.outputs.flutter_apple }} + steps: + - name: Check out source and base history + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + + - name: Test path classification + run: node --test scripts/ci-paths.test.mjs + + - name: Classify affected CI legs + id: classify + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" != pull_request ]]; then + node scripts/ci-paths.mjs --all >> "$GITHUB_OUTPUT" + exit 0 + fi + [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] + git diff --name-only -z "$BASE_SHA...$HEAD_SHA" | node scripts/ci-paths.mjs >> "$GITHUB_OUTPUT" + core: runs-on: ubuntu-24.04 timeout-minutes: 25 @@ -58,6 +100,8 @@ jobs: run: pnpm test:packaging legacy-bridge-continuity: + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.continuity == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 20 steps: @@ -130,6 +174,8 @@ jobs: retention-days: 14 tooling: + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.tooling == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 25 steps: @@ -154,6 +200,8 @@ jobs: run: pnpm test:tooling android-debug: + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.android_debug == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 35 steps: @@ -194,6 +242,8 @@ jobs: run: pnpm --filter @t4-code/mobile check:android:debug flutter: + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 30 steps: @@ -231,6 +281,8 @@ jobs: run: pnpm build:flutter:web flutter-android: + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter_android == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 35 steps: @@ -271,6 +323,8 @@ jobs: script: cd apps/flutter && flutter test integration_test/app_smoke_test.dart -d emulator-5554 flutter-apple: + needs: changes + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.flutter_apple == 'true' }} runs-on: macos-15 timeout-minutes: 40 steps: @@ -341,13 +395,14 @@ jobs: verify: name: verify if: ${{ always() }} - needs: [core, legacy-bridge-continuity, tooling, android-debug, flutter, flutter-android, flutter-apple] + needs: [changes, core, legacy-bridge-continuity, tooling, android-debug, flutter, flutter-android, flutter-apple] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - - name: Require every CI leg + - name: Require every selected CI leg shell: bash env: + CHANGES_RESULT: ${{ needs.changes.result }} CORE_RESULT: ${{ needs.core.result }} CONTINUITY_RESULT: ${{ needs.legacy-bridge-continuity.result }} TOOLING_RESULT: ${{ needs.tooling.result }} @@ -357,10 +412,18 @@ jobs: FLUTTER_APPLE_RESULT: ${{ needs.flutter-apple.result }} run: | set -euo pipefail + test "$CHANGES_RESULT" = success test "$CORE_RESULT" = success - test "$CONTINUITY_RESULT" = success - test "$TOOLING_RESULT" = success - test "$ANDROID_RESULT" = success - test "$FLUTTER_RESULT" = success - test "$FLUTTER_ANDROID_RESULT" = success - test "$FLUTTER_APPLE_RESULT" = success + for result in \ + "$CONTINUITY_RESULT" \ + "$TOOLING_RESULT" \ + "$ANDROID_RESULT" \ + "$FLUTTER_RESULT" \ + "$FLUTTER_ANDROID_RESULT" \ + "$FLUTTER_APPLE_RESULT" + do + case "$result" in + success|skipped) ;; + *) exit 1 ;; + esac + done diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index c29ffe76..14afbcad 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -823,14 +823,11 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", "name: verify", "if: ${{ always() }}", - "needs: [core, legacy-bridge-continuity, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, legacy-bridge-continuity, tooling, android-debug, flutter, flutter-android, flutter-apple]", + 'test "$CHANGES_RESULT" = success', 'test "$CORE_RESULT" = success', - 'test "$CONTINUITY_RESULT" = success', - 'test "$TOOLING_RESULT" = success', - 'test "$ANDROID_RESULT" = success', - 'test "$FLUTTER_RESULT" = success', - 'test "$FLUTTER_ANDROID_RESULT" = success', - 'test "$FLUTTER_APPLE_RESULT" = success', + "for result in \\", + "success|skipped) ;;", "github.event_name == 'pull_request' && github.ref || github.sha", "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", "actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index be20e719..4796e65b 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -245,8 +245,8 @@ test("rejects updater channel, stable manifest, and publication-contract drift", ".github/workflows/ci.yml", (text) => text.replace( - "needs: [core, legacy-bridge-continuity, tooling, android-debug, flutter, flutter-android, flutter-apple]", - "needs: [core, tooling, android-debug]", + "needs: [changes, core, legacy-bridge-continuity, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, tooling, android-debug]", ), ], [ @@ -545,16 +545,13 @@ test("deploys release site source only after artifact publication", () => { assert.ok(ciWorkflow.includes("if: ${{ always() }}")); assert.ok( ciWorkflow.includes( - "needs: [core, legacy-bridge-continuity, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, core, legacy-bridge-continuity, tooling, android-debug, flutter, flutter-android, flutter-apple]", ), ); + assert.ok(ciWorkflow.includes('test "$CHANGES_RESULT" = success')); assert.ok(ciWorkflow.includes('test "$CORE_RESULT" = success')); - assert.ok(ciWorkflow.includes('test "$CONTINUITY_RESULT" = success')); - assert.ok(ciWorkflow.includes('test "$TOOLING_RESULT" = success')); - assert.ok(ciWorkflow.includes('test "$ANDROID_RESULT" = success')); - assert.ok(ciWorkflow.includes('test "$FLUTTER_RESULT" = success')); - assert.ok(ciWorkflow.includes('test "$FLUTTER_ANDROID_RESULT" = success')); - assert.ok(ciWorkflow.includes('test "$FLUTTER_APPLE_RESULT" = success')); + assert.ok(ciWorkflow.includes("for result in \\")); + assert.ok(ciWorkflow.includes("success|skipped) ;;")); assert.ok(ciWorkflow.includes("github.event_name == 'pull_request' && github.ref || github.sha")); assert.ok(ciWorkflow.includes("cancel-in-progress: ${{ github.event_name == 'pull_request' }}")); assert.ok(ciWorkflow.includes('java-version: "21"')); diff --git a/scripts/ci-paths.mjs b/scripts/ci-paths.mjs new file mode 100755 index 00000000..52a53dbc --- /dev/null +++ b/scripts/ci-paths.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; + +const FORCE_ALL = [ + /^package\.json$/u, + /^pnpm-lock\.yaml$/u, + /^pnpm-workspace\.yaml$/u, +]; + +const GROUP_PATTERNS = Object.freeze({ + continuity: [ + /^packages\/host-service\/src\//u, + /^packages\/host-service\/package\.json$/u, + /^packages\/host-wire\/src\//u, + /^packages\/host-wire\/package\.json$/u, + /^provenance\/omp-host-migration\.json$/u, + /^scripts\/legacy-bridge-continuity(?:\.test)?\.mjs$/u, + ], + tooling: [ + /^\.github\//u, + /^compat\//u, + /^docs\//u, + /^provenance\//u, + /^scripts\//u, + /^packages\/host-(?:daemon|service|wire)\//u, + ], + android_debug: [ + /^apps\/(?:mobile|web)\//u, + /^packages\/(?:client|ui)\//u, + /^packages\/host-wire\//u, + ], + flutter: [/^apps\/flutter\//u, /^packages\/host-wire\//u], + flutter_android: [/^apps\/flutter\//u, /^packages\/host-wire\//u], + flutter_apple: [ + /^apps\/flutter\//u, + /^packages\/host-(?:daemon|wire)\//u, + /^apps\/desktop\/build\/entitlements\.omp-runtime\.plist$/u, + /^scripts\/(?:package-mac|stage-omp-runtime)/u, + ], +}); + +function normalizePath(path) { + return path.replace(/^\.\//u, "").replaceAll("\\", "/"); +} + +export function classifyCiPaths(paths) { + const normalized = [...new Set(paths.map(normalizePath).filter(Boolean))]; + const all = normalized.some((path) => FORCE_ALL.some((pattern) => pattern.test(path))); + return Object.fromEntries( + Object.entries(GROUP_PATTERNS).map(([group, patterns]) => [ + group, + all || normalized.some((path) => patterns.some((pattern) => pattern.test(path))), + ]), + ); +} + +export function formatGitHubOutputs(result) { + return `${Object.entries(result) + .map(([name, enabled]) => `${name}=${enabled ? "true" : "false"}`) + .join("\n")}\n`; +} + +async function readChangedPaths() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + return raw.includes("\0") ? raw.split("\0").filter(Boolean) : raw.split(/\r?\n/u).filter(Boolean); +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + const all = process.argv.includes("--all"); + const result = all + ? Object.fromEntries(Object.keys(GROUP_PATTERNS).map((group) => [group, true])) + : classifyCiPaths(await readChangedPaths()); + process.stdout.write(formatGitHubOutputs(result)); +} diff --git a/scripts/ci-paths.test.mjs b/scripts/ci-paths.test.mjs new file mode 100644 index 00000000..1a326c83 --- /dev/null +++ b/scripts/ci-paths.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classifyCiPaths, formatGitHubOutputs } from "./ci-paths.mjs"; + +const none = { + continuity: false, + tooling: false, + android_debug: false, + flutter: false, + flutter_android: false, + flutter_apple: false, +}; + +test("host runtime source runs host gates without unrelated platform builds", () => { + assert.deepEqual(classifyCiPaths(["packages/host-service/src/rpc-child.ts"]), { + ...none, + continuity: true, + tooling: true, + }); +}); + +test("lifecycle harness and architecture docs run tooling only", () => { + assert.deepEqual( + classifyCiPaths([ + "packages/host-service/bin/official-omp-gate0.ts", + "docs/T4_ARCHITECTURE.html", + "compat/omp-app-matrix.json", + ]), + { ...none, tooling: true }, + ); +}); + +test("Flutter changes run all Flutter legs", () => { + assert.deepEqual(classifyCiPaths(["apps/flutter/lib/src/client/t4_client_controller.dart"]), { + ...none, + flutter: true, + flutter_android: true, + flutter_apple: true, + }); +}); + +test("host wire changes run every dependent client and continuity gate", () => { + assert.deepEqual(classifyCiPaths(["packages/host-wire/src/command.ts"]), { + continuity: true, + tooling: true, + android_debug: true, + flutter: true, + flutter_android: true, + flutter_apple: true, + }); +}); + +test("host daemon changes run the Apple packaging leg", () => { + assert.deepEqual(classifyCiPaths(["packages/host-daemon/src/main.ts"]), { + ...none, + tooling: true, + flutter_apple: true, + }); +}); + +test("mobile web changes run only the Android debug product leg", () => { + assert.deepEqual(classifyCiPaths(["apps/web/src/App.tsx"]), { + ...none, + android_debug: true, + }); +}); + +test("dependency graph changes conservatively run every leg", () => { + for (const path of ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"]) { + assert.deepEqual(classifyCiPaths([path]), { + continuity: true, + tooling: true, + android_debug: true, + flutter: true, + flutter_android: true, + flutter_apple: true, + }); + } +}); + +test("workflow changes run tooling on the PR and the full matrix after merge", () => { + assert.deepEqual(classifyCiPaths([".github/workflows/ci.yml"]), { + ...none, + tooling: true, + }); +}); + +test("paths are normalized and GitHub outputs are stable", () => { + const result = classifyCiPaths(["./apps\\flutter\\pubspec.yaml", "./apps/flutter/pubspec.yaml"]); + assert.equal( + formatGitHubOutputs(result), + "continuity=false\ntooling=false\nandroid_debug=false\nflutter=true\nflutter_android=true\nflutter_apple=true\n", + ); +});