Files
Vojtech NovakandClaude Opus 5.5 ad36e49993 [agent-cli] CI: an LLP-only PR runs only the format check and the Linux unit job (#334)
## Summary

A PR that changes only LLP documents ran the whole agent-cli CI: six
Linux and Windows e2e shards, the macOS e2e job, the Windows unit job,
the agent evals, and four billed EAS Simulator sessions. No test in
those jobs reads `llp/`.

## How

The stack gate (`scripts/stack-gate.ts`) already decides, for GitHub and
EAS, whether a PR runs CI. It now returns a scope instead of a boolean:

- `none`: an open PR above holds this PR's head commit. Nothing runs
(unchanged).
- `llp`: the PR is based on the default branch, its head is the event's
commit, and every changed path, including the old path of a rename, is
under `packages/@expo/agent-cli/llp/`. Only `tier0-linux` (its
`oldName-test` scans `llp/`) and `oxfmt` run.
- `all`: anything else. Errors, pushes, manual runs and schedules also
give `all`. A stack layer above the default branch also gets `all`:
GitHub lists its files against the layer below, so an LLP-only top layer
would hide the code of a lower layer that it covers.

The script maps the scope to two outputs in one table: `run` (false only
on `none`, the old meaning) and `full` (true only on `all`), and prints
`full` on stdout. `tier0-linux` and `oxfmt` keep `run == 'true'`.
`tier0-e2e`, `tier0-e2e-macos`, `tier0-windows` and `tier1-agent-eval`
check `full != 'false'`, so an empty output runs them; a test fails on
any `outputs.full ==`. On EAS the gate step keeps its fail-open form and
names the output for what it carries: `set-output full "$(bun
scripts/stack-gate.ts || echo true)"`; the four cloud jobs check `full
== 'true'`. No status check is required by the ruleset, so a skipped job
does not block a merge.

## Verification

- `bun test scripts`: 43 pass. `stack-gate.test.ts` covers the file
rule, a rename from `src/` into `llp/`, an LLP-only stack layer, a head
that is not the event commit (or no event commit), "covered wins over
LLP-only", the exact reason of every scope, pagination, a GitHub error,
the condition form, the scope-to-output table, and the real script
against a local fake GitHub for an LLP-only PR (stdout `false`,
`run=true`, `full=false`). Breaking the page loop, dropping
`previous_filename`, writing one `full ==` condition, or printing `run`
on stdout each makes a case fail. The stack-layer case fails without the
base check.
- The four workflow files parse. `oxfmt --check` passes on the changed
files.
- Not checked: the skip on GitHub and on EAS for a real LLP-only PR.
This PR changes code, so every job runs here.

## Watch

`stack-gate.yml` gains a `full` output; `run` keeps its meaning, so
`format.yml` is unchanged. The EAS gate runs without `GITHUB_TOKEN`, so
the extra files request is unauthenticated; a rate-limit 403 gives
`all`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 19:26:38 +01:00

228 lines
8.4 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { type GateInput, type GateIo, LLP_DIR, OUTPUTS, decide, onlyLlp } from './stack-gate';
const input: GateInput = {
repo: 'o/r',
prNumber: 1,
headRef: 'feature',
headSha: 'aaa',
action: 'synchronize',
waitSeconds: 0,
apiUrl: 'https://api',
token: null,
};
type Route = (path: string) => unknown;
function fakeIo(route: Route): GateIo & { paths: string[] } {
const paths: string[] = [];
const fakeFetch = async (url: string | URL | Request) => {
const path = String(url).replace('https://api/repos/o/r', '');
paths.push(path);
const body = route(path);
return typeof body === 'number'
? new Response('{}', { status: body })
: new Response(JSON.stringify(body), { status: 200 });
};
return { fetch: fakeFetch as typeof fetch, sleep: async () => {}, paths };
}
const llpFiles = [{ filename: `${LLP_DIR}0015-a.rfc.md` }, { filename: `${LLP_DIR}0027-b.rfc.md` }];
function stack(
above: unknown[],
files: unknown,
compare = 'diverged',
base = 'main',
head = 'aaa'
): Route {
return (path) => {
if (path.startsWith('/pulls?')) return above;
if (path.startsWith('/compare/')) return { status: compare };
if (path.startsWith('/pulls/1/files')) return files;
if (path === '/pulls/1') {
return { head: { sha: head }, base: { ref: base, repo: { default_branch: 'main' } } };
}
throw new Error(`unexpected ${path}`);
};
}
describe('onlyLlp', () => {
it.each([
[[`${LLP_DIR}0015-a.rfc.md`, `${LLP_DIR}0027-b.rfc.md`], true],
[[`${LLP_DIR}0015-a.rfc.md`, 'packages/@expo/agent-cli/src/index.ts'], false],
[['packages/@expo/agent-cli/AGENTS.md'], false],
[['packages/@expo/agent-cli/llp-notes.md'], false],
[[], false],
])('%j -> %p', (files, expected) => {
expect(onlyLlp(files)).toBe(expected);
});
});
describe('decide', () => {
it('runs nothing when a PR above holds the head commit, even for an LLP-only PR', async () => {
const io = fakeIo(stack([{ number: 2, head: { ref: 'up', sha: 'bbb' } }], llpFiles, 'ahead'));
expect(await decide(input, io)).toEqual({
scope: 'none',
reason: 'Covered by #2: its head holds this commit. CI runs there.',
});
expect(io.paths.some((path) => path.includes('/files'))).toBe(false);
});
it('runs the LLP scope when every file is an LLP', async () => {
expect(await decide(input, fakeIo(stack([], llpFiles)))).toEqual({
scope: 'llp',
reason: `No open PR is based on this branch. All 2 changed paths are under ${LLP_DIR}: only the jobs that read it run.`,
});
});
it('runs everything for an LLP-only stack layer, whose files omit the layers below', async () => {
expect(await decide(input, fakeIo(stack([], llpFiles, 'diverged', 'feature')))).toEqual({
scope: 'all',
reason:
'No open PR is based on this branch. Based on feature, not the default branch. CI runs here.',
});
});
it('runs everything when the PR head moved past the event commit', async () => {
expect(await decide(input, fakeIo(stack([], llpFiles, 'diverged', 'main', 'bbb')))).toEqual({
scope: 'all',
reason:
'No open PR is based on this branch. The PR head bbb is not the event commit aaa. CI runs here.',
});
});
it('runs everything when the event names no head commit', async () => {
const decision = await decide({ ...input, headSha: null }, fakeIo(stack([], llpFiles)));
expect(decision.scope).toBe('all');
});
it('runs everything when a file moves into llp/ from outside it', async () => {
const renamed = [
{ filename: `${LLP_DIR}foo.md`, previous_filename: 'packages/@expo/agent-cli/src/foo.ts' },
];
expect((await decide(input, fakeIo(stack([], renamed)))).scope).toBe('all');
});
it('runs everything when a PR above does not hold the head commit and a file is code', async () => {
const io = fakeIo(
stack([{ number: 2, head: { ref: 'up', sha: 'bbb' } }], [{ filename: 'src/x.ts' }])
);
expect(await decide(input, io)).toEqual({
scope: 'all',
reason: '#2 based on this branch, but none holds this commit. CI runs here.',
});
});
it('reads every page of files', async () => {
const page1 = Array.from({ length: 100 }, (_, i) => ({ filename: `${LLP_DIR}${i}.md` }));
const io = fakeIo((path) => {
if (path.startsWith('/pulls?')) return [];
if (path === '/pulls/1') {
return { head: { sha: 'aaa' }, base: { ref: 'main', repo: { default_branch: 'main' } } };
}
return path.endsWith('page=1') ? page1 : [{ filename: 'src/x.ts' }];
});
expect((await decide(input, io)).scope).toBe('all');
expect(io.paths.some((path) => path.includes('page=2'))).toBe(true);
});
it('runs everything when GitHub refuses', async () => {
const decision = await decide(input, fakeIo(stack([], 403)));
expect(decision).toEqual({
scope: 'all',
reason: 'Could not read GitHub (403 on /pulls/1/files?per_page=100&page=1). CI runs here.',
});
});
});
describe('workflow conditions', () => {
const root = path.join(import.meta.dir, '..');
const GATE = /needs\.stack\.outputs\.\w+\s*[!=]=\s*'\w+'/g;
// GitHub jobs compare with != so an empty output runs them; EAS relies on `|| echo true`.
const expected: Record<string, Record<string, string[]>> = {
'.github/workflows/agent-cli.yml': {
'tier0-linux': ["needs.stack.outputs.run == 'true'"],
'tier0-e2e': ["needs.stack.outputs.full != 'false'"],
'tier0-e2e-macos': ["needs.stack.outputs.full != 'false'"],
'tier0-windows': ["needs.stack.outputs.full != 'false'"],
'tier1-agent-eval': ["needs.stack.outputs.full != 'false'"],
},
'.github/workflows/format.yml': { oxfmt: ["needs.stack.outputs.run == 'true'"] },
'packages/@expo/agent-cli/.eas/workflows/agent-cli-cloud-e2e.yml': {
'cloud-e2e-expo-go-ios': ["needs.stack.outputs.full == 'true'"],
'cloud-e2e-expo-go-android': ["needs.stack.outputs.full == 'true'"],
'cloud-e2e-dev-build-ios': ["needs.stack.outputs.full == 'true'"],
'cloud-e2e-dev-build-android': ["needs.stack.outputs.full == 'true'"],
},
};
it.each(Object.keys(expected))('%s gates each job on the stack outputs', (file) => {
const workflow = Bun.YAML.parse(readFileSync(path.join(root, file), 'utf8')) as {
jobs: Record<string, { if?: string }>;
};
const gates = Object.fromEntries(
Object.entries(workflow.jobs)
.map(([job, { if: condition }]) => [job, String(condition ?? '').match(GATE) ?? []])
.filter(([, found]) => found.length > 0)
);
expect(gates).toEqual(expected[file]);
});
it('EAS runs every job when the gate fails', () => {
const eas = readFileSync(
path.join(root, 'packages/@expo/agent-cli/.eas/workflows/agent-cli-cloud-e2e.yml'),
'utf8'
);
expect(eas).toContain('set-output full "$(bun scripts/stack-gate.ts || echo true)"');
});
});
describe('outputs', () => {
// EAS reads stdout as `full`; GitHub jobs read `run` and `full` from GITHUB_OUTPUT.
it('map each scope to the jobs it runs', () => {
expect(OUTPUTS).toEqual({
none: { run: false, full: false },
llp: { run: true, full: false },
all: { run: true, full: true },
});
});
it('print full and write run and full for an LLP-only PR', async () => {
const route = stack([], llpFiles);
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url);
const body = route((url.pathname + url.search).replace('/repos/o/r', ''));
return Response.json(body);
},
});
const output = path.join(mkdtempSync(path.join(tmpdir(), 'stack-gate-')), 'output');
try {
const child = Bun.spawn(['bun', path.join(import.meta.dir, 'stack-gate.ts')], {
env: {
PATH: process.env.PATH,
GITHUB_OUTPUT: output,
GITHUB_API_URL: server.url.origin,
GITHUB_REPOSITORY: 'o/r',
PR_NUMBER: '1',
PR_HEAD_REF: 'feature',
PR_HEAD_SHA: 'aaa',
},
stdout: 'pipe',
stderr: 'pipe',
});
expect(await new Response(child.stdout).text()).toBe('false\n');
expect(await child.exited).toBe(0);
expect(readFileSync(output, 'utf8')).toBe('run=true\nfull=false\n');
} finally {
server.stop(true);
}
});
});