Skip to content

Commit e299d76

Browse files
bradygasterCopilot
andcommitted
fix: bind bootstrap outputs to run provenance
Part of #2075 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent cd6d7d6 commit e299d76

6 files changed

Lines changed: 364 additions & 14 deletions

File tree

‎scripts/gh-aw-hosted-e2e.mjs‎

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const CAST_BRANCH = 'squad/bootstrap-cast';
3636
const CAST_PR_TITLE = '[squad] Cast your Squad';
3737
const RESEARCH_ISSUE_TITLE = '[Research Proposals] Agent-discovered repo opportunities';
3838
const RESEARCH_MARKER = '<!-- squad:bootstrap-opportunities schema=1 -->';
39+
const PROVENANCE_MARKER_PATTERN = /^<!-- squad:bootstrap-provenance (\{[^\r\n]+\}) -->$/gm;
40+
const ACTIONS_BOT_LOGIN = 'github-actions[bot]';
3941
const SAFE_CHILD_ENV = Object.freeze([
4042
'CI',
4143
'GH_CONFIG_DIR',
@@ -135,7 +137,7 @@ const githubAdapter = Object.freeze({
135137
listBranches(target) {
136138
return ghJson([
137139
'api', '--paginate', '--slurp', `repos/${target}/branches?per_page=100`,
138-
]).flat().map(({ name }) => ({ name }));
140+
]).flat().map((branch) => ({ name: branch.name, sha: branch.commit.sha }));
139141
},
140142
listPullRequests(target) {
141143
return ghJson([
@@ -148,7 +150,15 @@ const githubAdapter = Object.freeze({
148150
createdAt: pullRequest.created_at,
149151
isDraft: pullRequest.draft,
150152
headRefName: pullRequest.head.ref,
153+
headRepository: pullRequest.head.repo?.full_name,
154+
headSha: pullRequest.head.sha,
151155
baseRefName: pullRequest.base.ref,
156+
baseSha: pullRequest.base.sha,
157+
author: {
158+
login: pullRequest.user.login,
159+
id: pullRequest.user.id,
160+
type: pullRequest.user.type,
161+
},
152162
state: pullRequest.state,
153163
}));
154164
},
@@ -161,9 +171,20 @@ const githubAdapter = Object.freeze({
161171
title: issue.title,
162172
body: issue.body,
163173
createdAt: issue.created_at,
174+
author: {
175+
login: issue.user.login,
176+
id: issue.user.id,
177+
type: issue.user.type,
178+
},
164179
state: issue.state,
165180
}));
166181
},
182+
getUser(login) {
183+
return ghJson([
184+
'api', `users/${login}`,
185+
'--jq', '{login,id,type}',
186+
]);
187+
},
167188
});
168189

169190
function sourceGhJson(args) {
@@ -409,12 +430,55 @@ function waitForBootstrapRun(target, headSha, startedAt, evidence) {
409430
}
410431

411432
function bootstrapOutputs(target, github = githubAdapter) {
433+
const expectedAuthor = github.getUser(ACTIONS_BOT_LOGIN);
434+
if (expectedAuthor.login !== ACTIONS_BOT_LOGIN
435+
|| expectedAuthor.type !== 'Bot'
436+
|| !Number.isSafeInteger(expectedAuthor.id)) {
437+
throw new Error('GitHub Actions bot identity is unavailable or invalid.');
438+
}
439+
const castBranch = github.listBranches(target).find(({ name }) => name === CAST_BRANCH);
412440
return {
441+
target,
442+
expectedAuthor,
443+
castBranchSha: castBranch?.sha,
413444
castPrs: github.listPullRequests(target),
414445
issues: github.listIssues(target),
415446
};
416447
}
417448

449+
function parseBootstrapProvenance(body, label) {
450+
const matches = [...String(body ?? '').matchAll(PROVENANCE_MARKER_PATTERN)];
451+
if (matches.length !== 1) {
452+
throw new Error(`${label} must contain exactly one bootstrap provenance marker.`);
453+
}
454+
let marker;
455+
try {
456+
marker = JSON.parse(matches[0][1]);
457+
} catch {
458+
throw new Error(`${label} bootstrap provenance marker is malformed.`);
459+
}
460+
const keys = Object.keys(marker).sort();
461+
const expectedKeys = ['cast_sha', 'install_sha', 'repository', 'run_id', 'schema'];
462+
if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)
463+
|| marker.schema !== 1
464+
|| typeof marker.repository !== 'string'
465+
|| typeof marker.run_id !== 'string'
466+
|| typeof marker.install_sha !== 'string'
467+
|| typeof marker.cast_sha !== 'string'
468+
|| !SHA_PATTERN.test(marker.install_sha)
469+
|| !SHA_PATTERN.test(marker.cast_sha)
470+
|| !/^[1-9][0-9]*$/.test(marker.run_id)) {
471+
throw new Error(`${label} bootstrap provenance marker is malformed.`);
472+
}
473+
return marker;
474+
}
475+
476+
function sameAuthor(actual, expected) {
477+
return actual?.login === expected.login
478+
&& actual?.id === expected.id
479+
&& actual?.type === expected.type;
480+
}
481+
418482
export function selectBootstrapOutputs(outputs, baseline, installation, bootstrapRun) {
419483
const cutoff = Math.max(
420484
Date.parse(baseline.capturedAt),
@@ -424,24 +488,51 @@ export function selectBootstrapOutputs(outputs, baseline, installation, bootstra
424488
const castPrs = outputs.castPrs.filter((pr) => (
425489
pr.number > baseline.maximumPullRequestNumber
426490
&& Date.parse(pr.createdAt) >= cutoff
427-
&& pr.headRefName === CAST_BRANCH
428-
&& pr.baseRefName === baseline.defaultBranch
429491
&& pr.title === CAST_PR_TITLE
430-
&& pr.isDraft === true
431492
));
432493
const issues = outputs.issues.filter((issue) => (
433494
issue.number > baseline.maximumIssueNumber
434495
&& Date.parse(issue.createdAt) >= cutoff
435496
&& issue.title === RESEARCH_ISSUE_TITLE
436-
&& issue.body?.startsWith(RESEARCH_MARKER)
437497
));
438498
if (castPrs.length > 1 || issues.length > 1) {
439499
throw new Error(
440500
`Ambiguous current bootstrap outputs: ${castPrs.length} Cast PRs and ${issues.length} research issues.`,
441501
);
442502
}
443503
if (castPrs.length === 1 && issues.length === 1) {
444-
return { castPr: castPrs[0], researchIssue: issues[0] };
504+
const [castPr] = castPrs;
505+
const [researchIssue] = issues;
506+
const castMarker = parseBootstrapProvenance(castPr.body, 'Cast PR');
507+
const issueMarker = parseBootstrapProvenance(researchIssue.body, 'Research issue');
508+
const expectedMarker = {
509+
schema: 1,
510+
repository: outputs.target,
511+
run_id: String(bootstrapRun.databaseId),
512+
install_sha: installation.mergeCommit.oid,
513+
cast_sha: castPr.headSha,
514+
};
515+
if (JSON.stringify(castMarker) !== JSON.stringify(expectedMarker)
516+
|| JSON.stringify(issueMarker) !== JSON.stringify(expectedMarker)) {
517+
throw new Error('Bootstrap outputs do not share the expected current-run provenance.');
518+
}
519+
if (!researchIssue.body.startsWith(`${RESEARCH_MARKER}\n`)) {
520+
throw new Error('Research issue is missing the canonical bootstrap marker.');
521+
}
522+
if (!sameAuthor(castPr.author, outputs.expectedAuthor)
523+
|| !sameAuthor(researchIssue.author, outputs.expectedAuthor)) {
524+
throw new Error('Bootstrap outputs were not authored by the expected GitHub Actions bot.');
525+
}
526+
if (castPr.headRepository !== outputs.target
527+
|| castPr.headRefName !== CAST_BRANCH
528+
|| castPr.baseRefName !== baseline.defaultBranch
529+
|| castPr.baseSha !== installation.mergeCommit.oid
530+
|| castPr.headSha !== outputs.castBranchSha
531+
|| !SHA_PATTERN.test(castPr.headSha)
532+
|| castPr.isDraft !== true) {
533+
throw new Error('Bootstrap Cast PR repository, branch, SHA, base, or draft state is invalid.');
534+
}
535+
return { castPr, researchIssue };
445536
}
446537
return null;
447538
}

‎test/gh-aw-bootstrap-workflow.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,13 @@ describe('automatic Squad bootstrap workflow', () => {
738738
expect(lock).toContain('"payload_sha256"');
739739
expect(lock).not.toMatch(/"materialize-bootstrap":\{"inputs":\{"payload":/);
740740
expect(lock).toContain('reconstructBootstrapPayload(items[0])');
741+
expect(lock).toContain('SQUAD_BOOTSTRAP_INSTALL_SHA: ${{ github.sha }}');
742+
expect(lock).toContain('SQUAD_BOOTSTRAP_REPOSITORY: ${{ github.repository }}');
743+
expect(lock).toContain('SQUAD_BOOTSTRAP_RUN_ID: ${{ github.run_id }}');
744+
expect(lock).toContain('squad:bootstrap-provenance');
745+
expect(lock).toContain('pullRequestDetails.head.repo?.full_name !== provenance.repository');
746+
expect(lock).toContain('body: `${provenanceMarker}\\n${prBodyWithoutProvenance}`');
747+
expect(lock).toContain('body: markedIssueBody');
741748
expect(lock).not.toMatch(/\$\{\{[^}]*\\u00(?:26|3[cCeE])/);
742749
}, 180000);
743750

0 commit comments

Comments
 (0)