mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
ccf64f6797
* 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]`).
197 lines
6.7 KiB
TypeScript
197 lines
6.7 KiB
TypeScript
// What the loader derives from the real workflow tree, plus the two structural cases the
|
|
// tree cannot show: a composite-action cycle, and an action whose source is not here.
|
|
|
|
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 { parse } from 'yaml';
|
|
import { loadModel } from './model.ts';
|
|
import { loadLanes, matchesGlob, verbatimScripts } 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 model = loadModel(repoRoot, tracked);
|
|
|
|
test('the canonical action binds and runs a structural gate without optional arguments', () => {
|
|
const action = parse(
|
|
fs.readFileSync(path.join(repoRoot, '.github/actions/run-gate/action.yml'), 'utf8'),
|
|
) as {
|
|
inputs: { gate: { required: boolean } };
|
|
runs: { steps: { env?: Record<string, string>; run?: string }[] };
|
|
};
|
|
assert.equal(action.inputs.gate.required, true);
|
|
const runner = action.runs.steps.at(-1);
|
|
assert.equal(runner?.env?.INPUT_GATE, '${{ inputs.gate }}');
|
|
assert.match(runner?.run ?? '', /pnpm gate "\$INPUT_GATE"/);
|
|
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'run-gate-'));
|
|
try {
|
|
const mockPnpm = path.join(root, 'pnpm');
|
|
fs.writeFileSync(mockPnpm, '#!/bin/bash\nprintf "%s\\n" "$@"\n');
|
|
fs.chmodSync(mockPnpm, 0o755);
|
|
const stdout = execFileSync('/bin/bash', ['-c', runner?.run ?? ''], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${root}:${process.env.PATH ?? ''}`,
|
|
INPUT_GATE: 'layering',
|
|
INPUT_ARGS: '',
|
|
GITHUB_OUTPUT: path.join(root, 'output'),
|
|
},
|
|
});
|
|
assert.equal(stdout, 'gate\nlayering\n');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
/** Write a workflow (and optionally a tree of actions) and load it with the real loader. */
|
|
function planted(files: Record<string, string>): ReturnType<typeof loadLanes> {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-wf-'));
|
|
try {
|
|
for (const [name, body] of Object.entries(files)) {
|
|
fs.mkdirSync(path.join(root, path.dirname(name)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, name), body);
|
|
}
|
|
return loadLanes(path.join(root, '.github/workflows'), root, model.scripts);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('a command repeating a script body verbatim credits that script', () => {
|
|
const body = model.scripts['check:package'] as string;
|
|
assert.deepEqual(verbatimScripts(body, model.scripts), ['check:package']);
|
|
assert.deepEqual(verbatimScripts('node scripts/something-else.ts', model.scripts), []);
|
|
});
|
|
|
|
test('path filters use GitHub glob semantics', () => {
|
|
assert.equal(matchesGlob('website/**', 'website/docs/docs/commands.md'), true);
|
|
assert.equal(matchesGlob('docs/**', 'website/docs/x.md'), false);
|
|
assert.equal(matchesGlob('*.md', 'README.md'), true);
|
|
assert.equal(matchesGlob('*.md', 'docs/README.md'), false, '* must not span a separator');
|
|
});
|
|
|
|
test('lanes carry the workflow spelling the catalog used, and only real triggers qualify', () => {
|
|
const labels = model.lanes.map((lane) => lane.label);
|
|
assert.ok(labels.includes('Coverage'), 'CI jobs are named bare');
|
|
assert.ok(labels.includes('iOS / Smoke Tests'), 'other workflows are prefixed');
|
|
const deploy = model.lanes.find((lane) => lane.workflow === 'deploy.yml');
|
|
assert.equal(deploy?.qualifying, false, 'a push-only lane gates nothing on the way in');
|
|
});
|
|
|
|
test('a workflow_dispatch-only lane owns nothing, however many gates it declares', () => {
|
|
const [lane] = planted({
|
|
'.github/workflows/planted.yml': `name: Planted
|
|
on:
|
|
workflow_dispatch:
|
|
jobs:
|
|
planted:
|
|
steps:
|
|
- uses: ./.github/actions/run-gate
|
|
with:
|
|
gate: replay-ios`,
|
|
});
|
|
assert.deepEqual(lane?.gates, ['replay-ios'], 'the gate is still read');
|
|
assert.equal(lane?.qualifying, false, 'but nothing dispatches itself, so it owns nothing');
|
|
assert.deepEqual(lane?.triggers, ['workflow_dispatch'], 'the trigger kind survives the model');
|
|
});
|
|
|
|
// `qualifying` collapses every trigger into one bit, and two very different lanes share the
|
|
// `false` side of it: one a human starts, one that starts itself on every push.
|
|
test('trigger kinds survive the model, not just whether they qualify', () => {
|
|
const [push] = planted({
|
|
'.github/workflows/planted.yml': `name: Planted
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
jobs:
|
|
planted:
|
|
steps:
|
|
- run: echo hi`,
|
|
});
|
|
assert.equal(push?.qualifying, false);
|
|
assert.deepEqual(push?.triggers, ['push']);
|
|
const nightly = model.lanes.find((lane) => lane.workflow === 'replays-nightly.yml');
|
|
assert.deepEqual(nightly?.triggers, ['schedule', 'workflow_dispatch']);
|
|
});
|
|
|
|
test('a gate invoked from inside a composite action belongs to the calling lane', () => {
|
|
const android = model.lanes.find((lane) => lane.label === 'Android / Smoke Tests');
|
|
assert.ok(
|
|
android?.gates.includes('android-helpers'),
|
|
'the helper build is reached through the setup action',
|
|
);
|
|
});
|
|
|
|
test('a composite action cycle is a loud error, not an empty step list', () => {
|
|
// The depth cutoff this replaces returned no steps, which reads to every assertion
|
|
// downstream as "this action executes nothing" — silence in the one place that must shout.
|
|
assert.throws(
|
|
() =>
|
|
planted({
|
|
'.github/workflows/planted.yml': `name: Planted
|
|
on:
|
|
pull_request:
|
|
jobs:
|
|
planted:
|
|
steps:
|
|
- uses: ./.github/actions/a
|
|
`,
|
|
'.github/actions/a/action.yml': `runs:
|
|
using: composite
|
|
steps:
|
|
- uses: ./.github/actions/b
|
|
`,
|
|
'.github/actions/b/action.yml': `runs:
|
|
using: composite
|
|
steps:
|
|
- uses: ./.github/actions/a
|
|
`,
|
|
}),
|
|
/composite action cycle/,
|
|
);
|
|
});
|
|
|
|
test('nesting deeper than the old cutoff is followed rather than silently dropped', () => {
|
|
const nested = (next: string) => `runs:
|
|
using: composite
|
|
steps:
|
|
- uses: ./.github/actions/${next}
|
|
`;
|
|
const lanes = planted({
|
|
'.github/workflows/planted.yml': `name: Planted
|
|
on:
|
|
pull_request:
|
|
jobs:
|
|
planted:
|
|
steps:
|
|
- uses: ./.github/actions/a
|
|
`,
|
|
'.github/actions/a/action.yml': nested('b'),
|
|
'.github/actions/b/action.yml': nested('c'),
|
|
'.github/actions/c/action.yml': nested('d'),
|
|
'.github/actions/d/action.yml': nested('e'),
|
|
'.github/actions/e/action.yml': `runs:
|
|
using: composite
|
|
steps:
|
|
- uses: ./.github/actions/run-gate
|
|
with:
|
|
gate: layering
|
|
`,
|
|
});
|
|
assert.deepEqual(
|
|
lanes[0]?.gates,
|
|
['layering'],
|
|
'a gate five levels down is still credited to the lane',
|
|
);
|
|
});
|