GitNexus/.github/scripts/verify-workflow-run-pr-identity.cjs
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

274 lines
9.3 KiB
JavaScript

// Resolve the open PR for a trusted workflow_run consumer.
//
// Shared by commit-fork-prebuilds.yml and pr-autofix-publish.yml.
// workflow_run.pull_requests[] is empty on fork PRs, and
// GET /repos/{base}/commits/{sha}/pulls is also empty because the fork head
// commit is not in the base repo's commit graph. The authoritative lookup is
// GET /repos/{base}/pulls?head={owner}:{branch}&state=open using
// workflow_run.head_repository + workflow_run.head_branch (server-controlled).
// That same query works for same-repo PRs (owner is the base repo owner).
//
// The current PR tip may have moved past the SHA the producer built; that is
// not an identity failure — the caller decides whether to lease-push or just
// comment. Two open PRs from the same fork head (same owner:branch into this
// repo) are an identity failure: artifact pr_number is untrusted and must not
// pick among them. Set SCHEMA_PATTERN to the artifact schema allowlist
// (defaults to the tree-sitter prebuild schema).
'use strict';
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const SCHEMA_PATTERN = /^gitnexus\.ts-prebuild\/v[0-9]+$/;
const IDENTITY_PATTERNS = {
pr_number: /^[0-9]+$/,
head_sha: /^[0-9a-f]{40}$/,
head_ref: /^[A-Za-z0-9._/-]+$/,
repo: /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/,
};
function allowlistField(key, value, pattern) {
const text = value == null ? '' : String(value);
if (!text || !pattern.test(text)) {
throw new Error(`metadata.${key} failed allowlist (got: ${JSON.stringify(text)})`);
}
return text;
}
function forkHeadOwner(headRepo) {
const slash = headRepo.indexOf('/');
if (slash <= 0 || slash === headRepo.length - 1) {
throw new Error(`head_repo must be owner/name (got: ${JSON.stringify(headRepo)})`);
}
return headRepo.slice(0, slash);
}
function compileSchemaPattern(value) {
if (value instanceof RegExp) return value;
if (typeof value === 'string' && value.length > 0) {
try {
return new RegExp(value);
} catch {
throw new Error('SCHEMA_PATTERN is not a valid regular expression');
}
}
return SCHEMA_PATTERN;
}
function allowlistMetadata(raw, schemaPattern) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('metadata.json must be an object');
}
return {
schema: allowlistField('schema', parsed.schema, compileSchemaPattern(schemaPattern)),
pr_number: allowlistField('pr_number', parsed.pr_number, IDENTITY_PATTERNS.pr_number),
head_sha: allowlistField('head_sha', parsed.head_sha, IDENTITY_PATTERNS.head_sha),
head_ref: allowlistField('head_ref', parsed.head_ref, IDENTITY_PATTERNS.head_ref),
head_repo: allowlistField('head_repo', parsed.head_repo, IDENTITY_PATTERNS.repo),
base_repo: allowlistField('base_repo', parsed.base_repo, IDENTITY_PATTERNS.repo),
};
}
function allowlistAuthority(authority) {
return {
head_sha: allowlistField('head_sha', authority.head_sha, IDENTITY_PATTERNS.head_sha),
head_repo: allowlistField('head_repo', authority.head_repo, IDENTITY_PATTERNS.repo),
head_branch: allowlistField('head_ref', authority.head_branch, IDENTITY_PATTERNS.head_ref),
base_repo: allowlistField('base_repo', authority.base_repo, IDENTITY_PATTERNS.repo),
};
}
function verifyArtifactAgainstWorkflowRun(meta, authority) {
if (meta.head_sha !== authority.head_sha) {
throw new Error(
`Artifact head_sha (${meta.head_sha}) != workflow_run.head_sha (${authority.head_sha}) — refusing.`,
);
}
if (meta.head_repo !== authority.head_repo) {
throw new Error(
`Artifact head_repo (${meta.head_repo}) != workflow_run.head_repository (${authority.head_repo}) — refusing.`,
);
}
if (meta.base_repo !== authority.base_repo) {
throw new Error('Artifact base_repo does not match $GITHUB_REPOSITORY — refusing.');
}
if (meta.head_ref !== authority.head_branch) {
throw new Error(
`Artifact head_ref (${meta.head_ref}) != workflow_run.head_branch (${authority.head_branch}) — refusing.`,
);
}
}
function matchOpenPullsFromForkHead(pulls, { headRepo, headBranch, baseRepo }) {
if (!Array.isArray(pulls)) {
throw new Error('GitHub pulls?head= lookup returned a non-array');
}
return pulls.filter((pr) => {
return (
pr &&
pr.state === 'open' &&
Number.isInteger(pr.number) &&
pr.head &&
pr.head.repo &&
pr.head.repo.full_name === headRepo &&
pr.head.ref === headBranch &&
pr.base &&
pr.base.repo &&
pr.base.repo.full_name === baseRepo
);
});
}
function resolveVerifiedPullRequest({ meta, authority, pulls, schemaPattern }) {
const cleanMeta = allowlistMetadata(meta, schemaPattern);
const cleanAuthority = allowlistAuthority(authority);
verifyArtifactAgainstWorkflowRun(cleanMeta, cleanAuthority);
const matched = matchOpenPullsFromForkHead(pulls, {
headRepo: cleanAuthority.head_repo,
headBranch: cleanAuthority.head_branch,
baseRepo: cleanAuthority.base_repo,
});
if (matched.length === 0) {
throw new Error(
`No open PR from ${cleanAuthority.head_repo}:${cleanAuthority.head_branch} targeting ${cleanAuthority.base_repo} — refusing.`,
);
}
// Artifact pr_number is untrusted. Do not use it to pick among several open
// PRs that share this fork head (same owner:branch into this repo, different
// base branches). Fail closed unless GitHub-controlled fields leave exactly one.
if (matched.length !== 1) {
throw new Error(
`Ambiguous open PRs from ${cleanAuthority.head_repo}:${cleanAuthority.head_branch} targeting ${cleanAuthority.base_repo} (${matched
.map((pr) => pr.number)
.join(',')}) — refusing.`,
);
}
const chosen = matched[0];
const expected = Number(cleanMeta.pr_number);
if (chosen.number !== expected) {
throw new Error(
`Artifact pr_number (${cleanMeta.pr_number}) is not the open PR(s) from this fork head (${chosen.number}) — refusing.`,
);
}
const currentHeadSha = typeof chosen.head.sha === 'string' ? chosen.head.sha : '';
return {
pr_number: String(chosen.number),
head_ref: cleanAuthority.head_branch,
head_sha: cleanAuthority.head_sha,
head_repo: cleanAuthority.head_repo,
current_head_sha: currentHeadSha,
branch_moved: Boolean(currentHeadSha && currentHeadSha !== cleanAuthority.head_sha),
};
}
function flattenGhListPages(parsed) {
if (!Array.isArray(parsed)) {
throw new Error('GitHub pulls?head= lookup returned a non-array');
}
if (parsed.length === 0) return parsed;
if (parsed.every((page) => Array.isArray(page))) {
return parsed.flat();
}
return parsed;
}
function listOpenPullsByHead({ ghRepo, headOwner, headBranch, runGh }) {
const run = runGh || ((args) => spawnSync('gh', args, { encoding: 'utf8' }));
const result = run([
'api',
'--paginate',
'--slurp',
'-X',
'GET',
`repos/${ghRepo}/pulls`,
'-f',
'state=open',
'-f',
`head=${headOwner}:${headBranch}`,
]);
if (result.status !== 0) {
const err = (result.stderr || result.stdout || '').trim();
throw new Error(`GitHub pulls?head= lookup failed: ${err || `exit ${result.status}`}`);
}
const stdout = (result.stdout || '').trim();
if (!stdout) {
throw new Error('GitHub pulls?head= lookup returned an empty body');
}
let parsed;
try {
parsed = JSON.parse(stdout);
} catch {
throw new Error('GitHub pulls?head= lookup returned non-JSON');
}
return flattenGhListPages(parsed);
}
function main() {
const schemaPattern = compileSchemaPattern(process.env.SCHEMA_PATTERN);
const raw = fs.readFileSync(process.env.META_PATH, 'utf8');
const meta = allowlistMetadata(raw, schemaPattern);
const authority = allowlistAuthority({
head_sha: process.env.WF_HEAD_SHA,
head_repo: process.env.WF_HEAD_REPO,
head_branch: process.env.WF_HEAD_BRANCH,
base_repo: process.env.GH_REPO,
});
const pulls = listOpenPullsByHead({
ghRepo: authority.base_repo,
headOwner: forkHeadOwner(authority.head_repo),
headBranch: authority.head_branch,
});
const verified = resolveVerifiedPullRequest({ meta, authority, pulls, schemaPattern });
if (verified.branch_moved) {
console.log(
`PR head moved to ${verified.current_head_sha}; delivering against built SHA ${verified.head_sha} (lease will refuse if the branch moved).`,
);
}
console.log(
`Verified identity: PR=${verified.pr_number} head_sha=${verified.head_sha} head_repo=${verified.head_repo} head_ref=${verified.head_ref}.`,
);
const out = process.env.GITHUB_OUTPUT;
if (!out) {
throw new Error('GITHUB_OUTPUT is unset');
}
fs.appendFileSync(
out,
[
`pr_number=${verified.pr_number}`,
`head_ref=${verified.head_ref}`,
`head_sha=${verified.head_sha}`,
`head_repo=${verified.head_repo}`,
].join('\n') + '\n',
);
}
if (require.main === module) {
try {
main();
} catch (err) {
console.error(`::error::${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
module.exports = {
SCHEMA_PATTERN,
IDENTITY_PATTERNS,
allowlistField,
compileSchemaPattern,
allowlistMetadata,
allowlistAuthority,
forkHeadOwner,
verifyArtifactAgainstWorkflowRun,
matchOpenPullsFromForkHead,
flattenGhListPages,
resolveVerifiedPullRequest,
listOpenPullsByHead,
main,
};