From 3af8f847f0e35843f51622a136165dd6ee12cdd0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:38:38 +0800 Subject: [PATCH] ci: fix PR metadata triggers and defer expensive runner work --- .github/workflows/ci-full-pr.yml | 18 ++++ .github/workflows/ci.yml | 59 +++++------ .github/workflows/pr-title.yml | 29 ++++++ docs/testing.md | 25 ++++- scripts/ci/plan.mjs | 2 + tests/boundary/filesystem/ciPrEvents.test.ts | 98 +++++++++++++++++++ .../filesystem/ciRequiredLanes.test.ts | 21 ++++ 7 files changed, 211 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/ci-full-pr.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 tests/boundary/filesystem/ciPrEvents.test.ts diff --git a/.github/workflows/ci-full-pr.yml b/.github/workflows/ci-full-pr.yml new file mode 100644 index 000000000..8f76a2ea2 --- /dev/null +++ b/.github/workflows/ci-full-pr.yml @@ -0,0 +1,18 @@ +name: Full PR qualification + +on: + pull_request: + branches: [main, "release/*"] + types: [labeled] + +permissions: + contents: read + actions: read + +jobs: + qualification: + name: Qualify labeled PR revision + if: github.event.label.name == 'ci:full' + uses: ./.github/workflows/ci.yml + with: + source_sha: ${{ github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a9977f0d..cf5cd0a11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ on: workflow_call: inputs: source_sha: - description: Exact release source; must match the caller revision. + description: Exact qualification source; must match the caller revision. required: true type: string @@ -22,7 +22,7 @@ permissions: concurrency: # Complete qualification finishes even while main receives more pushes. # Scheduled/manual main runs coalesce to one active and the latest pending; - # release qualification has its own exact-revision group. + # release and label-triggered qualification use exact-revision groups. group: ci-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ inputs.source_sha || github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }} @@ -30,22 +30,6 @@ env: HUSKY: "0" jobs: - pr-title: - name: Conventional PR title - if: github.event_name == 'pull_request' && inputs.source_sha == '' - runs-on: ubuntu-latest - steps: - - name: Validate title for Release Please - env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: >- - node -e 'const title = process.env.PR_TITLE ?? ""; - const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u; - if (!conventional.test(title)) { - console.error("PR title must use Conventional Commits, for example: feat: add historical source import"); - process.exit(1); - }' - changes: name: Classify changes runs-on: ubuntu-latest @@ -83,7 +67,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: .nvmrc - - name: Bind release qualification to the caller revision + - name: Bind qualification to the caller revision if: inputs.source_sha != '' env: SOURCE_SHA: ${{ inputs.source_sha }} @@ -262,7 +246,9 @@ jobs: retention-days: 7 sqlite-runtime: - needs: [changes, build] + # Preserve all selected coverage, but admit expensive host/provider work + # only after static validation. Portable tests still provide early feedback. + needs: [changes, static, build] if: needs.changes.outputs.sqlite == 'true' uses: ./.github/workflows/sqlite-runtime.yml @@ -350,7 +336,7 @@ jobs: conformance-fixtures: name: Source-built conformance fixtures - needs: changes + needs: [changes, static] if: needs.changes.outputs.fixtures == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -361,7 +347,7 @@ jobs: windows-native-build: name: Build bundled Windows x64 native controls - needs: changes + needs: [changes, static] if: needs.changes.outputs.runtime == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -397,7 +383,7 @@ jobs: package-e2e: name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }}) - needs: [changes, build, windows-native-build] + needs: [changes, static, build, windows-native-build] if: needs.changes.outputs.runtime == 'true' strategy: fail-fast: false @@ -463,7 +449,7 @@ jobs: apple-artifacts: name: Real Apple artifact CLI/MCP E2E - needs: [changes, build] + needs: [changes, static, build] if: needs.changes.outputs.apple == 'true' runs-on: macos-15 timeout-minutes: 15 @@ -511,7 +497,7 @@ jobs: reconstruction-readiness: name: Reconstruction readiness - needs: changes + needs: [changes, static] if: needs.changes.outputs.readiness == 'true' runs-on: ubuntu-latest timeout-minutes: 15 @@ -540,7 +526,7 @@ jobs: windows-curated: name: Windows capability and package lane - needs: [changes, build, windows-native-build] + needs: [changes, static, build, windows-native-build] if: needs.changes.outputs.package == 'true' runs-on: windows-latest timeout-minutes: 25 @@ -672,52 +658,52 @@ jobs: if-no-files-found: error browser: - needs: changes + needs: [changes, static] if: needs.changes.outputs.browser == 'true' uses: ./.github/workflows/real-browser.yml binary_layout: - needs: changes + needs: [changes, static] if: needs.changes.outputs.binary_layout == 'true' uses: ./.github/workflows/real-binary-layout.yml evm: - needs: changes + needs: [changes, static] if: needs.changes.outputs.evm == 'true' uses: ./.github/workflows/real-evm-interface.yml javascript_recovery: - needs: changes + needs: [changes, static] if: needs.changes.outputs.javascript_recovery == 'true' uses: ./.github/workflows/real-javascript-recovery.yml recorded_crash: - needs: changes + needs: [changes, static] if: needs.changes.outputs.recorded_crash == 'true' uses: ./.github/workflows/real-recorded-crash.yml web_network: - needs: changes + needs: [changes, static] if: needs.changes.outputs.web_network == 'true' uses: ./.github/workflows/real-web-network-captures.yml web_runtime: - needs: changes + needs: [changes, static] if: needs.changes.outputs.web_runtime == 'true' uses: ./.github/workflows/real-web-runtime.yml web_source_map: - needs: changes + needs: [changes, static] if: needs.changes.outputs.web_source_map == 'true' uses: ./.github/workflows/real-web-source-map.yml nativeaot: - needs: changes + needs: [changes, static] if: needs.changes.outputs.nativeaot == 'true' uses: ./.github/workflows/nativeaot-fixtures.yml website: - needs: changes + needs: [changes, static] if: needs.changes.outputs.website == 'true' uses: ./.github/workflows/website-check.yml @@ -727,7 +713,6 @@ jobs: needs: [ changes, - pr-title, static, build, test-shard, diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 000000000..abe98bb48 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,29 @@ +name: PR title + +on: + pull_request: + branches: [main, "release/*"] + types: [opened, synchronize, reopened, edited] + +permissions: {} + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pr-title: + name: Conventional PR title + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Validate title for Release Please + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: >- + node -e 'const title = process.env.PR_TITLE ?? ""; + const conventional = /^(?:feat|fix|docs|refactor|perf|test|build|ci|chore|revert)(?:\([a-z0-9._/-]+\))?!?: .+/u; + if (!conventional.test(title)) { + console.error("PR title must use Conventional Commits, for example: feat: add historical source import"); + process.exit(1); + }' diff --git a/docs/testing.md b/docs/testing.md index 5c43a5c78..cf1b8febc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -276,6 +276,15 @@ sharding. Static checks, documentation, build, package verification, and real-system lanes remain separate jobs so one kind of evidence cannot stand in for another. Workflow syntax and expression validation runs inside the required static-check job when workflow changes select it, avoiding a separate runner. +Native, provider, fixture, SQLite compatibility, and website lanes wait for +static checks to succeed before allocating their runners. This avoids spending +those runner slots on revisions that already fail static validation. Portable +test shards and documentation checks remain independent of static results, so +they can still provide diagnostic feedback. The required gate rejects skipped +selected lanes when static checks fail; passing revisions retain all selected +coverage. This ordering reduces peak demand on invalid revisions but can add +static-check waiting to otherwise passing runs; it is not a measured reduction +in end-to-end latency. The separate `CI timings` workflow batches recent completed CI and Release runs hourly or on manual dispatch. It uploads `ci-job-timings-batch` with raw API pages and @@ -841,9 +850,12 @@ observation. Manual dispatch, `ci:full`, and exact release qualification always run full validation. Full qualification includes aggregate coverage, -every portable provider lane, and all native hosts below. A `ci:full` PR label -selects full qualification on the next PR run; manual dispatch can request it -immediately. Fast-tier broad fallbacks retain all owner lanes and the three-host +every portable provider lane, and all native hosts below. Adding `ci:full` +immediately starts the separate `Full PR qualification` workflow against the +label event's merge revision; subsequent PR revisions also select the full tier +in normal CI while that label remains. Unrelated labels do not start validation +jobs or replace the main CI checks. Manual dispatch also requests full validation. +Fast-tier broad fallbacks retain all owner lanes and the three-host package matrix; Intel macOS and coverage remain full-tier obligations. Each classification job records its selection and broad-fallback reasons in the run summary. Representative Git-diff cases live in @@ -851,7 +863,12 @@ summary. Representative Git-diff cases live in `CI required` always runs and checks classification and every selected lane, rejecting failed, cancelled, or unexpectedly skipped work. Branch protection -should require this aggregate, `Static checks`, and `Portable tests`. Fast runs +should require this aggregate, `Static checks`, `Portable tests`, and +`Conventional PR title`. Title validation runs separately on PR opening, +reopening, commits, and edits; correcting a title refreshes only this lightweight +check. It reads title text through an environment variable, executes no PR code, +and has no token permissions. Its separate concurrency group cannot cancel +test runs. Fast runs cancel superseded feedback for the same PR or main branch. Scheduled/manual qualification has a separate concurrency group: active work finishes and only the latest pending run is retained. Release qualification is grouped by its diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index e9505b903..9384d9b05 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -8,6 +8,8 @@ const HOST_TEST_OWNERS = ["apple", "package", "sqlite"]; // Actionlint validates workflow syntax only. The semantic suite asserts these // workflows' release, publication and self-hosted runner safeguards. const GUARDED_WORKFLOWS = new Set([ + ".github/workflows/pr-title.yml", + ".github/workflows/ci-full-pr.yml", ".github/workflows/release.yml", ".github/workflows/real-ghidra-windows.yml", ".github/workflows/sqlite-runtime.yml", diff --git a/tests/boundary/filesystem/ciPrEvents.test.ts b/tests/boundary/filesystem/ciPrEvents.test.ts new file mode 100644 index 000000000..3562b5df9 --- /dev/null +++ b/tests/boundary/filesystem/ciPrEvents.test.ts @@ -0,0 +1,98 @@ +import { readFile } from "node:fs/promises"; +import { expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; +import { executeWorkflowFixture } from "../../support/workflowGit.js"; + +async function workflow(name: string) { + return z + .object({ + on: z.record(z.string(), z.unknown()), + permissions: z.record(z.string(), z.string()), + concurrency: z.object({ group: z.string() }).optional(), + jobs: z.record( + z.string(), + z.object({ + name: z.string().optional(), + if: z.string().optional(), + uses: z.string().optional(), + with: z.record(z.string(), z.string()).optional(), + steps: z + .array( + z.object({ + run: z.string().optional(), + uses: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + }), + ) + .optional(), + }), + ), + }) + .parse( + parse( + await readFile( + new URL(`../../../.github/workflows/${name}`, import.meta.url), + "utf8", + ), + ), + ); +} + +it("refreshes title validation on edits without checking out PR code or cancelling CI", async () => { + const title = await workflow("pr-title.yml"); + expect(title.on.pull_request).toMatchObject({ + types: ["opened", "synchronize", "reopened", "edited"], + }); + expect(title.permissions).toEqual({}); + expect(title.concurrency?.group).not.toBe( + (await workflow("ci.yml")).concurrency?.group, + ); + const jobs = Object.values(title.jobs); + expect(jobs).toHaveLength(1); + expect(jobs[0]?.name).toBe("Conventional PR title"); + expect(jobs[0]?.if).toBeUndefined(); // A skipped check must not green-light an invalid title on body edits. + const steps = jobs[0]?.steps ?? []; + expect(steps).toHaveLength(1); + expect(steps[0]?.uses).toBeUndefined(); + expect(steps[0]?.env).toEqual({ + PR_TITLE: "${{ github.event.pull_request.title }}", + }); + const command = z.string().parse(steps[0]?.run); + for (const title of [ + "fix(ci): repair workflow", + "feat!: changed contract", + "docs: literal $(exit 91) `exit 92` text", + ]) { + await expect( + executeWorkflowFixture("bash", ["-c", command], { + env: { ...process.env, PR_TITLE: title }, + }), + ).resolves.toMatchObject({ stderr: "" }); + } + for (const title of ["", "Fix CI", "feat: "]) { + await expect( + executeWorkflowFixture("bash", ["-c", command], { + env: { ...process.env, PR_TITLE: title }, + }), + ).rejects.toMatchObject({ code: 1 }); + } + expect((await workflow("ci.yml")).jobs["pr-title"]).toBeUndefined(); +}); + +it("qualifies only ci:full label additions at the caller's merge revision", async () => { + const full = await workflow("ci-full-pr.yml"); + expect(full.on.pull_request).toMatchObject({ types: ["labeled"] }); + expect(full.permissions).toEqual({ contents: "read", actions: "read" }); + expect(Object.values(full.jobs)).toEqual([ + { + name: "Qualify labeled PR revision", + if: "github.event.label.name == 'ci:full'", + uses: "./.github/workflows/ci.yml", + with: { source_sha: "${{ github.sha }}" }, + }, + ]); + // Metadata events must not create skipped replacements for the main CI checks. + expect(full.jobs.required).toBeUndefined(); + expect(full.concurrency).toBeUndefined(); +}); diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts index bc2e5eb06..b3388cf80 100644 --- a/tests/boundary/filesystem/ciRequiredLanes.test.ts +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -144,6 +144,27 @@ it("makes the required gate wait for every CI lane, including future additions", ); }); +it("holds native and provider fan-out behind static validation while portable feedback stays independent", async () => { + const { jobs } = await workflow(); + const earlyJobs = new Set([ + "changes", + "static", + "build", + "test-shard", + "test", + "docs", + "required", + ]); + for (const [name, job] of Object.entries(jobs)) { + if (earlyJobs.has(name)) continue; + expect(job.needs, `${name} must wait for static validation`).toContain( + "static", + ); + expect(job.if).not.toMatch(/always\(|cancelled\(/u); + } + expect(jobs["test-shard"]?.needs).not.toContain("static"); +}); + it("runs workflow validation inside required static checks when selected", async () => { const { jobs } = await workflow(); const steps = z