ci: fix PR metadata triggers and defer expensive runner work
This commit is contained in:
parent
747e729914
commit
3af8f847f0
|
|
@ -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 }}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue