ci: fix PR metadata triggers and defer expensive runner work

This commit is contained in:
morluto 2026-10-13 18:38:38 +08:00
parent 747e729914
commit 3af8f847f0
7 changed files with 211 additions and 41 deletions

18
.github/workflows/ci-full-pr.yml vendored Normal file
View File

@ -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 }}

View File

@ -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,

29
.github/workflows/pr-title.yml vendored Normal file
View File

@ -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);
}'

View File

@ -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

View File

@ -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",

View File

@ -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();
});

View File

@ -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