From 4a317aee7509599d860e1811d30fb56bf67696c6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 11:30:24 +0900 Subject: [PATCH 1/2] ci: run docker/keyring smoke and React Doctor on PRs only when their inputs change Every pull request the ci filter matched (about nine in ten) started docker-smoke and the ubuntu keyring-smoke leg, and every pull request started React Doctor, although React Doctor scans only gui/ and each smoke job proves a narrow contract. - ci.yml: new docker and keyring path filters in the changes job, validated with the other narrow outputs. On pull_request the two jobs also require their own filter; push (main/preview), workflow_dispatch and the nightly schedule still request both unconditionally. The aggregate ci gate mirrors the new conditions, so the ci check keeps reporting on every PR. - react-doctor.yml: pull_request trigger path-filtered to gui/** and the workflow itself; the main push trend scan is unchanged. - structure/ops/docs-and-release.md: workflow table updated. --- .github/workflows/ci.yml | 59 +++++++++++++++++-- .github/workflows/react-doctor.yml | 7 +++ structure/ops/docs-and-release.md | 4 +- tests/ci-workflows/ci-privacy-gate.test.ts | 2 + tests/ci-workflows/ci-review-lanes.test.ts | 27 ++++++++- tests/ci-workflows/ci-scope-reduction.test.ts | 35 +++++++++++ tests/ci-workflows/ci-workflows.test.ts | 9 +++ 7 files changed, 134 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028c28ca318..16d59df9856 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,6 +233,10 @@ jobs: # skipping the check it selects. setup_action: ${{ steps.narrow.outputs.setup_action }} remote_helper: ${{ steps.narrow.outputs.remote_helper }} + # Pull-request scope for the two smoke jobs that used to ride the whole ci filter. + # Push, dispatch and schedule run both regardless; see the docker/keyring filters. + docker: ${{ steps.narrow.outputs.docker }} + keyring: ${{ steps.narrow.outputs.keyring }} # Whether this pull request needs the nine Windows shards: the windows path # filter below OR an added src/tests line naming a Windows marker. Computed # by scripts/ci/windows-sensitive-diff.sh, which validates its input and @@ -388,6 +392,37 @@ jobs: # real only on macOS and Windows, so its job lints and tests the crate on all three. remote_helper: - 'native/remote-workspace-helper/**' + # docker-smoke and keyring-smoke on a pull request. Both used to run on every PR the + # ci filter matched, which is nearly all of them, while each proves something narrow: + # the image builds, starts and survives recreation with its volume; the OS keyring + # binding creates, reads and deletes a secret. A pull request now runs each only when + # its own inputs move. Push to main/preview, workflow_dispatch and the nightly still + # request both unconditionally, so the release gate's push run always carries them. + # ci.yml is listed so an edit to either job verifies itself. The setup action is left to + # its own setup-action job, as the docs and structure filters leave it. + docker: + - 'Dockerfile' + - 'compose.yaml' + - '.dockerignore' + - 'docker/**' + - 'scripts/ci/docker-smoke.ts' + - 'scripts/generate-compatibility-version.ts' + - 'scripts/model-metadata.source.json' + - 'src/cli/index.ts' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + - 'gui/package.json' + - 'gui/bun.lock' + - '.github/workflows/ci.yml' + # scripts/keyring-smoke.ts imports only src/lib/keyring-native.ts; the native binding + # itself arrives through package.json and bun.lock. + keyring: + - 'src/lib/keyring-native.ts' + - 'scripts/keyring-smoke.ts' + - 'package.json' + - 'bun.lock' + - '.github/workflows/ci.yml' # Paths whose change selects the nine Windows suite shards on a pull # request. These carry the Windows service, update, shim, intercept, # ACL and process code, and the harness every Windows shard runs. @@ -532,9 +567,11 @@ jobs: env: SETUP_ACTION: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }} REMOTE_HELPER: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }} + DOCKER_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.docker }} + KEYRING_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.keyring }} run: | set -euo pipefail - for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER"; do + for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER" "docker=$DOCKER_SCOPE" "keyring=$KEYRING_SCOPE"; do case "${pair#*=}" in true|false) printf '%s\n' "$pair" >> "$GITHUB_OUTPUT" @@ -1148,7 +1185,7 @@ jobs: keyring-smoke: name: keyring ${{ matrix.name }} needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.keyring == 'true') runs-on: ${{ matrix.runner }} timeout-minutes: 8 strategy: @@ -1211,7 +1248,7 @@ jobs: docker-smoke: name: docker smoke needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.docker == 'true') runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -1729,6 +1766,8 @@ jobs: CHANGES_STRUCTURE: ${{ needs.changes.outputs.structure }} CHANGES_SETUP_ACTION: ${{ needs.changes.outputs.setup_action }} CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }} + CHANGES_DOCKER: ${{ needs.changes.outputs.docker }} + CHANGES_KEYRING: ${{ needs.changes.outputs.keyring }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} CHANGES_WINDOWS: ${{ needs.changes.outputs.windows }} @@ -1785,6 +1824,16 @@ jobs: if [ "$CHANGES_REMOTE_HELPER" = "true" ]; then remote_helper=requested fi + # docker-smoke and keyring-smoke follow the ci scope, narrowed on a pull request to + # their own filters. Mirrors their `if:` exactly, like desktop_shell above. + docker=$scoped + if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_DOCKER" != "true" ]; then + docker=not-requested + fi + keyring=$scoped + if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_KEYRING" != "true" ]; then + keyring=not-requested + fi # privacy-gate runs exactly where `gates` (scoped) does not, on every # pull request the ci filter declines. Deriving it from `scoped` keeps # the two scans complementary here as they are in the jobs' own @@ -1823,8 +1872,10 @@ jobs: expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|keyring-smoke|docker-smoke) + test|storage-policy|api-usage|gates) echo "$scoped" ;; + docker-smoke) echo "$docker" ;; + keyring-smoke) echo "$keyring" ;; platform-macos|widget) echo "$native" ;; desktop-shell) echo "$desktop_shell" ;; diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index d8ac13ab366..93d8a38e3df 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -14,8 +14,15 @@ name: React Doctor on: # Scans the PR's changed files relative to the merge base of the target branch. + # The scan reads only gui/ (`directory: gui` below), so a pull request that + # changes nothing there has nothing to scan: it used to start a full-history + # checkout and an npm install on every PR for an empty changed-file list. No + # branch rule requires this check, so a PR it skips is not left waiting on it. pull_request: types: [opened, synchronize, reopened, ready_for_review] + paths: + - "gui/**" + - ".github/workflows/react-doctor.yml" # Scans `main` on every push to track the health-score trend. push: branches: ["main"] diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index e9d86f0f544..9451cbfefac 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -215,7 +215,7 @@ cannot supply a write-job PR number. If no unique local current-head candidate r | Workflow | Trigger | Purpose | | --- | --- | --- | -| `.github/workflows/ci.yml` | Any `pull_request`; runtime/package `push` to `main`/`preview`; manual dispatch | A pull request verifies Linux and TypeScript: Linux runs four suite shards plus `gates` alongside the scoped docs, structure, packaging, keyring, and npm-global jobs. The `platform-macos` macOS suite, the `widget` macOS widget + Tauri app-bundle build, and the `desktop-shell` Rust toolchain build are native-gated: they run on `main`/`preview` pushes and manual dispatch, and on a pull request only when the `changes` job's native path filter selects the change. `dev` pushes start nothing; dev integration is covered by the pull-request run, while `main` and `preview` must stay push triggers because `release.yml` requires a push-event run for the exact release SHA. Windows runs nine shards only on manual dispatch with `lane=all` (or empty), not on push events. Linux runs at-most-12-file processes with a 120-second process bound; Windows uses measured six-file/480-second processes and all-file scope so its full-suite contract is unchanged. The dedicated Windows batch step sets `OCX_TEST_NO_QUEUE=1` because its sequential processes are one logical runner; each process still creates an isolated home and arms the test guards before the lock boundary. No lane retries: a test failure, a process timeout and a Bun runtime crash each fail their job on the first occurrence. Aggregate `ci` is event-aware — it derives which jobs this event requested and requires `success` from each of them and `skipped` from the rest, and on a `lane=all` dispatch it reads the run's own job list and requires nine concrete successful `windows N/9` results. `npm-global-smoke` remains GitHub-hosted because it mutates the global package prefix. Manual `lane=release-gates` keeps the ordinary native-gated jobs and selected dynamic keyring/packaging matrix legs, but skips the Windows suite and unsharded macOS control. Default `all` (or empty) still requests both diagnostics; `macos-control` requests the control without Windows suite shards. Release eligibility requires successful push-event CI on the exact release SHA; a manual lane does not authorize publishing. Changes that touch only `.github/actions/` or `native/remote-workspace-helper/` run the narrow `setup-action` and `remote-helper` jobs instead of the full matrix, and Linux shard membership follows the per-file durations in `scripts/ci/test-durations.tsv`. | +| `.github/workflows/ci.yml` | Any `pull_request`; runtime/package `push` to `main`/`preview`; manual dispatch | A pull request verifies Linux and TypeScript: Linux runs four suite shards plus `gates` alongside the scoped docs, structure, packaging, and npm-global jobs; `docker-smoke` and the ubuntu `keyring-smoke` leg run on a pull request only when the `changes` job's `docker` / `keyring` filters select it, and on every push, dispatch, and nightly run. The `platform-macos` macOS suite, the `widget` macOS widget + Tauri app-bundle build, and the `desktop-shell` Rust toolchain build are native-gated: they run on `main`/`preview` pushes and manual dispatch, and on a pull request only when the `changes` job's native path filter selects the change. `dev` pushes start nothing; dev integration is covered by the pull-request run, while `main` and `preview` must stay push triggers because `release.yml` requires a push-event run for the exact release SHA. Windows runs nine shards only on manual dispatch with `lane=all` (or empty), not on push events. Linux runs at-most-12-file processes with a 120-second process bound; Windows uses measured six-file/480-second processes and all-file scope so its full-suite contract is unchanged. The dedicated Windows batch step sets `OCX_TEST_NO_QUEUE=1` because its sequential processes are one logical runner; each process still creates an isolated home and arms the test guards before the lock boundary. No lane retries: a test failure, a process timeout and a Bun runtime crash each fail their job on the first occurrence. Aggregate `ci` is event-aware — it derives which jobs this event requested and requires `success` from each of them and `skipped` from the rest, and on a `lane=all` dispatch it reads the run's own job list and requires nine concrete successful `windows N/9` results. `npm-global-smoke` remains GitHub-hosted because it mutates the global package prefix. Manual `lane=release-gates` keeps the ordinary native-gated jobs and selected dynamic keyring/packaging matrix legs, but skips the Windows suite and unsharded macOS control. Default `all` (or empty) still requests both diagnostics; `macos-control` requests the control without Windows suite shards. Release eligibility requires successful push-event CI on the exact release SHA; a manual lane does not authorize publishing. Changes that touch only `.github/actions/` or `native/remote-workspace-helper/` run the narrow `setup-action` and `remote-helper` jobs instead of the full matrix, and Linux shard membership follows the per-file durations in `scripts/ci/test-durations.tsv`. | | `.github/workflows/dev-version-bump.yml` | Manual dispatch with an intended version and `pre-move` or `repair` mode | Opens the reviewed pull request that moves `dev` past a release target. The default `pre-move` mode runs before promotion and publication; explicit `repair` mode retains the post-publish catch-up path. It is neither called by `release.yml` nor triggered by publication. | | `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. The `preflight` job checks channel, version sources, tag, GitHub release, npm, global tag ordering and the `dev` pre-move before any packaging job starts. The publish job repeats those checks, requires a successful push-event Cross-platform CI run for the exact `GITHUB_SHA` (a pull-request run does not qualify), requires `dev` to outrank the target, then checks the target against the freshly fetched global tag set before publish or dry-run. After a real publish, `release-outcomes` reports the public GitHub release, the npm version read-back and the npm dist-tag as separate rows. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. This is the deploy path; the pull-request build gate is the `docs-site-build` job in `ci.yml`. | @@ -226,7 +226,7 @@ cannot supply a write-job PR number. If no unique local current-head candidate r | `.github/workflows/issue-quality-tests.yml` | `pull_request` and `push` to `main`/`preview` filtered on the issue/PR automation scripts, templates, and their workflows | Tests the issue and PR automation scripts themselves, so the gates cannot rot silently. | | `.github/workflows/issue-triage.yml` | `issues` (opened) | Duplicate detection and triage labeling for new issues. | | `.github/workflows/pr-labeler.yml` | `pull_request_target` (opened, edited, synchronize, labeled, unlabeled) | Type and path labeling plus title sync; `labeled`/`unlabeled` let a human override enqueue a fresher run in the per-PR concurrency group. | -| `.github/workflows/react-doctor.yml` | `pull_request` (opened, synchronize, reopened, ready_for_review) and `push` to `main`; no path filter | React-focused static review. Findings fail the job; write-scoped outputs stay disabled, a contract pinned by `tests/ci-workflows/ci-workflows.test.ts`. | +| `.github/workflows/react-doctor.yml` | `pull_request` (opened, synchronize, reopened, ready_for_review) touching `gui/**` or the workflow, and every `push` to `main` | React-focused static review. Findings fail the job; write-scoped outputs stay disabled, a contract pinned by `tests/ci-workflows/ci-workflows.test.ts`. | | `.github/workflows/stale-needs-info.yml` | `schedule` only (daily 06:15 UTC); deliberately no manual dispatch | Closes issues left in needs-info past the grace period. Manual dispatch is omitted so a branch-selected run cannot execute that branch's body with issue write scope. | `pull_request_target`, `issues`, and `schedule` workflows always load from the repository default branch, not from `dev`. Landing a change to one of them on `dev` does not change live behavior until it is promoted, so those files follow the promotion model rather than ordinary integration. diff --git a/tests/ci-workflows/ci-privacy-gate.test.ts b/tests/ci-workflows/ci-privacy-gate.test.ts index fbe2e019d17..c0ef44bbfe8 100644 --- a/tests/ci-workflows/ci-privacy-gate.test.ts +++ b/tests/ci-workflows/ci-privacy-gate.test.ts @@ -123,6 +123,8 @@ describe.skipIf(cannotRunAggregate)("the aggregate ci gate, executed", () => { CHANGES_STRUCTURE: "false", CHANGES_SETUP_ACTION: "false", CHANGES_REMOTE_HELPER: "false", + CHANGES_DOCKER: "false", + CHANGES_KEYRING: "false", ...scope, // needs serializes as an object per job; the gate reads .value.result. RESULTS: JSON.stringify(Object.fromEntries(Object.entries(results).map(([job, result]) => [job, { result }]))), diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index ea51831dbab..09fc9956ad2 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -166,11 +166,22 @@ describe("CI review lanes", () => { const scopedCondition = "github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"; // platform-macos is native-gated and pinned to the native condition above, // so it must not be read against this ci-only condition. - for (const jobName of ["test", "storage-policy", "gates", "keyring-smoke", "docker-smoke"]) { + for (const jobName of ["test", "storage-policy", "gates"]) { const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); expect(`${jobName}:${job?.if}`).toBe(`${jobName}:${scopedCondition}`); } + // The two smoke jobs keep the ci scope and, on a pull request only, also need their + // own narrow filter. Every other event still requests them unconditionally. + for (const [jobName, output] of [["keyring-smoke", "keyring"], ["docker-smoke", "docker"]] as const) { + const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; + expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); + expect(`${jobName}:${job?.if}`).toBe( + `${jobName}:github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.${output} == 'true')`, + ); + expect((ci.jobs?.changes as { outputs?: Record } | undefined)?.outputs?.[output]) + .toBe(`\${{ steps.narrow.outputs.${output} }}`); + } const macosControlIf = ci.jobs?.["macos-control"] as { needs?: string; if?: string } | undefined; expect(macosControlIf?.needs).toBe("changes"); expect(macosControlIf?.if).toBe("github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); @@ -182,12 +193,12 @@ describe("CI review lanes", () => { }; // These job conditions use boolean operators and lowercase string comparisons. // Evaluate the checked-in expressions, rather than a second implementation of them. - const enabled = (job: string, event: string, lane: string, scope = "true", packaging = "true", native = "true", windows = "false") => { + const enabled = (job: string, event: string, lane: string, scope = "true", packaging = "true", native = "true", windows = "false", smoke = "true") => { const condition = ci.jobs[job]!.if ?? "true"; const evaluate = new Function("github", "needs", `return (${condition});`); return evaluate( { event_name: event, event: { inputs: { lane } } }, - { changes: { outputs: { ci: scope, packaging, native, windows } } }, + { changes: { outputs: { ci: scope, packaging, native, windows, docker: smoke, keyring: smoke } } }, ); }; for (const [event, lane, windows, control] of [ @@ -210,6 +221,14 @@ describe("CI review lanes", () => { expect(enabled(job, event, lane)).toBe(true); expect(enabled(job, event, lane, "false")).toBe(event !== "pull_request"); } + // A pull request whose diff misses the smoke filters skips both smoke jobs; any + // other event still runs them, which is what keeps them on the release push run. + for (const job of ["keyring-smoke", "docker-smoke"]) { + expect(enabled(job, event, lane, "true", "true", "true", "false", "false")).toBe(event !== "pull_request"); + } + for (const job of ["test", "gates", "storage-policy", "api-usage"]) { + expect(enabled(job, event, lane, "true", "true", "true", "false", "false")).toBe(true); + } // macOS jobs never run on a pull request; they run after merge, on push. for (const job of ["platform-macos", "widget"]) { for (const scope of ["true", "false"]) { @@ -281,6 +300,7 @@ describe("CI review lanes", () => { CHANGES_CI: "true", CHANGES_NATIVE: "true", CHANGES_PACKAGING: packaging, CHANGES_DOCS: "false", CHANGES_STRUCTURE: "false", CHANGES_SETUP_ACTION: "false", CHANGES_REMOTE_HELPER: "false", + CHANGES_DOCKER: "true", CHANGES_KEYRING: "true", }, deadlineMs: 5_000, }); @@ -394,6 +414,7 @@ describe("Windows and nightly CI contracts", () => { CHANGES_CI: "true", CHANGES_NATIVE: "true", CHANGES_PACKAGING: "true", CHANGES_DESKTOP: "false", CHANGES_DOCS: "false", CHANGES_STRUCTURE: "false", CHANGES_SETUP_ACTION: "false", CHANGES_REMOTE_HELPER: "false", + CHANGES_DOCKER: "false", CHANGES_KEYRING: "false", ...(windows === undefined ? {} : { CHANGES_WINDOWS: windows }), }; const result = spawnSync("bash", ["-c", `set -u\n${slice}\necho "WIN=$(expected_for platform-windows)"\necho "CTL=$(expected_for macos-control)"`], { diff --git a/tests/ci-workflows/ci-scope-reduction.test.ts b/tests/ci-workflows/ci-scope-reduction.test.ts index 37380ea2bb2..8c20565a493 100644 --- a/tests/ci-workflows/ci-scope-reduction.test.ts +++ b/tests/ci-workflows/ci-scope-reduction.test.ts @@ -245,6 +245,8 @@ type GateInputs = { docs: string; structure: string; lane: string; + docker?: string; + keyring?: string; }; const PR_WITHOUT_NATIVE: GateInputs = { @@ -255,6 +257,8 @@ const PR_WITHOUT_NATIVE: GateInputs = { docs: "false", structure: "false", lane: "", + docker: "true", + keyring: "true", }; function gateEnvironment(inputs: GateInputs, results: Record): Record { @@ -266,6 +270,8 @@ function gateEnvironment(inputs: GateInputs, results: Record): R CHANGES_PACKAGING: inputs.packaging, CHANGES_DOCS: inputs.docs, CHANGES_STRUCTURE: inputs.structure, + CHANGES_DOCKER: inputs.docker ?? "false", + CHANGES_KEYRING: inputs.keyring ?? "false", LANE: inputs.lane, // `needs` serializes as an object per job, and the gate reads // `.value.result` out of it. Passing a flat name-to-string map makes jq @@ -504,4 +510,33 @@ describe("the aggregate gate", () => { expect(withPackaging.expectations["npm-global-smoke"]).toBe("requested"); expect(withPackaging.expectations["keyring-smoke"]).toBe("requested"); }); + + test.skipIf(cannotRunGate)("drops the smoke jobs from a pull request that misses their filters, and only there", () => { + const narrow = { ...PR_WITHOUT_NATIVE, docker: "false", keyring: "false" }; + const { expectations } = runGate(narrow, {}); + expect(expectations["docker-smoke"]).toBe("not-requested"); + expect(expectations["keyring-smoke"]).toBe("not-requested"); + for (const name of ["test", "storage-policy", "api-usage", "gates"]) { + expect(`${name}:${expectations[name]}`).toBe(`${name}:requested`); + } + // Each filter selects only its own job. + const dockerOnly = runGate({ ...narrow, docker: "true" }, {}).expectations; + expect([dockerOnly["docker-smoke"], dockerOnly["keyring-smoke"]]).toEqual(["requested", "not-requested"]); + const keyringOnly = runGate({ ...narrow, keyring: "true" }, {}).expectations; + expect([keyringOnly["docker-smoke"], keyringOnly["keyring-smoke"]]).toEqual(["not-requested", "requested"]); + // An out-of-scope pull request stays out of scope even when a smoke filter matched. + const outOfScope = runGate({ ...narrow, ci: "false", docker: "true", keyring: "true" }, {}).expectations; + expect([outOfScope["docker-smoke"], outOfScope["keyring-smoke"]]).toEqual(["not-requested", "not-requested"]); + // Push, dispatch and schedule ignore the smoke filters: the release gate reads the push run. + for (const eventName of ["push", "workflow_dispatch", "schedule"]) { + const other = runGate({ ...narrow, eventName }, {}).expectations; + expect(`${eventName}:${other["docker-smoke"]}:${other["keyring-smoke"]}`).toBe(`${eventName}:requested:requested`); + } + // Results consistent with the narrowed expectations pass; a smoke job that ran anyway fails by name. + const consistent = Object.fromEntries(Object.keys(jobs) + .filter(name => name !== "ci") + .map(name => [name, expectations[name] === "requested" ? "success" : "skipped"])); + expect(runGate(narrow, consistent).bad).toBe(""); + expect(runGate(narrow, { ...consistent, "docker-smoke": "success" }).bad).toContain("docker-smoke"); + }); }); diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index eaea2720078..c73498ce2b2 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -5253,6 +5253,15 @@ describe("GitHub Actions hardening", () => { test("React Doctor workflow is SHA-pinned, engine-pinned, gating, and read-only", async () => { const workflow = await readText(".github/workflows/react-doctor.yml"); + // Pull requests scan only when gui/ (the scanned directory) or this workflow changes; + // the main push trend scan stays unfiltered. + const triggers = (Bun.YAML.parse(workflow) as { + on?: { pull_request?: { types?: string[]; paths?: string[] }; push?: { branches?: string[]; paths?: string[] } }; + }).on; + expect(triggers?.pull_request?.types).toEqual(["opened", "synchronize", "reopened", "ready_for_review"]); + expect(triggers?.pull_request?.paths).toEqual(["gui/**", ".github/workflows/react-doctor.yml"]); + expect(workflow).toContain("directory: gui"); + expect(triggers?.push).toEqual({ branches: ["main"] }); expect(workflow).toContain("actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8"); expect(workflow).toContain("millionco/react-doctor@01820bb4fd4d0a4aebcd8df2b2a143a098649cb2"); From d246c29655bd9dc24714fe14a8ef8f4351d846c1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 11:50:14 +0900 Subject: [PATCH 2/2] ci: include the compatibility-version module closure in the docker filter --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16d59df9856..63343e5f7fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -408,6 +408,12 @@ jobs: - 'scripts/ci/docker-smoke.ts' - 'scripts/generate-compatibility-version.ts' - 'scripts/model-metadata.source.json' + # The image's final RUN imports version.ts to prove the generated compatibility + # manifest; these four files are its whole module closure (Bun.build metafile). + - 'src/routing/compatibility/version.ts' + - 'src/lab/digest.ts' + - 'src/lab/conformance/digest.ts' + - 'src/lab/conformance/jcs.ts' - 'src/cli/index.ts' - 'package.json' - 'bun.lock'