mirror of
https://github.com/expo/expo-agent-cli.git
synced 2026-11-15 03:58:08 -10:00
## 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]>
213 lines
7.2 KiB
TypeScript
213 lines
7.2 KiB
TypeScript
// Decides how much CI a PR runs. `none`: an open PR based on its head branch already holds its head
|
|
// commit, and that PR's CI covers this one. `llp`: the PR is based on the default branch and every
|
|
// changed path is an agent-cli LLP, so only the jobs that read llp/ run. `all`: anything else,
|
|
// including anything the gate cannot prove.
|
|
//
|
|
// Outputs: `run` for the jobs that also run on `llp`, `full` for the rest. stdout is `full`.
|
|
|
|
export type GateInput = {
|
|
repo: string;
|
|
prNumber: number;
|
|
headRef: string | null;
|
|
headSha: string | null;
|
|
action: string;
|
|
waitSeconds: number;
|
|
apiUrl: string;
|
|
token: string | null;
|
|
};
|
|
|
|
export type Scope = 'none' | 'llp' | 'all';
|
|
|
|
export type GateDecision = { scope: Scope; reason: string };
|
|
|
|
export const OUTPUTS: Record<Scope, { run: boolean; full: boolean }> = {
|
|
none: { run: false, full: false },
|
|
llp: { run: true, full: false },
|
|
all: { run: true, full: true },
|
|
};
|
|
|
|
export type GateIo = {
|
|
fetch: typeof fetch;
|
|
sleep: (ms: number) => Promise<void>;
|
|
};
|
|
|
|
type Pull = { number: number; head: { ref: string; sha: string } };
|
|
|
|
type PullBase = { head: { sha: string }; base: { ref: string; repo: { default_branch: string } } };
|
|
|
|
type StackCover = { kind: 'covered'; by: number } | { kind: 'uncovered'; note: string };
|
|
|
|
export const LLP_DIR = 'packages/@expo/agent-cli/llp/';
|
|
|
|
const POLL_MS = 10_000;
|
|
const FILES_PER_PAGE = 100;
|
|
// GitHub lists at most 3000 files of a PR.
|
|
const MAX_FILE_PAGES = 30;
|
|
|
|
export function inputFromEnv(env: NodeJS.ProcessEnv): GateInput | null {
|
|
const prNumber = Number(env.PR_NUMBER);
|
|
if (!env.GITHUB_REPOSITORY || !Number.isInteger(prNumber) || prNumber <= 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
repo: env.GITHUB_REPOSITORY,
|
|
prNumber,
|
|
headRef: env.PR_HEAD_REF || null,
|
|
headSha: env.PR_HEAD_SHA || null,
|
|
action: env.EVENT_ACTION ?? '',
|
|
waitSeconds: Number(env.STACK_GATE_WAIT_SECONDS ?? 60),
|
|
apiUrl: env.GITHUB_API_URL || 'https://api.github.com',
|
|
token: env.GITHUB_TOKEN || null,
|
|
};
|
|
}
|
|
|
|
export function onlyLlp(files: readonly string[]): boolean {
|
|
return files.length > 0 && files.every((file) => file.startsWith(LLP_DIR));
|
|
}
|
|
|
|
export async function decide(input: GateInput, io: GateIo): Promise<GateDecision> {
|
|
try {
|
|
return await decideOrThrow(input, io);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return { scope: 'all', reason: `Could not read GitHub (${message}). CI runs here.` };
|
|
}
|
|
}
|
|
|
|
async function decideOrThrow(input: GateInput, io: GateIo): Promise<GateDecision> {
|
|
const stack = await stackCover(input, io);
|
|
if (stack.kind === 'covered') {
|
|
return {
|
|
scope: 'none',
|
|
reason: `Covered by #${stack.by}: its head holds this commit. CI runs there.`,
|
|
};
|
|
}
|
|
// A stack layer lists only its own files against the layer below, so an LLP-only top layer could
|
|
// hide the code of a lower layer that it covers.
|
|
const { head, base } = (await github(input, io, `/pulls/${input.prNumber}`)) as PullBase;
|
|
// The file list is the PR's as of now; it must be the event's commit.
|
|
if (head.sha !== input.headSha) {
|
|
return {
|
|
scope: 'all',
|
|
reason: `${stack.note} The PR head ${head.sha} is not the event commit ${input.headSha ?? '(none)'}. CI runs here.`,
|
|
};
|
|
}
|
|
if (base.ref !== base.repo.default_branch) {
|
|
return {
|
|
scope: 'all',
|
|
reason: `${stack.note} Based on ${base.ref}, not the default branch. CI runs here.`,
|
|
};
|
|
}
|
|
const paths = await prPaths(input, io);
|
|
if (onlyLlp(paths)) {
|
|
return {
|
|
scope: 'llp',
|
|
reason: `${stack.note} All ${paths.length} changed paths are under ${LLP_DIR}: only the jobs that read it run.`,
|
|
};
|
|
}
|
|
return { scope: 'all', reason: `${stack.note} CI runs here.` };
|
|
}
|
|
|
|
async function stackCover(input: GateInput, io: GateIo): Promise<StackCover> {
|
|
let { headRef, headSha } = input;
|
|
if (!headRef || !headSha) {
|
|
const pull = (await github(input, io, `/pulls/${input.prNumber}`)) as Pull;
|
|
headRef = pull.head.ref;
|
|
headSha = pull.head.sha;
|
|
}
|
|
|
|
// gh stack submit opens the PR above a few seconds after this one, so a fresh PR waits for it.
|
|
const deadline = input.action === 'opened' ? input.waitSeconds * 1000 : 0;
|
|
let waited = 0;
|
|
for (;;) {
|
|
const above = (await github(
|
|
input,
|
|
io,
|
|
`/pulls?state=open&base=${encodeURIComponent(headRef)}&per_page=100`
|
|
)) as Pull[];
|
|
if (above.length > 0) {
|
|
for (const pull of above) {
|
|
const comparison = (await github(input, io, `/compare/${headSha}...${pull.head.sha}`)) as {
|
|
status: string;
|
|
};
|
|
if (comparison.status === 'ahead' || comparison.status === 'identical') {
|
|
return { kind: 'covered', by: pull.number };
|
|
}
|
|
}
|
|
const numbers = above.map((pull) => `#${pull.number}`).join(', ');
|
|
return {
|
|
kind: 'uncovered',
|
|
note: `${numbers} based on this branch, but none holds this commit.`,
|
|
};
|
|
}
|
|
if (waited >= deadline) {
|
|
return { kind: 'uncovered', note: 'No open PR is based on this branch.' };
|
|
}
|
|
await io.sleep(POLL_MS);
|
|
waited += POLL_MS;
|
|
}
|
|
}
|
|
|
|
// A rename lists its old path in previous_filename; a move out of src/ into llp/ is not LLP-only.
|
|
async function prPaths(input: GateInput, io: GateIo): Promise<string[]> {
|
|
const paths: string[] = [];
|
|
for (let page = 1; page <= MAX_FILE_PAGES; page++) {
|
|
const batch = (await github(
|
|
input,
|
|
io,
|
|
`/pulls/${input.prNumber}/files?per_page=${FILES_PER_PAGE}&page=${page}`
|
|
)) as { filename: string; previous_filename?: string }[];
|
|
for (const file of batch) {
|
|
paths.push(file.filename);
|
|
if (file.previous_filename) {
|
|
paths.push(file.previous_filename);
|
|
}
|
|
}
|
|
if (batch.length < FILES_PER_PAGE) {
|
|
return paths;
|
|
}
|
|
}
|
|
throw new Error(`more than ${MAX_FILE_PAGES * FILES_PER_PAGE} files`);
|
|
}
|
|
|
|
async function github(input: GateInput, io: GateIo, path: string): Promise<unknown> {
|
|
const headers: Record<string, string> = {
|
|
Accept: 'application/vnd.github+json',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'User-Agent': 'expo-agent-cli-stack-gate',
|
|
};
|
|
if (input.token) {
|
|
headers.Authorization = `Bearer ${input.token}`;
|
|
}
|
|
const response = await io.fetch(`${input.apiUrl}/repos/${input.repo}${path}`, { headers });
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} on ${path}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const input = inputFromEnv(process.env);
|
|
const decision: GateDecision = input
|
|
? await decide(input, { fetch, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) })
|
|
: {
|
|
scope: 'all',
|
|
reason: `Not a pull request (GITHUB_REPOSITORY=${process.env.GITHUB_REPOSITORY ?? ''}, PR_NUMBER=${process.env.PR_NUMBER ?? ''}). CI runs.`,
|
|
};
|
|
console.error(decision.reason);
|
|
const { run, full } = OUTPUTS[decision.scope];
|
|
const { appendFile } = await import('node:fs/promises');
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
await appendFile(process.env.GITHUB_OUTPUT, `run=${run}\nfull=${full}\n`);
|
|
}
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${decision.reason}\n`);
|
|
}
|
|
// Last, so a throw prints nothing and EAS's `|| echo true` captures only `true`.
|
|
console.log(String(full));
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
await main();
|
|
}
|