Files
callstack__agent-device/scripts/gate/routing.test.ts
Michał Pierzchała 393eb30a28 ci: give check:affected real Apple ownership rules and route ios.yml on them (#1781 A9-2) (#1857)
* ci: give check:affected real Apple ownership rules and route ios.yml on them (#1781 A9-2)

Device-lane ownership by platform family in the affected selector
(scripts/check-affected/device-lanes.ts): a TypeScript-only Apple change now
carries replay-ios/replay-ios-device/replay-macos in a narrow plan, other
families own only their own lanes, shared runtime surface owns every lane,
unit tests own none. Golden tables (contracts/fixtures) own the parity unit
test and both runner builds instead of failing open.

ios.yml pull_request paths-ignore is routed on that ownership; the gate
manifest asserts the list against the selector over every tracked path both
ways (scripts/gate/routing.ts, ROUTED_LANES). push to main is unfiltered.
Path coverage exempts declared manual-only checks the way owned does.

* ci: tighten routing assertion shape (fallow: unused exports, complexity)

* ci: name parked checks in check:affected --run skips

* ci: bound the routed-lane exemption to sibling workflows (review of #1857)

The exact-name .github exemption was unbounded: naming the lane's own
setup-apple-runner-build or boot-ios-test-simulator action skipped the lane
that runs them and the manifest stayed green. Lane now carries the transitive
composite-action closure plus its own workflow file (Lane.uses, same walk
declaredGates does), and the exemption refuses anything in it.

Also: an unowned path under an ignored root (a non-TS fixture under a family
root) asked for the ignore entry to be removed, which would un-route every
sibling in that tree; it now asks for a selector owner. Both cases pinned,
both proven red against the pre-fix code. Documents GitHub's 300-changed-file
path-filter limit in docs/agents/testing.md.

* ci: close the routed-lane exemption over composite-action support files

Lane.uses recorded only each composite action's action.yml, so a support file
the descriptor executes was exemptible as if it were an unrelated sibling
workflow: ios.yml uses setup-fixture-app, whose action.yml runs
"$GITHUB_ACTION_PATH/fetch-artifact.sh", and that script runs its siblings
resolve-artifact-name.sh and trusted-artifact.mjs — references that exist only
inside shell, one level past anything YAML parsing sees.

The closure unit is the action's directory now. It needs no shell model and
cannot miss a file however deep the reference chain runs; the coarseness is
harmless because a file in an action's own directory belongs to that action.
All three files pinned, red against the descriptor-only closure.
2026-08-19 17:35:23 +02:00

183 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Routed-lane witnesses (#1781 A9-2): the live ios.yml agrees with the selector, and each
// planted disagreement below is reported — an ignore that hides an Apple path or a tooling
// glob, and a missing ignore for a tree the selector says is another family's.
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { ROUTED_LANES } from './declarations.ts';
import { loadModel, type Model } from './model.ts';
import { routing } from './routing.ts';
import type { Lane } 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);
const IOS = ROUTED_LANES.find((routed) => routed.lane === 'iOS / Smoke Tests');
assert.ok(IOS, 'the iOS lane is declared routed');
const iosLane = base.lanes.find((lane) => lane.label === IOS.lane);
assert.ok(iosLane, 'ios.yml defines the routed lane');
function withIos(change: (lane: Lane) => Lane): Model {
return {
...base,
lanes: base.lanes.map((lane) => (lane.label === IOS.lane ? change(lane) : lane)),
};
}
const messages = (model: Model) => routing(model, ROUTED_LANES).map((failure) => failure.message);
test('the live ios.yml paths-ignore agrees with the selector over every tracked path', () => {
assert.deepEqual(messages(base), []);
});
test('the routed lane derives its needs from its declared gate plus the sampled checks', () => {
assert.ok(iosLane.gates.includes('swift-runner-ios'), 'ios.yml still declares the runner build');
assert.deepEqual([...IOS.sampled], ['replay-ios', 'replay-ios-device']);
});
test('ignoring an Apple-owned tree is reported with the checks the selector routes it to', () => {
const model = withIos((lane) => ({
...lane,
pathsIgnore: [...lane.pathsIgnore, 'src/platforms/apple/**'],
}));
const found = messages(model);
assert.ok(found.length > 0);
assert.ok(
found.some(
(message) =>
/ignores src\/platforms\/apple\//.test(message) &&
/routes it to "replay-ios"/.test(message),
),
found.slice(0, 3).join('\n'),
);
});
test('ignoring a tooling glob is reported as fail-open, even under .github/', () => {
const model = withIos((lane) => ({
...lane,
pathsIgnore: [...lane.pathsIgnore, '.github/actions/**', 'package.json'],
}));
const found = messages(model);
assert.ok(
found.some(
(message) =>
/ignores \.github\/actions\/setup-apple-runner-build\/action\.yml/.test(message) &&
/fails open on it \(workflow-tooling\)/.test(message),
),
found.slice(0, 3).join('\n'),
);
assert.ok(
found.some((message) => /ignores package\.json, but the selector fails open/.test(message)),
);
});
test('a .github path ignored by exact name stays the workflow’s own call', () => {
// The live list names deploy.yml and the docs preview workflows explicitly; the live tree
// is green above, so this only pins that the exemption is by exact path, not by prefix.
const model = withIos((lane) => ({
...lane,
pathsIgnore: [...lane.pathsIgnore, '.github/workflows/size.yml'],
}));
assert.deepEqual(messages(model), []);
});
// Review finding on #1857: the exact-name exemption was unbounded, so naming the lane's OWN
// build and boot actions skipped the lane that runs them and the manifest stayed green. The
// exemption now stops at `lane.uses` — the transitive composite-action closure plus the
// workflow file — and these are the reviewer's two planted cases.
test('the exemption cannot name an action the lane itself runs', () => {
for (const own of [
'.github/actions/setup-apple-runner-build/action.yml',
'.github/actions/boot-ios-test-simulator/action.yml',
'.github/actions/run-gate/action.yml', // reached only through a composite action
'.github/workflows/ios.yml', // the lane's own definition
]) {
assert.ok(iosLane.uses.includes(own), `${own} must be in the lane's uses closure`);
const model = withIos((lane) => ({ ...lane, pathsIgnore: [...lane.pathsIgnore, own] }));
assert.ok(
messages(model).some(
(message) => message.includes(`ignores ${own}`) && /fails open/.test(message),
),
`naming ${own} exactly must still fail the routing assertion`,
);
}
});
// Second review finding on #1857, one level past the first: `Lane.uses` recorded only the
// composite actions' `action.yml` descriptors, so a support file the descriptor *executes*
// (`bash "$GITHUB_ACTION_PATH/fetch-artifact.sh"`) was exemptible as if it were an unrelated
// sibling workflow. The closure is the action's directory now, which also covers the two files
// referenced no closer than inside that shell script.
test('the exemption cannot name a support file of an action the lane runs', () => {
const support = [
'.github/actions/setup-fixture-app/fetch-artifact.sh', // named by the action.yml
'.github/actions/setup-fixture-app/resolve-artifact-name.sh', // named only inside that script
'.github/actions/setup-fixture-app/trusted-artifact.mjs', // likewise
];
for (const file of support) {
assert.ok(iosLane.uses.includes(file), `${file} must be in the lane's uses closure`);
const model = withIos((lane) => ({ ...lane, pathsIgnore: [...lane.pathsIgnore, file] }));
assert.ok(
messages(model).some(
(message) => message.includes(`ignores ${file}`) && /fails open/.test(message),
),
`naming ${file} exactly must still fail the routing assertion`,
);
}
});
// The trap fires correctly for a tracked non-TS file under an ignored family root, but the
// remedy is not "remove the ignore entry" — that would un-route every sibling `.ts` in the
// tree. The selector gap is the fix, and the message has to say so.
test('an unowned path under an ignored root asks for an owner, not for the entry’s removal', () => {
const planted = 'src/platforms/android/probe-fixture.json';
const model = {
...base,
trackedFiles: new Set([...base.trackedFiles, planted]),
};
const found = routing(model, ROUTED_LANES).map((failure) => failure.message);
assert.equal(found.length, 1, found.join('\n'));
assert.match(found[0] ?? '', /fails open on it \(ambiguous-path\)/);
assert.match(found[0] ?? '', /Give it an owning check in scripts\/check-affected\//);
assert.ok(!/Remove the ignore entry/.test(found[0] ?? ''));
});
test('dropping a family root from the ignore list fails the routing claim for that tree', () => {
const model = withIos((lane) => ({
...lane,
pathsIgnore: lane.pathsIgnore.filter((pattern) => pattern !== 'src/platforms/android/**'),
}));
const found = messages(model);
assert.ok(
found.some(
(message) =>
/starts on src\/platforms\/android\//.test(message) &&
/android-owned \(lanes: replay-android\)/.test(message),
),
found.slice(0, 3).join('\n'),
);
});
test('dropping the unit-test ignore fails the routing claim for a unit test', () => {
const model = withIos((lane) => ({
...lane,
pathsIgnore: lane.pathsIgnore.filter((pattern) => pattern !== 'src/**/*.test.ts'),
}));
assert.ok(
messages(model).some((message) =>
/\.test\.ts, which the selector classifies as a unit test/.test(message),
),
);
});
test('a routed lane that loses its pull_request trigger, or its job, is reported', () => {
const gone = { ...base, lanes: base.lanes.filter((lane) => lane.label !== IOS.lane) };
assert.ok(messages(gone).some((message) => /not defined by any workflow/.test(message)));
const pushOnly = withIos((lane) => ({ ...lane, triggers: ['push'] }));
assert.ok(messages(pushOnly).some((message) => /no pull_request trigger/.test(message)));
});