mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
393eb30a28
* 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.
183 lines
7.7 KiB
TypeScript
183 lines
7.7 KiB
TypeScript
// 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)));
|
||
});
|