Files
Michał Pierzchała ccf64f6797 ci: move parked device replay suites to a dispatch-only workflow (#1781 A1) (#1794)
* ci: move parked device replay suites to a dispatch-only workflow (#1781 A1)

Both full-tier device jobs have failed every scheduled run since 2026-07-24: the
Android suite inside full-tier scenarios that had never executed end to end, the
iOS suite on varying steps. They move to .github/workflows/replays-manual.yml,
which has no `schedule:`, so the schedule stops emitting a guaranteed failure while
the suites stay runnable on demand.

A job-level `if: github.event_name == 'workflow_dispatch'` would have looked the
same and lied: `workflowLanes()` decides `qualifying` per workflow FILE and never
reads job-level `if:`, so the manifest kept reporting replay-android, replay-ios,
and replay-ios-device as scheduled-lane owners — the silent-owner-loss failure the
manifest exists to catch. A separate file is what the file-level model already
reads correctly.

Those three checks now have no pull_request/schedule owner, so they are declared as
MANUAL_ONLY_OWNERS rather than folded into UNPROVABLE_OWNERS, whose claim ("it runs,
this loader cannot see it") is no longer true for replay-android. check:gate-manifest
drops from 48 to 46 wired checks and names the three on every run. Two tests pin it:
a dispatch-only lane is non-qualifying however many gates it declares, and every
manual-only declaration must name a registered check that no qualifying lane owns, so
a re-scheduled lane cannot keep a stale exemption.

* ci: attest manual-only checks against their dispatch lane (#1781 A1)

Review P1: MANUAL_ONLY_OWNERS was a negative allowlist — it proved each entry named a
registered check no qualifying lane owned, but nothing tied the entry to a lane that can
still run it. Deleting a parked job, or its run-gate step, would have left the manifest
green and still printing the check as manual-only: parked coverage silently becoming
deleted coverage.

Each entry now names its dispatch lane, and a new 'manual-only' audit assertion resolves
that name against the derived model: the lane must exist, must still be dispatch-only, and
must still declare the gate. replay-android carries an explicit `opaque` flag because its
gate sits inside the third-party emulator action's `script:` (#1429), so the job's
existence is the whole attestation the model can make — and the flag says so rather than
letting an unreadable lane look like a declaring one.

Four regressions pin both directions: deleting a declaration reports the check as unowned;
deleting the parked job fails with 'no workflow defines'; re-scheduling the lane fails
until the entry is dropped; and a parked lane that loses its run-gate step fails unless the
entry is opaque.

* ci: make manual-only mean dispatch-only, not merely non-qualifying (#1781 A1)

Review follow-up: the attestation checked `qualifying === false`, which is true of any lane
that is not pull_request/schedule. Swapping `workflow_dispatch` for `push` in
replays-manual.yml would have kept the audit green and the checks printed as manual-only,
while the runs nobody starts by hand quietly started themselves on every push.

The lane model now keeps the trigger names instead of collapsing them into that one bit, and
the manual-only assertion requires `workflow_dispatch` and nothing else. Three planted
regressions cover the gap the review named: a parked lane re-triggered by `push` fails, a
parked lane with no trigger at all fails, and the loader test pins that trigger kinds survive
into the model (a push lane reads `[push]`, the nightly reads `[schedule, workflow_dispatch]`).
2026-08-18 09:59:34 +02:00

193 lines
6.9 KiB
TypeScript

// Structural ownership regressions: only the canonical action can declare a gate.
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { CHECK_CATALOG } from '../check-affected/checks.ts';
import { audit, formatFailures } from './audit.ts';
import { MANUAL_ONLY_OWNERS } from './declarations.ts';
import { covered, loadModel, type Model } from './model.ts';
import { loadLanes } from './workflows.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const tracked = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' })
.split('\n')
.filter(Boolean);
const base = loadModel(repoRoot, tracked);
function plant(yaml: string): Model {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-lane-'));
try {
fs.writeFileSync(path.join(dir, 'planted.yml'), yaml);
return { ...base, lanes: [...base.lanes, ...loadLanes(dir, repoRoot, base.scripts)] };
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const workflow = (step: string) => `name: Planted
on:
pull_request:
jobs:
planted:
steps:
${step}`;
test('the live tree is green', () => {
assert.deepEqual(audit(base), []);
});
test('a structural gate id must exist in CHECK_CATALOG', () => {
const model = plant(
workflow(` - uses: ./.github/actions/run-gate
with:
gate: not-a-real-check`),
);
assert.ok(audit(model).some((failure) => /names no registered check/.test(failure.message)));
});
test('raw shell text cannot declare ownership', () => {
const model = plant(
workflow(` - run: |
pnpm gate not-a-real-check
echo "pnpm gate layering"`),
);
assert.ok(!audit(model).some((failure) => /not-a-real-check/.test(failure.message)));
});
test('reusable workflows fail closed because their action declarations are hidden', () => {
const model = plant(`name: Planted
on:
pull_request:
jobs:
planted:
uses: ./.github/workflows/reusable.yml`);
assert.ok(
audit(model).some((failure) => /runs steps this loader never opens/.test(failure.message)),
);
});
// The exemption is what keeps the tree green while these lanes are parked, so nothing else
// would notice it going stale: a re-scheduled lane would silently keep its "nothing runs this"
// declaration, and the next parked check would be waved through under a name that no longer
// describes it.
test('every manual-only declaration names a registered check no qualifying lane owns', () => {
for (const id of Object.keys(MANUAL_ONLY_OWNERS)) {
const spec = CHECK_CATALOG.find((entry) => entry.id === id);
assert.ok(spec, `manual-only declaration "${id}" names no registered check`);
assert.equal(
covered(spec, null, base).covered,
false,
`"${id}" is owned by a pull_request/schedule lane again — drop its MANUAL_ONLY_OWNERS entry`,
);
}
});
/** The live model with the parked dispatch lanes rewritten, to age the declaration on purpose. */
function withManualLanes(rewrite: (lane: Model['lanes'][number]) => Model['lanes'][number] | null) {
const parked = new Set(Object.values(MANUAL_ONLY_OWNERS).map((owner) => owner.lane));
return {
...base,
lanes: base.lanes.flatMap((lane) => {
if (!parked.has(lane.label)) return [lane];
const rewritten = rewrite(lane);
return rewritten ? [rewritten] : [];
}),
};
}
test('deleting a manual-only declaration reports its check as unowned', () => {
const failures = audit(base, { manualOnly: {}, unprovable: {} });
for (const id of Object.keys(MANUAL_ONLY_OWNERS)) {
assert.ok(
failures.some(
(failure) => failure.assertion === 'owned' && failure.message.includes(`"${id}"`),
),
`dropping the declaration for "${id}" must surface it as unowned, not as wired`,
);
}
});
// The failure this record exists to prevent: parked coverage quietly becoming deleted coverage.
test('deleting a parked job fails instead of reading as still parked', () => {
const failures = audit(withManualLanes(() => null));
for (const id of Object.keys(MANUAL_ONLY_OWNERS)) {
assert.ok(
failures.some(
(failure) =>
failure.assertion === 'manual-only' &&
failure.message.includes(`"${id}"`) &&
failure.message.includes('no workflow defines'),
),
`deleting the lane "${MANUAL_ONLY_OWNERS[id]?.lane}" must fail the manifest for "${id}"`,
);
}
});
// `qualifying` alone cannot carry this: swapping `workflow_dispatch` for `push` keeps the lane
// non-qualifying, so without the trigger kinds the audit would stay green while the run nobody
// starts by hand became a run nobody starts by hand *or* reviews.
test('a parked lane re-triggered by push fails even though push does not qualify', () => {
const failures = audit(
withManualLanes((lane) => ({ ...lane, qualifying: false, triggers: ['push'] })),
);
assert.ok(
failures.some(
(failure) =>
failure.assertion === 'manual-only' && failure.message.includes('is triggered by push'),
),
'replacing workflow_dispatch with another non-qualifying trigger must fail',
);
});
test('a parked lane that loses workflow_dispatch entirely fails', () => {
const failures = audit(withManualLanes((lane) => ({ ...lane, triggers: [] })));
assert.ok(
failures.some(
(failure) =>
failure.assertion === 'manual-only' && failure.message.includes('no trigger at all'),
),
);
});
test('a parked lane back on a schedule fails until its declaration is deleted', () => {
const failures = audit(withManualLanes((lane) => ({ ...lane, qualifying: true })));
assert.ok(
failures.some(
(failure) =>
failure.assertion === 'manual-only' &&
/runs on pull_request\/schedule again/.test(failure.message),
),
);
});
// Android is the reason `opaque` exists: its gate sits inside a third-party action's `script:`,
// so the lane's own steps can never show it and the job's existence is the whole attestation.
test('a parked lane that stops declaring its gate fails unless the gate is opaque', () => {
const failures = audit(withManualLanes((lane) => ({ ...lane, gates: [] })));
assert.ok(
failures.some(
(failure) =>
failure.assertion === 'manual-only' && failure.message.includes('no longer declares gate'),
),
'a visible parked lane losing its run-gate step must fail',
);
assert.ok(
!failures.some((failure) => failure.message.includes('gate "replay-android"')),
'the opaque Android entry cannot assert a step the loader never reads',
);
});
test('unknown assertion kinds remain visible in the report', () => {
const report = formatFailures([
{ assertion: 'owned', message: 'a' },
{ assertion: 'new-kind', message: 'b' },
]);
assert.match(report, /Registered checks no lane declares:/);
assert.match(report, /Other failures \(new-kind\):/);
assert.match(report, /2 failure\(s\)/);
});