Skip to content

Commit cd6d7d6

Browse files
bradygasterCopilot
andcommitted
fix: bind hosted bootstrap evidence (Part of #2075)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 26debd4 commit cd6d7d6

2 files changed

Lines changed: 485 additions & 38 deletions

File tree

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

Lines changed: 281 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ const TRUSTED_SOURCE = Object.freeze({
3030
ownerId: 41929050,
3131
});
3232
const SHA_PATTERN = /^[0-9a-f]{40}$/;
33+
const INSTALL_PR_TITLE = 'ci: install Squad agentic workflows';
34+
const PROBE_PR_TITLE = 'test: add Squad bootstrap trigger probe';
35+
const CAST_BRANCH = 'squad/bootstrap-cast';
36+
const CAST_PR_TITLE = '[squad] Cast your Squad';
37+
const RESEARCH_ISSUE_TITLE = '[Research Proposals] Agent-discovered repo opportunities';
38+
const RESEARCH_MARKER = '<!-- squad:bootstrap-opportunities schema=1 -->';
3339
const SAFE_CHILD_ENV = Object.freeze([
3440
'CI',
3541
'GH_CONFIG_DIR',
@@ -113,6 +119,53 @@ function ghJson(args, options = {}) {
113119
return output ? JSON.parse(output) : null;
114120
}
115121

122+
const githubAdapter = Object.freeze({
123+
getDefaultBranchSha(target, branch) {
124+
return ghJson([
125+
'api', `repos/${target}/branches/${branch}`,
126+
'--jq', '{sha:.commit.sha}',
127+
]).sha;
128+
},
129+
getTree(target, sha) {
130+
return ghJson([
131+
'api', `repos/${target}/git/trees/${sha}?recursive=1`,
132+
'--jq', '{truncated,paths:[.tree[] | select(.type == "blob") | .path]}',
133+
]);
134+
},
135+
listBranches(target) {
136+
return ghJson([
137+
'api', '--paginate', '--slurp', `repos/${target}/branches?per_page=100`,
138+
]).flat().map(({ name }) => ({ name }));
139+
},
140+
listPullRequests(target) {
141+
return ghJson([
142+
'api', '--paginate', '--slurp', `repos/${target}/pulls?state=all&per_page=100`,
143+
]).flat().map((pullRequest) => ({
144+
number: pullRequest.number,
145+
url: pullRequest.html_url,
146+
title: pullRequest.title,
147+
body: pullRequest.body,
148+
createdAt: pullRequest.created_at,
149+
isDraft: pullRequest.draft,
150+
headRefName: pullRequest.head.ref,
151+
baseRefName: pullRequest.base.ref,
152+
state: pullRequest.state,
153+
}));
154+
},
155+
listIssues(target) {
156+
return ghJson([
157+
'api', '--paginate', '--slurp', `repos/${target}/issues?state=all&per_page=100`,
158+
]).flat().filter((issue) => !issue.pull_request).map((issue) => ({
159+
number: issue.number,
160+
url: issue.html_url,
161+
title: issue.title,
162+
body: issue.body,
163+
createdAt: issue.created_at,
164+
state: issue.state,
165+
}));
166+
},
167+
});
168+
116169
function sourceGhJson(args) {
117170
const token = process.env.SOURCE_READ_TOKEN ?? process.env.GH_TOKEN;
118171
if (!token) throw new Error('A source repository read token is required.');
@@ -196,7 +249,103 @@ function assertExactStagedDiff(cwd, expected) {
196249
if (deleted) throw new Error(`Staged deletions are forbidden: ${deleted}`);
197250
}
198251

199-
export function authorizeTarget(args, sourceInfo) {
252+
function packageArtifactCategories(contract) {
253+
return {
254+
workflowDestinations: contract.workflows.map(({ destination }) => destination),
255+
workflowLocks: contract.workflows.map(({ lock }) => lock),
256+
runtimePackageDestinations: contract.runtime.map(({ packageDestination }) => packageDestination),
257+
runtimeDestinations: contract.runtime.map(({ destination }) => destination),
258+
manifest: [CONTRACT_DESTINATION],
259+
ownership: [OWNERSHIP_DESTINATION],
260+
skill: contract.skills.map(({ destination }) => destination),
261+
triggerSentinel: [TRIGGER_PROBE_DESTINATION],
262+
};
263+
}
264+
265+
function matchingPriorRunArtifacts(branches, pullRequests, issues) {
266+
return {
267+
branches: branches.filter(({ name }) => name.startsWith('squad-e2e/install-')
268+
|| name.startsWith('squad-e2e/probe-')
269+
|| name === CAST_BRANCH),
270+
pullRequests: pullRequests.filter(({ headRefName, title }) => (
271+
headRefName.startsWith('squad-e2e/install-')
272+
|| headRefName.startsWith('squad-e2e/probe-')
273+
|| headRefName === CAST_BRANCH
274+
|| title === INSTALL_PR_TITLE
275+
|| title === PROBE_PR_TITLE
276+
|| title === CAST_PR_TITLE
277+
)),
278+
issues: issues.filter(({ title, body }) => (
279+
title === RESEARCH_ISSUE_TITLE || (body ?? '').includes(RESEARCH_MARKER)
280+
)),
281+
};
282+
}
283+
284+
export function assertPristineTarget(target, defaultBranch, contract, github = githubAdapter, now = Date.now) {
285+
if (contract.workflows.length !== 7 || contract.runtime.length !== 15 || contract.skills.length !== 1) {
286+
throw new Error('Trusted package topology must be exactly 7 workflows, 15 runtime resources, and 1 skill.');
287+
}
288+
const defaultBranchSha = github.getDefaultBranchSha(target, defaultBranch);
289+
assertSha(defaultBranchSha, 'Target default-branch SHA');
290+
const tree = github.getTree(target, defaultBranchSha);
291+
if (tree.truncated) throw new Error('Target default-branch tree response was truncated.');
292+
const paths = new Set(tree.paths);
293+
const categories = packageArtifactCategories(contract);
294+
const existingPaths = Object.entries(categories).flatMap(([category, candidates]) => (
295+
candidates.filter((path) => paths.has(path)).map((path) => ({ category, path }))
296+
));
297+
const packageNamespace = [...paths].filter((path) => path.startsWith('.github/aw/squad/'));
298+
const branches = github.listBranches(target);
299+
const pullRequests = github.listPullRequests(target);
300+
const issues = github.listIssues(target);
301+
const prior = matchingPriorRunArtifacts(branches, pullRequests, issues);
302+
if (existingPaths.length > 0 || packageNamespace.length > 0
303+
|| prior.branches.length > 0 || prior.pullRequests.length > 0 || prior.issues.length > 0) {
304+
throw new Error(`Target is not pristine: ${JSON.stringify({
305+
packagePaths: existingPaths,
306+
packageNamespace,
307+
priorRunBranches: prior.branches.map(({ name }) => name),
308+
priorRunPullRequests: prior.pullRequests.map(({ number }) => number),
309+
priorRunIssues: prior.issues.map(({ number }) => number),
310+
})}`);
311+
}
312+
return {
313+
capturedAt: new Date(now()).toISOString(),
314+
defaultBranch,
315+
defaultBranchSha,
316+
maximumPullRequestNumber: Math.max(0, ...pullRequests.map(({ number }) => number)),
317+
maximumIssueNumber: Math.max(0, ...issues.map(({ number }) => number)),
318+
};
319+
}
320+
321+
export function assertTargetDefaultBranch(
322+
target,
323+
defaultBranch,
324+
expectedSha,
325+
phase,
326+
github = githubAdapter,
327+
) {
328+
const actualSha = github.getDefaultBranchSha(target, defaultBranch);
329+
if (actualSha !== expectedSha) {
330+
throw new Error(
331+
`Target default branch moved before ${phase}: expected ${expectedSha}, found ${actualSha}.`,
332+
);
333+
}
334+
}
335+
336+
export function guardedTargetMutation({
337+
target,
338+
defaultBranch,
339+
expectedSha,
340+
phase,
341+
mutate,
342+
github = githubAdapter,
343+
}) {
344+
assertTargetDefaultBranch(target, defaultBranch, expectedSha, phase, github);
345+
return mutate();
346+
}
347+
348+
export function authorizeTarget(args, sourceInfo, contract, github = githubAdapter) {
200349
const target = requireArg(args, 'target');
201350
const expectedRepositoryId = Number(requireArg(args, 'target_repository_id'));
202351
const expectedOwnerId = Number(requireArg(args, 'target_owner_id'));
@@ -233,7 +382,8 @@ export function authorizeTarget(args, sourceInfo) {
233382
|| workflowPermissions.can_approve_pull_request_reviews !== true) {
234383
throw new Error('Target Actions permissions do not match the supported secure configuration.');
235384
}
236-
return { target, info, workflowPermissions, actor };
385+
const baseline = assertPristineTarget(target, info.default_branch, contract, github);
386+
return { target, info, workflowPermissions, actor, baseline };
237387
}
238388

239389
function waitForBootstrapRun(target, headSha, startedAt, evidence) {
@@ -258,45 +408,122 @@ function waitForBootstrapRun(target, headSha, startedAt, evidence) {
258408
throw new Error(`Timed out waiting for bootstrap run at ${headSha}.`);
259409
}
260410

261-
function bootstrapOutputs(target) {
262-
const castPrs = ghJson([
263-
'pr', 'list', '--repo', target, '--state', 'all', '--head', 'squad/bootstrap-cast',
264-
'--json', 'number,url,isDraft,title,headRefName,baseRefName,state',
265-
]);
266-
const issues = ghJson([
267-
'issue', 'list', '--repo', target, '--state', 'all', '--limit', '100',
268-
'--search', '"[Research Proposals] Agent-discovered repo opportunities" in:title',
269-
'--json', 'number,url,title,body,state',
270-
]).filter((issue) => issue.title === '[Research Proposals] Agent-discovered repo opportunities'
271-
&& issue.body.includes('<!-- squad:bootstrap-opportunities schema=1 -->'));
272-
return { castPrs, issues };
411+
function bootstrapOutputs(target, github = githubAdapter) {
412+
return {
413+
castPrs: github.listPullRequests(target),
414+
issues: github.listIssues(target),
415+
};
273416
}
274417

275-
function waitForBootstrapOutputs(target) {
276-
const deadline = Date.now() + 5 * 60 * 1000;
277-
while (Date.now() < deadline) {
278-
const outputs = bootstrapOutputs(target);
279-
if (outputs.castPrs.length === 1 && outputs.castPrs[0].isDraft && outputs.issues.length === 1) {
280-
return { castPr: outputs.castPrs[0], researchIssue: outputs.issues[0] };
281-
}
282-
sleep(10_000);
418+
export function selectBootstrapOutputs(outputs, baseline, installation, bootstrapRun) {
419+
const cutoff = Math.max(
420+
Date.parse(baseline.capturedAt),
421+
Date.parse(installation.mergedAt),
422+
Date.parse(bootstrapRun.createdAt),
423+
);
424+
const castPrs = outputs.castPrs.filter((pr) => (
425+
pr.number > baseline.maximumPullRequestNumber
426+
&& Date.parse(pr.createdAt) >= cutoff
427+
&& pr.headRefName === CAST_BRANCH
428+
&& pr.baseRefName === baseline.defaultBranch
429+
&& pr.title === CAST_PR_TITLE
430+
&& pr.isDraft === true
431+
));
432+
const issues = outputs.issues.filter((issue) => (
433+
issue.number > baseline.maximumIssueNumber
434+
&& Date.parse(issue.createdAt) >= cutoff
435+
&& issue.title === RESEARCH_ISSUE_TITLE
436+
&& issue.body?.startsWith(RESEARCH_MARKER)
437+
));
438+
if (castPrs.length > 1 || issues.length > 1) {
439+
throw new Error(
440+
`Ambiguous current bootstrap outputs: ${castPrs.length} Cast PRs and ${issues.length} research issues.`,
441+
);
442+
}
443+
if (castPrs.length === 1 && issues.length === 1) {
444+
return { castPr: castPrs[0], researchIssue: issues[0] };
445+
}
446+
return null;
447+
}
448+
449+
export function waitForBootstrapOutputs(
450+
target,
451+
baseline,
452+
installation,
453+
bootstrapRun,
454+
{
455+
github = githubAdapter,
456+
now = Date.now,
457+
pause = sleep,
458+
timeoutMs = 5 * 60 * 1000,
459+
pollMs = 10_000,
460+
} = {},
461+
) {
462+
const deadline = now() + timeoutMs;
463+
while (now() < deadline) {
464+
const selected = selectBootstrapOutputs(
465+
bootstrapOutputs(target, github),
466+
baseline,
467+
installation,
468+
bootstrapRun,
469+
);
470+
if (selected) return selected;
471+
pause(pollMs);
283472
}
284473
throw new Error('Timed out waiting for the draft Cast PR and bootstrap research issue.');
285474
}
286475

287-
function createAndMergePr({ cwd, target, defaultBranch, branch, title, body, evidence }) {
476+
function createAndMergePr({
477+
cwd,
478+
target,
479+
defaultBranch,
480+
expectedBaseSha,
481+
branch,
482+
title,
483+
body,
484+
evidence,
485+
github = githubAdapter,
486+
}) {
288487
privileged('gh', ['auth', 'setup-git']);
289-
privileged('git', ['push', '--set-upstream', 'origin', branch], { cwd, capture: false });
290-
const prUrl = privileged('gh', [
291-
'pr', 'create', '--repo', target, '--base', defaultBranch, '--head', branch,
292-
'--title', title, '--body', body,
293-
], { cwd });
488+
guardedTargetMutation({
489+
target,
490+
defaultBranch,
491+
expectedSha: expectedBaseSha,
492+
phase: `${title} push`,
493+
github,
494+
mutate: () => privileged('git', ['push', '--set-upstream', 'origin', branch], {
495+
cwd,
496+
capture: false,
497+
}),
498+
});
499+
const prUrl = guardedTargetMutation({
500+
target,
501+
defaultBranch,
502+
expectedSha: expectedBaseSha,
503+
phase: `${title} PR creation`,
504+
github,
505+
mutate: () => privileged('gh', [
506+
'pr', 'create', '--repo', target, '--base', defaultBranch, '--head', branch,
507+
'--title', title, '--body', body,
508+
], { cwd }),
509+
});
294510
const pr = ghJson(['pr', 'view', prUrl, '--repo', target, '--json', 'number,url,state,headRefName,baseRefName']);
295511
writeJson(resolve(evidence, `pr-${pr.number}-created.json`), pr);
296-
privileged('gh', ['pr', 'merge', String(pr.number), '--repo', target, '--merge', '--delete-branch'], {
297-
cwd,
298-
capture: false,
299-
timeout: 300_000,
512+
guardedTargetMutation({
513+
target,
514+
defaultBranch,
515+
expectedSha: expectedBaseSha,
516+
phase: `${title} merge`,
517+
github,
518+
mutate: () => privileged(
519+
'gh',
520+
['pr', 'merge', String(pr.number), '--repo', target, '--merge', '--delete-branch'],
521+
{
522+
cwd,
523+
capture: false,
524+
timeout: 300_000,
525+
},
526+
),
300527
});
301528
const merged = ghJson([
302529
'pr', 'view', String(pr.number), '--repo', target,
@@ -328,7 +555,7 @@ function hosted(args, repositoryRoot) {
328555
if (contract.workflows.length !== 7 || contract.runtime.length !== 15 || contract.skills.length !== 1) {
329556
throw new Error('Trusted package topology must be exactly 7 workflows, 15 runtime resources, and 1 skill.');
330557
}
331-
const targetState = authorizeTarget(args, sourceInfo);
558+
const targetState = authorizeTarget(args, sourceInfo, contract);
332559
mkdirSync(evidence, { recursive: true });
333560
writeJson(resolve(evidence, 'preflight.json'), targetState);
334561

@@ -341,6 +568,12 @@ function hosted(args, repositoryRoot) {
341568
capture: false,
342569
timeout: 300_000,
343570
});
571+
const clonedHead = runChild('git', ['rev-parse', 'HEAD'], { cwd: checkout });
572+
if (clonedHead !== targetState.baseline.defaultBranchSha) {
573+
throw new Error(
574+
`Cloned target HEAD does not match pristine baseline: expected ${targetState.baseline.defaultBranchSha}, found ${clonedHead}.`,
575+
);
576+
}
344577
runChild('git', ['config', 'user.name', 'Squad hosted E2E'], { cwd: checkout });
345578
runChild('git', ['config', 'user.email', 'github-actions[bot]@users.noreply.github.com'], { cwd: checkout });
346579
const installBranch = `squad-e2e/install-${process.env.GITHUB_RUN_ID ?? sourceSha.slice(0, 12)}`;
@@ -384,8 +617,9 @@ function hosted(args, repositoryRoot) {
384617
cwd: checkout,
385618
target: targetState.target,
386619
defaultBranch: targetState.info.default_branch,
620+
expectedBaseSha: targetState.baseline.defaultBranchSha,
387621
branch: installBranch,
388-
title: 'ci: install Squad agentic workflows',
622+
title: INSTALL_PR_TITLE,
389623
body: 'One-shot hosted E2E installation. Generated Squad work remains human-reviewed and is not merged.',
390624
evidence,
391625
});
@@ -396,7 +630,17 @@ function hosted(args, repositoryRoot) {
396630
installStartedAt,
397631
evidence,
398632
);
399-
const outputs = waitForBootstrapOutputs(targetState.target);
633+
writeJson(resolve(evidence, 'installation-identity.json'), {
634+
baseline: targetState.baseline,
635+
installation,
636+
bootstrapRun: installRun,
637+
});
638+
const outputs = waitForBootstrapOutputs(
639+
targetState.target,
640+
targetState.baseline,
641+
installation,
642+
installRun,
643+
);
400644

401645
runChild('git', ['fetch', 'origin', targetState.info.default_branch], { cwd: checkout });
402646
const probeBranch = `squad-e2e/probe-${process.env.GITHUB_RUN_ID ?? sourceSha.slice(0, 12)}`;
@@ -421,8 +665,9 @@ function hosted(args, repositoryRoot) {
421665
cwd: checkout,
422666
target: targetState.target,
423667
defaultBranch: targetState.info.default_branch,
668+
expectedBaseSha: installation.mergeCommit.oid,
424669
branch: probeBranch,
425-
title: 'test: add Squad bootstrap trigger probe',
670+
title: PROBE_PR_TITLE,
426671
body: 'One-shot non-executable sentinel proving the bootstrap path trigger.',
427672
evidence,
428673
});

0 commit comments

Comments
 (0)