GitNexus/gitnexus/test/unit/verify-workflow-run-pr-identity.test.ts
Gergő Magyar 1e8bdd890a
fix(ci): look up fork prebuild PRs by head owner and branch (#3236)
* fix(ci): look up fork prebuild PRs by head owner and branch

commits/{sha}/pulls is empty for fork SHAs, so deliver-fork-prebuilds failed closed on every real fork PR. Resolve the open PR from workflow_run head owner+branch instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): use the same fork-PR lookup in autofix publish

pr-autofix-publish had the same commits/{sha}/pulls fallback, which is empty for fork SHAs. Share the pulls?head=owner:branch verifier and always run it before sticky comments or check runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): fail closed on ambiguous fork-head PRs

Untrusted artifact pr_number must not pick among sibling open PRs from the same fork branch. Parse paginated gh --slurp pages and require verify success before prebuild checkout/push.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(ci): prettier the fork-PR identity verifier

Root prettier --check fails on .cjs; lint-staged only formats .js.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:46:24 +01:00

402 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Identity gate for commit-fork-prebuilds.yml and pr-autofix-publish.yml.
* The production failure on fork PR #3179 was: workflow_run.pull_requests[]
* is empty (GitHub design) and GET /repos/{base}/commits/{sha}/pulls is also
* empty (fork commit is not in the base graph). The verifier must use
* pulls?head=owner:branch and must not treat a gh failure as "no PR".
*/
const requireCjs = createRequire(import.meta.url);
const SCRIPT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../.github/scripts/verify-workflow-run-pr-identity.cjs',
);
const WORKFLOW = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../.github/workflows/commit-fork-prebuilds.yml',
);
const AUTOFIX_WORKFLOW = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../.github/workflows/pr-autofix-publish.yml',
);
const AUTOFIX_SCHEMA = /^gitnexus\.pr-autofix\/v[0-9]+$/;
const mod = requireCjs(SCRIPT) as {
allowlistField: (key: string, value: unknown, pattern: RegExp) => string;
allowlistMetadata: (raw: unknown, schemaPattern?: RegExp) => Record<string, string>;
forkHeadOwner: (headRepo: string) => string;
resolveVerifiedPullRequest: (input: {
meta: unknown;
authority: {
head_sha: string;
head_repo: string;
head_branch: string;
base_repo: string;
};
pulls: unknown;
schemaPattern?: RegExp;
}) => {
pr_number: string;
head_ref: string;
head_sha: string;
head_repo: string;
current_head_sha: string;
branch_moved: boolean;
};
listOpenPullsByHead: (input: {
ghRepo: string;
headOwner: string;
headBranch: string;
runGh: (args: string[]) => { status: number; stdout?: string; stderr?: string };
}) => unknown[];
flattenGhListPages: (parsed: unknown) => unknown[];
IDENTITY_PATTERNS: { pr_number: RegExp; head_sha: RegExp; head_ref: RegExp; repo: RegExp };
};
const SHA = 'ee08b034ea90943e8b037c6224f1e0de41a65613';
const MOVED = '8a33525d9bb8617dfb08d426d4b2834581e1277a';
const META = {
schema: 'gitnexus.ts-prebuild/v1',
pr_number: 3179,
head_sha: SHA,
head_ref: 'objective-c_support',
head_repo: 'mengkaka/GitNexus',
base_repo: 'abhigyanpatwari/GitNexus',
};
const AUTHORITY = {
head_sha: SHA,
head_repo: 'mengkaka/GitNexus',
head_branch: 'objective-c_support',
base_repo: 'abhigyanpatwari/GitNexus',
};
function pull(overrides: Record<string, unknown> = {}) {
return {
number: 3179,
state: 'open',
head: {
sha: SHA,
ref: 'objective-c_support',
repo: { full_name: 'mengkaka/GitNexus' },
},
base: { repo: { full_name: 'abhigyanpatwari/GitNexus' } },
...overrides,
};
}
describe('allowlistField', () => {
it('rejects a newline that would inject a second GITHUB_OUTPUT line', () => {
expect(() =>
mod.allowlistField('head_ref', 'main\npr_number=1', mod.IDENTITY_PATTERNS.head_ref),
).toThrow(/failed allowlist/);
});
it('accepts a slashy branch name', () => {
expect(mod.allowlistField('head_ref', 'feat/objc', mod.IDENTITY_PATTERNS.head_ref)).toBe(
'feat/objc',
);
});
});
describe('forkHeadOwner', () => {
it('takes the owner from owner/name', () => {
expect(mod.forkHeadOwner('mengkaka/GitNexus')).toBe('mengkaka');
});
it('rejects a bare owner', () => {
expect(() => mod.forkHeadOwner('mengkaka')).toThrow(/owner\/name/);
});
});
describe('resolveVerifiedPullRequest', () => {
it('accepts the #3179 production shape even when the PR tip has moved', () => {
const verified = mod.resolveVerifiedPullRequest({
meta: META,
authority: AUTHORITY,
pulls: [
pull({
head: {
sha: MOVED,
ref: 'objective-c_support',
repo: { full_name: 'mengkaka/GitNexus' },
},
}),
],
});
expect(verified).toEqual({
pr_number: '3179',
head_ref: 'objective-c_support',
head_sha: SHA,
head_repo: 'mengkaka/GitNexus',
current_head_sha: MOVED,
branch_moved: true,
});
});
it('refuses an empty pulls list — the commits/{sha}/pulls miss', () => {
expect(() =>
mod.resolveVerifiedPullRequest({ meta: META, authority: AUTHORITY, pulls: [] }),
).toThrow(/No open PR from mengkaka\/GitNexus:objective-c_support/);
});
it('refuses a forged pr_number that is not the fork-head PR', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: { ...META, pr_number: 1 },
authority: AUTHORITY,
pulls: [pull()],
}),
).toThrow(/Artifact pr_number \(1\)/);
});
it('refuses a head_sha that does not match workflow_run', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: { ...META, head_sha: MOVED },
authority: AUTHORITY,
pulls: [pull()],
}),
).toThrow(/head_sha/);
});
it('refuses a head_repo that does not match workflow_run', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: { ...META, head_repo: 'evil/GitNexus' },
authority: AUTHORITY,
pulls: [pull()],
}),
).toThrow(/head_repo/);
});
it('refuses a head_ref that does not match workflow_run.head_branch', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: { ...META, head_ref: 'other-branch' },
authority: AUTHORITY,
pulls: [pull()],
}),
).toThrow(/head_ref/);
});
it('refuses a base_repo that does not match $GITHUB_REPOSITORY', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: { ...META, base_repo: 'evil/upstream' },
authority: AUTHORITY,
pulls: [pull()],
}),
).toThrow(/base_repo does not match/);
});
it('refuses two open PRs that share the same fork head', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: META,
authority: AUTHORITY,
pulls: [
pull(),
pull({
number: 4000,
base: { repo: { full_name: 'abhigyanpatwari/GitNexus' }, ref: 'release' },
}),
],
}),
).toThrow(/Ambiguous open PRs/);
});
it('refuses a closed PR for the same fork head', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: META,
authority: AUTHORITY,
pulls: [pull({ state: 'closed' })],
}),
).toThrow(/No open PR/);
});
it('accepts gitnexus.pr-autofix metadata when SCHEMA_PATTERN is the autofix schema', () => {
const verified = mod.resolveVerifiedPullRequest({
meta: { ...META, schema: 'gitnexus.pr-autofix/v1', changed_lines: 12 },
authority: AUTHORITY,
pulls: [pull()],
schemaPattern: AUTOFIX_SCHEMA,
});
expect(verified.pr_number).toBe('3179');
});
it('rejects an autofix schema against the default prebuild allowlist', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: { ...META, schema: 'gitnexus.pr-autofix/v1' },
authority: AUTHORITY,
pulls: [pull()],
}),
).toThrow(/metadata.schema failed allowlist/);
});
it('ignores a PR from the same owner that targets a different repo or branch', () => {
expect(() =>
mod.resolveVerifiedPullRequest({
meta: META,
authority: AUTHORITY,
pulls: [
pull({
number: 9,
head: { sha: SHA, ref: 'unrelated', repo: { full_name: 'mengkaka/GitNexus' } },
}),
],
}),
).toThrow(/No open PR/);
});
});
describe('listOpenPullsByHead', () => {
it('calls gh with state=open and head=owner:branch', () => {
let seen: string[] = [];
const pulls = [pull()];
const listed = mod.listOpenPullsByHead({
ghRepo: 'abhigyanpatwari/GitNexus',
headOwner: 'mengkaka',
headBranch: 'objective-c_support',
runGh: (args) => {
seen = args;
return { status: 0, stdout: JSON.stringify(pulls) };
},
});
expect(seen).toEqual([
'api',
'--paginate',
'--slurp',
'-X',
'GET',
'repos/abhigyanpatwari/GitNexus/pulls',
'-f',
'state=open',
'-f',
'head=mengkaka:objective-c_support',
]);
expect(listed).toEqual(pulls);
});
it('flattens gh --paginate --slurp page arrays', () => {
const page1 = [pull()];
const page2 = [
pull({
number: 4000,
head: {
sha: SHA,
ref: 'other',
repo: { full_name: 'mengkaka/GitNexus' },
},
}),
];
const listed = mod.listOpenPullsByHead({
ghRepo: 'abhigyanpatwari/GitNexus',
headOwner: 'mengkaka',
headBranch: 'objective-c_support',
runGh: () => ({ status: 0, stdout: JSON.stringify([page1, page2]) }),
});
expect(listed).toEqual([...page1, ...page2]);
});
it('throws on an empty body', () => {
expect(() =>
mod.listOpenPullsByHead({
ghRepo: 'abhigyanpatwari/GitNexus',
headOwner: 'mengkaka',
headBranch: 'objective-c_support',
runGh: () => ({ status: 0, stdout: '' }),
}),
).toThrow(/empty body/);
});
it('throws on a non-JSON success body', () => {
expect(() =>
mod.listOpenPullsByHead({
ghRepo: 'abhigyanpatwari/GitNexus',
headOwner: 'mengkaka',
headBranch: 'objective-c_support',
runGh: () => ({ status: 0, stdout: 'not-json' }),
}),
).toThrow(/non-JSON/);
});
it('throws on a gh failure instead of pretending there is no PR', () => {
expect(() =>
mod.listOpenPullsByHead({
ghRepo: 'abhigyanpatwari/GitNexus',
headOwner: 'mengkaka',
headBranch: 'objective-c_support',
runGh: () => ({ status: 1, stderr: 'HTTP 404' }),
}),
).toThrow(/pulls\?head= lookup failed: HTTP 404/);
});
it('throws on a non-array success body', () => {
expect(() =>
mod.listOpenPullsByHead({
ghRepo: 'abhigyanpatwari/GitNexus',
headOwner: 'mengkaka',
headBranch: 'objective-c_support',
runGh: () => ({ status: 0, stdout: '{"message":"Not Found"}' }),
}),
).toThrow(/non-array/);
});
});
describe('commit-fork-prebuilds.yml contract', () => {
const workflow = readFileSync(WORKFLOW, 'utf8');
it('runs the tested verifier and keys the fork lookup on workflow_run.head_branch', () => {
expect(workflow).toContain('.github/scripts/verify-workflow-run-pr-identity.cjs');
expect(workflow).toContain('github.event.workflow_run.head_branch');
expect(workflow).toContain('WF_HEAD_BRANCH');
});
it('does not call commits/{sha}/pulls (empty for fork SHAs; comments may name it)', () => {
expect(workflow).not.toMatch(/gh api .*commits\/[^/\s]+\/pulls/);
});
it('does not checkout, place, or push unless identity verify succeeded', () => {
expect(workflow).toContain('steps.verify.outputs.head_repo');
expect(workflow).toContain('steps.verify.outputs.head_sha');
expect(workflow).toContain('steps.verify.outputs.pr_number');
for (const step of [
'Checkout fork PR head',
'Place prebuilds into the fork checkout',
'Commit and push to the fork branch',
]) {
const chunk = workflow.split(`- name: ${step}`)[1]?.split('- name:')[0] ?? '';
expect(chunk, step).toMatch(/steps\.verify\.outcome == 'success'/);
}
});
});
describe('pr-autofix-publish.yml contract', () => {
const workflow = readFileSync(AUTOFIX_WORKFLOW, 'utf8');
it('runs the tested verifier and keys the lookup on workflow_run.head_branch', () => {
expect(workflow).toContain('.github/scripts/verify-workflow-run-pr-identity.cjs');
expect(workflow).toContain('github.event.workflow_run.head_branch');
expect(workflow).toContain('WF_HEAD_BRANCH');
expect(workflow).toContain('gitnexus\\.pr-autofix');
});
it('does not call commits/{sha}/pulls (empty for fork SHAs; comments may name it)', () => {
expect(workflow).not.toMatch(/gh api .*commits\/[^/\s]+\/pulls/);
});
it('does not post sticky comments or check runs unless identity verify succeeded', () => {
expect(workflow).toMatch(/steps\.verify\.outcome == 'success'/);
expect(workflow).toContain('steps.verify.outputs.pr_number');
expect(workflow).toContain('steps.verify.outputs.head_sha');
});
});