diff --git a/test/gh-aw-bootstrap-workflow.test.ts b/test/gh-aw-bootstrap-workflow.test.ts index 611bc64ff..643c65096 100644 --- a/test/gh-aw-bootstrap-workflow.test.ts +++ b/test/gh-aw-bootstrap-workflow.test.ts @@ -1118,6 +1118,187 @@ describe('automatic Squad bootstrap workflow', () => { }); }); +describe('gh-aw: squad-bootstrap candidate lifetime', () => { + const baseSha = 'c'.repeat(40); + const castSha = 'b'.repeat(40); + const prUrl = 'https://github.com/octo/example/pull/3'; + + function writerScript(source: string): string { + const frontmatter = source.split('\n---\n')[0].replace(/^---\n/, ''); + const steps = parse(frontmatter)['safe-outputs'].jobs['materialize-bootstrap'].steps; + const script = steps.find((step: { with?: { script?: string } }) => step.with?.script)?.with.script; + expect(script, 'live materialize-bootstrap writer script must exist').toBeTypeOf('string'); + return script; + } + + function writerFixture( + scenario: 'success' | 'opt-out' | 'permission-denied' | 'api-error' = 'success', + source = WORKFLOW, + ) { + const fixture = createFixture(); + const workspace = mkdtempSync(join(tmpdir(), 'gh-aw-bootstrap-writer-')); + workspaces.push(workspace); + const checkout = join(workspace, 'bootstrap-repo'); + cpSync(fixture.root, checkout, { recursive: true }); + cpSync(resolve(ROOT, 'workflows/shared'), join(checkout, '.github/workflows/shared'), { recursive: true }); + const outputPath = join(workspace, 'output.json'); + writeFileSync(outputPath, JSON.stringify({ + items: [{ type: 'materialize_bootstrap', ...createBootstrapPayloadEnvelope(JSON.stringify(fixture.payload)) }], + })); + let candidate = ''; + const captureCandidate = (path: string) => { + candidate = path; + workspaces.push(path); + }; + const script = writerScript(source).replace( + "const candidate = mkdtempSync(join(tmpdir(), 'squad-bootstrap-candidate-'));", + "const candidate = mkdtempSync(join(tmpdir(), 'squad-bootstrap-candidate-')); captureCandidate(candidate);", + ); + const runScript = compileFunction( + `return (async () => {\n${script}\n})();`, + ['github', 'context', 'core', 'process', 'captureCandidate'], + { importModuleDynamically: vmConstants.USE_MAIN_CONTEXT_DEFAULT_LOADER }, + ); + const issueCalls: Array<{ title: string; body: string }> = []; + const commentCalls: Array<{ issue_number: number; body: string }> = []; + const updates: Array<{ body: string }> = []; + const writesWithCandidate: boolean[] = []; + const recordWrite = () => writesWithCandidate.push( + existsSync(join(candidate, '.squad/team.md')) && + existsSync(join(candidate, 'package.json')) && + existsSync(join(candidate, '.github/workflows/shared/builtins/scribe-charter.md')), + ); + const pull = { + number: 3, + state: scenario === 'opt-out' ? 'closed' : 'open', + title: BOOTSTRAP_PR_TITLE, + head: { ref: BOOTSTRAP_BRANCH, sha: castSha, repo: { full_name: 'octo/example' } }, + base: { ref: 'main' }, + html_url: prUrl, + body: fixture.payload.pr_body, + merged_at: null, + }; + let branchCreated = false; + let pullCreated = false; + const github = { + paginate: async (method: () => Promise) => method(), + rest: { + git: { + getRef: async ({ ref }: { ref: string }) => { + if (ref === `heads/${BOOTSTRAP_BRANCH}` && !branchCreated) { + throw Object.assign(new Error('Not Found'), { status: 404 }); + } + return { data: { object: { sha: ref === 'heads/main' ? baseSha : castSha } } }; + }, + getCommit: async () => ({ data: { tree: { sha: 'base-tree' } } }), + createBlob: async () => { recordWrite(); return { data: { sha: 'blob' } }; }, + createTree: async () => ({ data: { sha: 'tree' } }), + createCommit: async () => ({ data: { sha: castSha } }), + createRef: async () => { branchCreated = true; }, + }, + pulls: { + list: async () => scenario === 'opt-out' || pullCreated ? [pull] : [], + create: async () => { + recordWrite(); + if (scenario === 'permission-denied') throw new Error(CREATE_PR_PERMISSION_DENIED_TEXT); + pullCreated = true; + return { data: pull }; + }, + get: async () => ({ data: pull }), + update: async (args: { body: string }) => { recordWrite(); updates.push(args); }, + }, + issues: { + listForRepo: async () => [], + listComments: async () => [], + create: async (args: { title: string; body: string }) => { + recordWrite(); + issueCalls.push(args); + if (scenario === 'api-error') throw new Error('research issue API outage'); + return { data: { number: 6, html_url: 'https://github.com/octo/example/issues/6' } }; + }, + createComment: async (args: { issue_number: number; body: string }) => { + recordWrite(); + commentCalls.push(args); + }, + }, + }, + }; + const failures: string[] = []; + const run = () => runScript( + github, + { repo: { owner: 'octo', repo: 'example' }, sha: baseSha, runId: 123, eventName: 'push' }, + { info: () => {}, warning: () => {}, setFailed: (message: string) => failures.push(message) }, + { env: { + GITHUB_WORKSPACE: workspace, + GH_AW_AGENT_OUTPUT: outputPath, + GITHUB_SERVER_URL: 'https://github.com', + SQUAD_BOOTSTRAP_DEFAULT_BRANCH: 'main', + SQUAD_BOOTSTRAP_REPOSITORY: 'octo/example', + SQUAD_BOOTSTRAP_INSTALL_SHA: baseSha, + SQUAD_BOOTSTRAP_RUN_ID: '123', + } }, + captureCandidate, + ); + return { + run, issueCalls, commentCalls, updates, writesWithCandidate, failures, + candidate: () => candidate, + }; + } + + it('keeps the real candidate through resolved validation, issue and research creation, then removes it', async () => { + const fixture = writerFixture(); + await fixture.run(); + expect(fixture.failures).toEqual([]); + expect(fixture.issueCalls).toHaveLength(1); + expect(fixture.issueCalls[0].title).toBe(BOOTSTRAP_ISSUE_TITLE); + expect(fixture.issueCalls[0].body).toContain(`[draft Cast PR](${prUrl})`); + expect(fixture.issueCalls[0].body).not.toContain('{{CAST_PR_URL}}'); + expect(fixture.updates[0].body).toContain('"cast_sha":"' + castSha + '"'); + expect(fixture.commentCalls.map(call => call.issue_number)).toEqual([3, 6]); + expect(fixture.commentCalls[1].body).toBe(createBootstrapResearchComment(issueBody(prUrl), 6)); + expect(fixture.writesWithCandidate.length).toBeGreaterThan(0); + expect(fixture.writesWithCandidate.every(Boolean), 'candidate files must survive all GitHub writes').toBe(true); + expect(existsSync(fixture.candidate()), 'success must remove the entire candidate tree').toBe(false); + }); + + it.each(['opt-out', 'permission-denied'] as const)('removes the candidate on the %s early return', async (scenario) => { + const fixture = writerFixture(scenario); + await fixture.run(); + expect(fixture.failures).toEqual([]); + expect(fixture.commentCalls).toEqual([]); + expect(fixture.issueCalls).toHaveLength(scenario === 'permission-denied' ? 1 : 0); + expect(existsSync(fixture.candidate()), 'early return must remove the entire candidate tree').toBe(false); + }); + + it('removes the candidate while propagating a post-validation API error', async () => { + const fixture = writerFixture('api-error'); + await expect(fixture.run()).rejects.toThrow('research issue API outage'); + expect(fixture.commentCalls).toHaveLength(1); + expect(fixture.writesWithCandidate.every(Boolean)).toBe(true); + expect(existsSync(fixture.candidate()), 'API error must remove the entire candidate tree').toBe(false); + }); + + it('rejects a realistic mutation restoring premature cleanup with missing-path diagnostics', async () => { + const prematureCleanup = WORKFLOW + .replace( + ' writeFileSync(payloadPath, payloadText);\n', + ' writeFileSync(payloadPath, payloadText);\n' + + ' } finally {\n' + + ' rmSync(candidate, { recursive: true, force: true });\n' + + ' }\n', + ) + .replace( + ' } finally {\n rmSync(candidate, { recursive: true, force: true });\n }\n---', + '---', + ); + expect(prematureCleanup).not.toBe(WORKFLOW); + const fixture = writerFixture('success', prematureCleanup); + await expect(fixture.run()).rejects.toThrow(/Squad bootstrap validation failed:[\s\S]*\.squad\/team\.md/); + expect(fixture.issueCalls).toEqual([]); + expect(fixture.commentCalls).toHaveLength(1); + }); +}); + // Regression coverage for the `can_approve_pull_request_reviews=false` policy // finding: GITHUB_TOKEN pull request creation and review approval cannot be // separated (proven against gh-aw's own create_pull_request.cjs / @@ -1137,9 +1318,11 @@ describe('gh-aw: squad-bootstrap pull-request-creation permission-denied fallbac function extractConstArrow(source: string, declaration: string): string { const startIndex = source.indexOf(declaration); expect(startIndex, `declaration not found: ${declaration}`).toBeGreaterThanOrEqual(0); - const endIndex = source.indexOf('\n };', startIndex); + const indent = source.slice(source.lastIndexOf('\n', startIndex) + 1, startIndex); + const endMarker = `\n${indent}};`; + const endIndex = source.indexOf(endMarker, startIndex); expect(endIndex, `no closing '};' for: ${declaration}`).toBeGreaterThanOrEqual(0); - return source.slice(startIndex, endIndex + '\n };'.length); + return source.slice(startIndex, endIndex + endMarker.length); } /** Read the `if (!pullRequest) { ... } else { ... }` statement verbatim from source. */ @@ -1147,7 +1330,8 @@ describe('gh-aw: squad-bootstrap pull-request-creation permission-denied fallbac const startMarker = 'let pullRequest = snapshot.state.pull_request;'; const startIndex = source.indexOf(startMarker); expect(startIndex, 'pullRequest block start not found').toBeGreaterThanOrEqual(0); - const elseCloseMarker = 'await assertRemotePayload(ref);\n }'; + const indent = source.slice(source.lastIndexOf('\n', startIndex) + 1, startIndex); + const elseCloseMarker = `await assertRemotePayload(ref);\n${indent}}`; const elseCloseIndex = source.indexOf(elseCloseMarker, startIndex); expect(elseCloseIndex, 'pullRequest block end not found').toBeGreaterThanOrEqual(0); return source.slice(startIndex, elseCloseIndex + elseCloseMarker.length); diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index ce58f0da8..2d80454a1 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -344,389 +344,389 @@ safe-outputs: writeFileSync(target, file.content); } writeFileSync(payloadPath, payloadText); - } finally { - rmSync(candidate, { recursive: true, force: true }); - } - const listState = async () => { - const pullRequests = await github.paginate(github.rest.pulls.list, { - ...context.repo, - state: 'all', - per_page: 100, - }); - const issues = (await github.paginate(github.rest.issues.listForRepo, { - ...context.repo, - state: 'all', - per_page: 100, - })).filter((issue) => !issue.pull_request); - const preliminary = stateModule.classifyBootstrapState({ - pullRequests, - issues, - defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - }); - const comments = preliminary.issue - ? await github.paginate(github.rest.issues.listComments, { - ...context.repo, - issue_number: preliminary.issue.number, - per_page: 100, - }) - : []; - return { - pullRequests, - issues, - comments, - state: stateModule.classifyBootstrapState({ + const listState = async () => { + const pullRequests = await github.paginate(github.rest.pulls.list, { + ...context.repo, + state: 'all', + per_page: 100, + }); + const issues = (await github.paginate(github.rest.issues.listForRepo, { + ...context.repo, + state: 'all', + per_page: 100, + })).filter((issue) => !issue.pull_request); + const preliminary = stateModule.classifyBootstrapState({ pullRequests, issues, - comments, defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - }), + }); + const comments = preliminary.issue + ? await github.paginate(github.rest.issues.listComments, { + ...context.repo, + issue_number: preliminary.issue.number, + per_page: 100, + }) + : []; + return { + pullRequests, + issues, + comments, + state: stateModule.classifyBootstrapState({ + pullRequests, + issues, + comments, + defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + }), + }; }; - }; - - let snapshot = await listState(); - if (snapshot.state.action === 'opt_out') { - core.info('A closed-unmerged bootstrap Cast PR records human opt-out; no replacement was created.'); - return; - } - if (snapshot.state.action === 'noop') { - core.info('The deterministic Cast PR, research-proposals issue, and research artifact already exist.'); - return; - } - const getRef = async (ref) => { - try { - return (await github.rest.git.getRef({ ...context.repo, ref })).data; - } catch (error) { - if (error.status === 404) return null; - throw error; - } - }; - const assertRemotePayload = async (ref) => { - for (const file of payload.files) { - const response = await github.rest.repos.getContent({ - ...context.repo, - path: file.path, - ref, - }); - if (Array.isArray(response.data) || response.data.type !== 'file') { - throw new Error(`Bootstrap branch path is not a file: ${file.path}`); - } - const remote = Buffer.from(response.data.content, 'base64').toString('utf8').replace(/\r\n/g, '\n'); - if (remote !== String(file.content).replace(/\r\n/g, '\n')) { - throw new Error(`Existing bootstrap branch diverges at ${file.path}; refusing replacement.`); - } + let snapshot = await listState(); + if (snapshot.state.action === 'opt_out') { + core.info('A closed-unmerged bootstrap Cast PR records human opt-out; no replacement was created.'); + return; } - if (ref !== process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH) { - const comparison = await github.rest.repos.compareCommitsWithBasehead({ - ...context.repo, - basehead: `${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}...${ref}`, - per_page: 100, - }); - const changed = (comparison.data.files || []).map((file) => file.filename).sort(); - const allowed = payload.files.map((file) => file.path).sort(); - if (JSON.stringify(changed) !== JSON.stringify(allowed)) { - throw new Error(`Existing bootstrap branch changed files outside the validated payload: ${changed.join(', ')}`); - } + if (snapshot.state.action === 'noop') { + core.info('The deterministic Cast PR, research-proposals issue, and research artifact already exist.'); + return; } - }; - let pullRequest = snapshot.state.pull_request; - if (!pullRequest) { - const branchRefName = `heads/${stateModule.BOOTSTRAP_BRANCH}`; - const existingRef = await getRef(branchRefName); - if (existingRef) { - await assertRemotePayload(stateModule.BOOTSTRAP_BRANCH); - } else { - const baseRef = await github.rest.git.getRef({ - ...context.repo, - ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, - }); - const baseCommit = await github.rest.git.getCommit({ - ...context.repo, - commit_sha: baseRef.data.object.sha, - }); - const tree = []; + const getRef = async (ref) => { + try { + return (await github.rest.git.getRef({ ...context.repo, ref })).data; + } catch (error) { + if (error.status === 404) return null; + throw error; + } + }; + const assertRemotePayload = async (ref) => { for (const file of payload.files) { - const blob = await github.rest.git.createBlob({ + const response = await github.rest.repos.getContent({ ...context.repo, - content: Buffer.from(file.content, 'utf8').toString('base64'), - encoding: 'base64', - }); - tree.push({ path: file.path, - mode: '100644', - type: 'blob', - sha: blob.data.sha, + ref, }); + if (Array.isArray(response.data) || response.data.type !== 'file') { + throw new Error(`Bootstrap branch path is not a file: ${file.path}`); + } + const remote = Buffer.from(response.data.content, 'base64').toString('utf8').replace(/\r\n/g, '\n'); + if (remote !== String(file.content).replace(/\r\n/g, '\n')) { + throw new Error(`Existing bootstrap branch diverges at ${file.path}; refusing replacement.`); + } } - const createdTree = await github.rest.git.createTree({ - ...context.repo, - base_tree: baseCommit.data.tree.sha, - tree, - }); - const commit = await github.rest.git.createCommit({ - ...context.repo, - message: 'chore(squad): add repository-derived Squad', - tree: createdTree.data.sha, - parents: [baseRef.data.object.sha], - }); - await github.rest.git.createRef({ - ...context.repo, - ref: `refs/heads/${stateModule.BOOTSTRAP_BRANCH}`, - sha: commit.data.sha, - }); - } - let created; - try { - created = await github.rest.pulls.create({ - ...context.repo, - title: stateModule.BOOTSTRAP_PR_TITLE, - head: stateModule.BOOTSTRAP_BRANCH, - base: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - body: payload.pr_body, - draft: true, - }); - } catch (prError) { - if (!stateModule.isCreatePullRequestPermissionDenied(prError)) { - throw prError; + if (ref !== process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH) { + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}...${ref}`, + per_page: 100, + }); + const changed = (comparison.data.files || []).map((file) => file.filename).sort(); + const allowed = payload.files.map((file) => file.path).sort(); + if (JSON.stringify(changed) !== JSON.stringify(allowed)) { + throw new Error(`Existing bootstrap branch changed files outside the validated payload: ${changed.join(', ')}`); + } } - core.warning(`Squad bootstrap could not create the Cast pull request: ${prError.message}`); - const pushedRef = await getRef(`heads/${stateModule.BOOTSTRAP_BRANCH}`); - if (!pushedRef) { - throw new Error( - 'Squad bootstrap cannot fall back to a manual pull request link because the candidate branch was not pushed.', - ); + }; + + let pullRequest = snapshot.state.pull_request; + if (!pullRequest) { + const branchRefName = `heads/${stateModule.BOOTSTRAP_BRANCH}`; + const existingRef = await getRef(branchRefName); + if (existingRef) { + await assertRemotePayload(stateModule.BOOTSTRAP_BRANCH); + } else { + const baseRef = await github.rest.git.getRef({ + ...context.repo, + ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, + }); + const baseCommit = await github.rest.git.getCommit({ + ...context.repo, + commit_sha: baseRef.data.object.sha, + }); + const tree = []; + for (const file of payload.files) { + const blob = await github.rest.git.createBlob({ + ...context.repo, + content: Buffer.from(file.content, 'utf8').toString('base64'), + encoding: 'base64', + }); + tree.push({ + path: file.path, + mode: '100644', + type: 'blob', + sha: blob.data.sha, + }); + } + const createdTree = await github.rest.git.createTree({ + ...context.repo, + base_tree: baseCommit.data.tree.sha, + tree, + }); + const commit = await github.rest.git.createCommit({ + ...context.repo, + message: 'chore(squad): add repository-derived Squad', + tree: createdTree.data.sha, + parents: [baseRef.data.object.sha], + }); + await github.rest.git.createRef({ + ...context.repo, + ref: `refs/heads/${stateModule.BOOTSTRAP_BRANCH}`, + sha: commit.data.sha, + }); } - const repository = `${context.repo.owner}/${context.repo.repo}`; - const compareUrl = stateModule.buildBootstrapPrFallbackCompareUrl({ - repository, - baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - headBranch: stateModule.BOOTSTRAP_BRANCH, - title: stateModule.BOOTSTRAP_PR_TITLE, - server: process.env.GITHUB_SERVER_URL, - }); - // context.sha is the default branch's exact commit this run executed on (the - // top-level exact default-branch ref gate - // guards both the push and workflow_dispatch trigger paths), so it is a - // reliable, zero-extra-API-call stand-in for "the base commit any fresh - // provenance record produced by this run would be bound to". - const baseSha = context.sha; - const existingFallback = stateModule.findExistingBootstrapPrFallbackIssue( - snapshot.issues, - stateModule.BOOTSTRAP_BRANCH, - { + let created; + try { + created = await github.rest.pulls.create({ + ...context.repo, + title: stateModule.BOOTSTRAP_PR_TITLE, + head: stateModule.BOOTSTRAP_BRANCH, + base: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + body: payload.pr_body, + draft: true, + }); + } catch (prError) { + if (!stateModule.isCreatePullRequestPermissionDenied(prError)) { + throw prError; + } + core.warning(`Squad bootstrap could not create the Cast pull request: ${prError.message}`); + const pushedRef = await getRef(`heads/${stateModule.BOOTSTRAP_BRANCH}`); + if (!pushedRef) { + throw new Error( + 'Squad bootstrap cannot fall back to a manual pull request link because the candidate branch was not pushed.', + ); + } + const repository = `${context.repo.owner}/${context.repo.repo}`; + const compareUrl = stateModule.buildBootstrapPrFallbackCompareUrl({ + repository, + baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + headBranch: stateModule.BOOTSTRAP_BRANCH, + title: stateModule.BOOTSTRAP_PR_TITLE, + server: process.env.GITHUB_SERVER_URL, + }); + // context.sha is the default branch's exact commit this run executed on (the + // top-level exact default-branch ref gate + // guards both the push and workflow_dispatch trigger paths), so it is a + // reliable, zero-extra-API-call stand-in for "the base commit any fresh + // provenance record produced by this run would be bound to". + const baseSha = context.sha; + const existingFallback = stateModule.findExistingBootstrapPrFallbackIssue( + snapshot.issues, + stateModule.BOOTSTRAP_BRANCH, + { + repository, + baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + baseSha, + headSha: pushedRef.object.sha, + }, + ); + if (existingFallback) { + core.info( + `A fallback issue already requests manual Cast pull request creation: ${existingFallback.html_url}`, + ); + return; + } + // squad-review-guard's validateBootstrapPrFallbackAttribution() only authorizes a + // fallback provenance record whose referenced run has event === 'push' (mirroring + // the bot-authored path's own push-only trust model). A workflow_dispatch run that + // reaches this branch would mint a fallback issue no Cast PR could ever satisfy, + // and future reruns would dedupe against that permanently-unusable issue forever + // (findExistingBootstrapPrFallbackIssue does not consider triggering event). Fail + // closed instead of minting a dead-end issue. + if (context.eventName !== 'push') { + throw new Error( + 'Squad bootstrap cannot open a trusted fallback issue because this run was triggered by ' + + `'${context.eventName}', not 'push'. The Squad review workflow only authorizes a Cast ` + + "pull request against a push-triggered bootstrap run's provenance. Re-run this workflow " + + 'via a push to a squad-related path on the default branch (for example, merging the ' + + 'pending installation changes) so a push-triggered run can open an authorizable fallback issue.', + ); + } + const runUrl = `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.SQUAD_BOOTSTRAP_RUN_ID}`; + const provenanceLine = stateModule.buildBootstrapPrFallbackProvenanceLine({ repository, + runId: process.env.SQUAD_BOOTSTRAP_RUN_ID, baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, baseSha, + headBranch: stateModule.BOOTSTRAP_BRANCH, headSha: pushedRef.object.sha, - }, - ); - if (existingFallback) { - core.info( - `A fallback issue already requests manual Cast pull request creation: ${existingFallback.html_url}`, - ); - return; - } - // squad-review-guard's validateBootstrapPrFallbackAttribution() only authorizes a - // fallback provenance record whose referenced run has event === 'push' (mirroring - // the bot-authored path's own push-only trust model). A workflow_dispatch run that - // reaches this branch would mint a fallback issue no Cast PR could ever satisfy, - // and future reruns would dedupe against that permanently-unusable issue forever - // (findExistingBootstrapPrFallbackIssue does not consider triggering event). Fail - // closed instead of minting a dead-end issue. - if (context.eventName !== 'push') { - throw new Error( - 'Squad bootstrap cannot open a trusted fallback issue because this run was triggered by ' + - `'${context.eventName}', not 'push'. The Squad review workflow only authorizes a Cast ` + - "pull request against a push-triggered bootstrap run's provenance. Re-run this workflow " + - 'via a push to a squad-related path on the default branch (for example, merging the ' + - 'pending installation changes) so a push-triggered run can open an authorizable fallback issue.', - ); - } - const runUrl = `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.SQUAD_BOOTSTRAP_RUN_ID}`; - const provenanceLine = stateModule.buildBootstrapPrFallbackProvenanceLine({ - repository, - runId: process.env.SQUAD_BOOTSTRAP_RUN_ID, - baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - baseSha, - headBranch: stateModule.BOOTSTRAP_BRANCH, - headSha: pushedRef.object.sha, - compareUrl, - }); - const fallbackBody = stateModule.buildBootstrapPrFallbackIssueBody({ - repository, - baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - headBranch: stateModule.BOOTSTRAP_BRANCH, - compareUrl, - runUrl, - provenanceLine, - }); - try { - const fallbackIssue = await github.rest.issues.create({ - ...context.repo, - title: stateModule.BOOTSTRAP_PR_FALLBACK_ISSUE_TITLE, - body: fallbackBody, + compareUrl, }); - core.warning( - `Opened a fallback issue for manual Cast pull request creation: ${fallbackIssue.data.html_url}`, - ); - return; - } catch (issueError) { - throw new Error( - `Failed to create the Cast pull request (${prError.message}) and failed to create the ` + - `fallback issue (${issueError.message}).`, - ); + const fallbackBody = stateModule.buildBootstrapPrFallbackIssueBody({ + repository, + baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + headBranch: stateModule.BOOTSTRAP_BRANCH, + compareUrl, + runUrl, + provenanceLine, + }); + try { + const fallbackIssue = await github.rest.issues.create({ + ...context.repo, + title: stateModule.BOOTSTRAP_PR_FALLBACK_ISSUE_TITLE, + body: fallbackBody, + }); + core.warning( + `Opened a fallback issue for manual Cast pull request creation: ${fallbackIssue.data.html_url}`, + ); + return; + } catch (issueError) { + throw new Error( + `Failed to create the Cast pull request (${prError.message}) and failed to create the ` + + `fallback issue (${issueError.message}).`, + ); + } } + pullRequest = { + number: created.data.number, + state: created.data.state, + merged: false, + url: created.data.html_url, + }; + } else { + const ref = pullRequest.merged + ? process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH + : stateModule.BOOTSTRAP_BRANCH; + await assertRemotePayload(ref); } - pullRequest = { - number: created.data.number, - state: created.data.state, - merged: false, - url: created.data.html_url, - }; - } else { - const ref = pullRequest.merged - ? process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH - : stateModule.BOOTSTRAP_BRANCH; - await assertRemotePayload(ref); - } - if (!pullRequest?.url) { - throw new Error('The deterministic Cast PR URL is unavailable after materialization.'); - } - const pullRequestDetails = (await github.rest.pulls.get({ - ...context.repo, - pull_number: pullRequest.number, - })).data; - const provenance = { - schema: 1, - repository: process.env.SQUAD_BOOTSTRAP_REPOSITORY, - run_id: process.env.SQUAD_BOOTSTRAP_RUN_ID, - install_sha: process.env.SQUAD_BOOTSTRAP_INSTALL_SHA, - cast_sha: pullRequestDetails.head.sha, - }; - if (provenance.repository !== `${context.repo.owner}/${context.repo.repo}` - || provenance.run_id !== String(context.runId) - || provenance.install_sha !== context.sha - || !/^[0-9a-f]{40}$/.test(provenance.install_sha) - || !/^[0-9a-f]{40}$/.test(provenance.cast_sha) - || pullRequestDetails.head.ref !== stateModule.BOOTSTRAP_BRANCH - || pullRequestDetails.head.repo?.full_name !== provenance.repository) { - throw new Error('Trusted bootstrap provenance inputs or Cast PR head identity are invalid.'); - } - const provenancePrefix = '<' + '!-- squad:bootstrap-provenance '; - const provenanceMarker = `${provenancePrefix}${JSON.stringify(provenance)} -->`; - const prBodyWithoutProvenance = String(pullRequestDetails.body || payload.pr_body) - .replace(new RegExp(`^${provenancePrefix}.* -->\\r?\\n?`, 'gm'), ''); - await github.rest.pulls.update({ - ...context.repo, - pull_number: pullRequest.number, - body: `${provenanceMarker}\n${prBodyWithoutProvenance}`, - }); - const provenanceComments = (await github.paginate( - github.rest.issues.listComments, - { - ...context.repo, - issue_number: pullRequest.number, - per_page: 100, - }, - )).filter((comment) => - String(comment.body || '').startsWith(provenancePrefix), - ); - if (provenanceComments.length > 1) { - throw new Error('Ambiguous bot-authenticated bootstrap provenance comments.'); - } - const provenanceCommentBody = - `${provenanceMarker}\nBase-controlled bootstrap provenance. Do not edit this comment.`; - if (provenanceComments.length === 1) { - if (provenanceComments[0].user?.login !== 'github-actions[bot]') { - throw new Error('Bootstrap provenance comment is not owned by GitHub Actions.'); + if (!pullRequest?.url) { + throw new Error('The deterministic Cast PR URL is unavailable after materialization.'); } - await github.rest.issues.updateComment({ + const pullRequestDetails = (await github.rest.pulls.get({ ...context.repo, - comment_id: provenanceComments[0].id, - body: provenanceCommentBody, - }); - } else { - await github.rest.issues.createComment({ - ...context.repo, - issue_number: pullRequest.number, - body: provenanceCommentBody, - }); - } - const finalPayload = { - ...payload, - issue_body: payload.issue_body.replace('{{CAST_PR_URL}}', pullRequest.url), - }; - validate(finalPayload, 'resolved'); - const issueBodyNewline = finalPayload.issue_body.indexOf('\n'); - if (issueBodyNewline < 0) { - throw new Error('Validated bootstrap issue body has no marker delimiter.'); - } - const markedIssueBody = `${finalPayload.issue_body.slice(0, issueBodyNewline)}\n${provenanceMarker}${finalPayload.issue_body.slice(issueBodyNewline)}`; - - snapshot = await listState(); - let issueNumber; - if (snapshot.state.issue) { - if (snapshot.state.issue.state !== 'open') { - core.info(`Bootstrap issue #${snapshot.state.issue.number} is closed; preserving human state.`); - return; + pull_number: pullRequest.number, + })).data; + const provenance = { + schema: 1, + repository: process.env.SQUAD_BOOTSTRAP_REPOSITORY, + run_id: process.env.SQUAD_BOOTSTRAP_RUN_ID, + install_sha: process.env.SQUAD_BOOTSTRAP_INSTALL_SHA, + cast_sha: pullRequestDetails.head.sha, + }; + if (provenance.repository !== `${context.repo.owner}/${context.repo.repo}` + || provenance.run_id !== String(context.runId) + || provenance.install_sha !== context.sha + || !/^[0-9a-f]{40}$/.test(provenance.install_sha) + || !/^[0-9a-f]{40}$/.test(provenance.cast_sha) + || pullRequestDetails.head.ref !== stateModule.BOOTSTRAP_BRANCH + || pullRequestDetails.head.repo?.full_name !== provenance.repository) { + throw new Error('Trusted bootstrap provenance inputs or Cast PR head identity are invalid.'); } - issueNumber = snapshot.state.issue.number; - await github.rest.issues.update({ - ...context.repo, - issue_number: issueNumber, - title: stateModule.BOOTSTRAP_ISSUE_TITLE, - body: markedIssueBody, - }); - } else { - const createdIssue = await github.rest.issues.create({ + const provenancePrefix = '<' + '!-- squad:bootstrap-provenance '; + const provenanceMarker = `${provenancePrefix}${JSON.stringify(provenance)} -->`; + const prBodyWithoutProvenance = String(pullRequestDetails.body || payload.pr_body) + .replace(new RegExp(`^${provenancePrefix}.* -->\\r?\\n?`, 'gm'), ''); + await github.rest.pulls.update({ ...context.repo, - title: stateModule.BOOTSTRAP_ISSUE_TITLE, - body: markedIssueBody, + pull_number: pullRequest.number, + body: `${provenanceMarker}\n${prBodyWithoutProvenance}`, }); - issueNumber = createdIssue.data.number; - } - - const researchBody = validatorModule.createBootstrapResearchComment( - finalPayload.issue_body, - issueNumber, - ); - const comments = await github.paginate(github.rest.issues.listComments, { - ...context.repo, - issue_number: issueNumber, - per_page: 100, - }); - const researchArtifacts = validatorModule.findBootstrapResearchArtifacts( - comments, - issueNumber, - ); - const currentResearch = researchArtifacts.at(-1); - if (currentResearch) { - if (validatorModule.isBootstrapResearchSeed(currentResearch)) { + const provenanceComments = (await github.paginate( + github.rest.issues.listComments, + { + ...context.repo, + issue_number: pullRequest.number, + per_page: 100, + }, + )).filter((comment) => + String(comment.body || '').startsWith(provenancePrefix), + ); + if (provenanceComments.length > 1) { + throw new Error('Ambiguous bot-authenticated bootstrap provenance comments.'); + } + const provenanceCommentBody = + `${provenanceMarker}\nBase-controlled bootstrap provenance. Do not edit this comment.`; + if (provenanceComments.length === 1) { + if (provenanceComments[0].user?.login !== 'github-actions[bot]') { + throw new Error('Bootstrap provenance comment is not owned by GitHub Actions.'); + } await github.rest.issues.updateComment({ ...context.repo, - comment_id: currentResearch.id, - body: researchBody, + comment_id: provenanceComments[0].id, + body: provenanceCommentBody, }); } else { - core.info( - `Preserving focused research artifact comment #${currentResearch.id}.`, - ); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: pullRequest.number, + body: provenanceCommentBody, + }); + } + const finalPayload = { + ...payload, + issue_body: payload.issue_body.replace('{{CAST_PR_URL}}', pullRequest.url), + }; + validate(finalPayload, 'resolved'); + const issueBodyNewline = finalPayload.issue_body.indexOf('\n'); + if (issueBodyNewline < 0) { + throw new Error('Validated bootstrap issue body has no marker delimiter.'); } - for (const duplicate of researchArtifacts.slice(0, -1)) { - await github.rest.issues.deleteComment({ + const markedIssueBody = `${finalPayload.issue_body.slice(0, issueBodyNewline)}\n${provenanceMarker}${finalPayload.issue_body.slice(issueBodyNewline)}`; + + snapshot = await listState(); + let issueNumber; + if (snapshot.state.issue) { + if (snapshot.state.issue.state !== 'open') { + core.info(`Bootstrap issue #${snapshot.state.issue.number} is closed; preserving human state.`); + return; + } + issueNumber = snapshot.state.issue.number; + await github.rest.issues.update({ ...context.repo, - comment_id: duplicate.id, + issue_number: issueNumber, + title: stateModule.BOOTSTRAP_ISSUE_TITLE, + body: markedIssueBody, }); + } else { + const createdIssue = await github.rest.issues.create({ + ...context.repo, + title: stateModule.BOOTSTRAP_ISSUE_TITLE, + body: markedIssueBody, + }); + issueNumber = createdIssue.data.number; } - } else { - await github.rest.issues.createComment({ + + const researchBody = validatorModule.createBootstrapResearchComment( + finalPayload.issue_body, + issueNumber, + ); + const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: issueNumber, - body: researchBody, + per_page: 100, }); + const researchArtifacts = validatorModule.findBootstrapResearchArtifacts( + comments, + issueNumber, + ); + const currentResearch = researchArtifacts.at(-1); + if (currentResearch) { + if (validatorModule.isBootstrapResearchSeed(currentResearch)) { + await github.rest.issues.updateComment({ + ...context.repo, + comment_id: currentResearch.id, + body: researchBody, + }); + } else { + core.info( + `Preserving focused research artifact comment #${currentResearch.id}.`, + ); + } + for (const duplicate of researchArtifacts.slice(0, -1)) { + await github.rest.issues.deleteComment({ + ...context.repo, + comment_id: duplicate.id, + }); + } + } else { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: issueNumber, + body: researchBody, + }); + } + } finally { + rmSync(candidate, { recursive: true, force: true }); } --- diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 11a483267..64705a511 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -335,389 +335,389 @@ safe-outputs: writeFileSync(target, file.content); } writeFileSync(payloadPath, payloadText); - } finally { - rmSync(candidate, { recursive: true, force: true }); - } - const listState = async () => { - const pullRequests = await github.paginate(github.rest.pulls.list, { - ...context.repo, - state: 'all', - per_page: 100, - }); - const issues = (await github.paginate(github.rest.issues.listForRepo, { - ...context.repo, - state: 'all', - per_page: 100, - })).filter((issue) => !issue.pull_request); - const preliminary = stateModule.classifyBootstrapState({ - pullRequests, - issues, - defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - }); - const comments = preliminary.issue - ? await github.paginate(github.rest.issues.listComments, { - ...context.repo, - issue_number: preliminary.issue.number, - per_page: 100, - }) - : []; - return { - pullRequests, - issues, - comments, - state: stateModule.classifyBootstrapState({ + const listState = async () => { + const pullRequests = await github.paginate(github.rest.pulls.list, { + ...context.repo, + state: 'all', + per_page: 100, + }); + const issues = (await github.paginate(github.rest.issues.listForRepo, { + ...context.repo, + state: 'all', + per_page: 100, + })).filter((issue) => !issue.pull_request); + const preliminary = stateModule.classifyBootstrapState({ pullRequests, issues, - comments, defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - }), + }); + const comments = preliminary.issue + ? await github.paginate(github.rest.issues.listComments, { + ...context.repo, + issue_number: preliminary.issue.number, + per_page: 100, + }) + : []; + return { + pullRequests, + issues, + comments, + state: stateModule.classifyBootstrapState({ + pullRequests, + issues, + comments, + defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + }), + }; }; - }; - - let snapshot = await listState(); - if (snapshot.state.action === 'opt_out') { - core.info('A closed-unmerged bootstrap Cast PR records human opt-out; no replacement was created.'); - return; - } - if (snapshot.state.action === 'noop') { - core.info('The deterministic Cast PR, research-proposals issue, and research artifact already exist.'); - return; - } - const getRef = async (ref) => { - try { - return (await github.rest.git.getRef({ ...context.repo, ref })).data; - } catch (error) { - if (error.status === 404) return null; - throw error; - } - }; - const assertRemotePayload = async (ref) => { - for (const file of payload.files) { - const response = await github.rest.repos.getContent({ - ...context.repo, - path: file.path, - ref, - }); - if (Array.isArray(response.data) || response.data.type !== 'file') { - throw new Error(`Bootstrap branch path is not a file: ${file.path}`); - } - const remote = Buffer.from(response.data.content, 'base64').toString('utf8').replace(/\r\n/g, '\n'); - if (remote !== String(file.content).replace(/\r\n/g, '\n')) { - throw new Error(`Existing bootstrap branch diverges at ${file.path}; refusing replacement.`); - } + let snapshot = await listState(); + if (snapshot.state.action === 'opt_out') { + core.info('A closed-unmerged bootstrap Cast PR records human opt-out; no replacement was created.'); + return; } - if (ref !== process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH) { - const comparison = await github.rest.repos.compareCommitsWithBasehead({ - ...context.repo, - basehead: `${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}...${ref}`, - per_page: 100, - }); - const changed = (comparison.data.files || []).map((file) => file.filename).sort(); - const allowed = payload.files.map((file) => file.path).sort(); - if (JSON.stringify(changed) !== JSON.stringify(allowed)) { - throw new Error(`Existing bootstrap branch changed files outside the validated payload: ${changed.join(', ')}`); - } + if (snapshot.state.action === 'noop') { + core.info('The deterministic Cast PR, research-proposals issue, and research artifact already exist.'); + return; } - }; - let pullRequest = snapshot.state.pull_request; - if (!pullRequest) { - const branchRefName = `heads/${stateModule.BOOTSTRAP_BRANCH}`; - const existingRef = await getRef(branchRefName); - if (existingRef) { - await assertRemotePayload(stateModule.BOOTSTRAP_BRANCH); - } else { - const baseRef = await github.rest.git.getRef({ - ...context.repo, - ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, - }); - const baseCommit = await github.rest.git.getCommit({ - ...context.repo, - commit_sha: baseRef.data.object.sha, - }); - const tree = []; + const getRef = async (ref) => { + try { + return (await github.rest.git.getRef({ ...context.repo, ref })).data; + } catch (error) { + if (error.status === 404) return null; + throw error; + } + }; + const assertRemotePayload = async (ref) => { for (const file of payload.files) { - const blob = await github.rest.git.createBlob({ + const response = await github.rest.repos.getContent({ ...context.repo, - content: Buffer.from(file.content, 'utf8').toString('base64'), - encoding: 'base64', - }); - tree.push({ path: file.path, - mode: '100644', - type: 'blob', - sha: blob.data.sha, + ref, }); + if (Array.isArray(response.data) || response.data.type !== 'file') { + throw new Error(`Bootstrap branch path is not a file: ${file.path}`); + } + const remote = Buffer.from(response.data.content, 'base64').toString('utf8').replace(/\r\n/g, '\n'); + if (remote !== String(file.content).replace(/\r\n/g, '\n')) { + throw new Error(`Existing bootstrap branch diverges at ${file.path}; refusing replacement.`); + } } - const createdTree = await github.rest.git.createTree({ - ...context.repo, - base_tree: baseCommit.data.tree.sha, - tree, - }); - const commit = await github.rest.git.createCommit({ - ...context.repo, - message: 'chore(squad): add repository-derived Squad', - tree: createdTree.data.sha, - parents: [baseRef.data.object.sha], - }); - await github.rest.git.createRef({ - ...context.repo, - ref: `refs/heads/${stateModule.BOOTSTRAP_BRANCH}`, - sha: commit.data.sha, - }); - } - let created; - try { - created = await github.rest.pulls.create({ - ...context.repo, - title: stateModule.BOOTSTRAP_PR_TITLE, - head: stateModule.BOOTSTRAP_BRANCH, - base: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - body: payload.pr_body, - draft: true, - }); - } catch (prError) { - if (!stateModule.isCreatePullRequestPermissionDenied(prError)) { - throw prError; + if (ref !== process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH) { + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}...${ref}`, + per_page: 100, + }); + const changed = (comparison.data.files || []).map((file) => file.filename).sort(); + const allowed = payload.files.map((file) => file.path).sort(); + if (JSON.stringify(changed) !== JSON.stringify(allowed)) { + throw new Error(`Existing bootstrap branch changed files outside the validated payload: ${changed.join(', ')}`); + } } - core.warning(`Squad bootstrap could not create the Cast pull request: ${prError.message}`); - const pushedRef = await getRef(`heads/${stateModule.BOOTSTRAP_BRANCH}`); - if (!pushedRef) { - throw new Error( - 'Squad bootstrap cannot fall back to a manual pull request link because the candidate branch was not pushed.', - ); + }; + + let pullRequest = snapshot.state.pull_request; + if (!pullRequest) { + const branchRefName = `heads/${stateModule.BOOTSTRAP_BRANCH}`; + const existingRef = await getRef(branchRefName); + if (existingRef) { + await assertRemotePayload(stateModule.BOOTSTRAP_BRANCH); + } else { + const baseRef = await github.rest.git.getRef({ + ...context.repo, + ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, + }); + const baseCommit = await github.rest.git.getCommit({ + ...context.repo, + commit_sha: baseRef.data.object.sha, + }); + const tree = []; + for (const file of payload.files) { + const blob = await github.rest.git.createBlob({ + ...context.repo, + content: Buffer.from(file.content, 'utf8').toString('base64'), + encoding: 'base64', + }); + tree.push({ + path: file.path, + mode: '100644', + type: 'blob', + sha: blob.data.sha, + }); + } + const createdTree = await github.rest.git.createTree({ + ...context.repo, + base_tree: baseCommit.data.tree.sha, + tree, + }); + const commit = await github.rest.git.createCommit({ + ...context.repo, + message: 'chore(squad): add repository-derived Squad', + tree: createdTree.data.sha, + parents: [baseRef.data.object.sha], + }); + await github.rest.git.createRef({ + ...context.repo, + ref: `refs/heads/${stateModule.BOOTSTRAP_BRANCH}`, + sha: commit.data.sha, + }); } - const repository = `${context.repo.owner}/${context.repo.repo}`; - const compareUrl = stateModule.buildBootstrapPrFallbackCompareUrl({ - repository, - baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - headBranch: stateModule.BOOTSTRAP_BRANCH, - title: stateModule.BOOTSTRAP_PR_TITLE, - server: process.env.GITHUB_SERVER_URL, - }); - // context.sha is the default branch's exact commit this run executed on (the - // top-level exact default-branch ref gate - // guards both the push and workflow_dispatch trigger paths), so it is a - // reliable, zero-extra-API-call stand-in for "the base commit any fresh - // provenance record produced by this run would be bound to". - const baseSha = context.sha; - const existingFallback = stateModule.findExistingBootstrapPrFallbackIssue( - snapshot.issues, - stateModule.BOOTSTRAP_BRANCH, - { + let created; + try { + created = await github.rest.pulls.create({ + ...context.repo, + title: stateModule.BOOTSTRAP_PR_TITLE, + head: stateModule.BOOTSTRAP_BRANCH, + base: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + body: payload.pr_body, + draft: true, + }); + } catch (prError) { + if (!stateModule.isCreatePullRequestPermissionDenied(prError)) { + throw prError; + } + core.warning(`Squad bootstrap could not create the Cast pull request: ${prError.message}`); + const pushedRef = await getRef(`heads/${stateModule.BOOTSTRAP_BRANCH}`); + if (!pushedRef) { + throw new Error( + 'Squad bootstrap cannot fall back to a manual pull request link because the candidate branch was not pushed.', + ); + } + const repository = `${context.repo.owner}/${context.repo.repo}`; + const compareUrl = stateModule.buildBootstrapPrFallbackCompareUrl({ + repository, + baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + headBranch: stateModule.BOOTSTRAP_BRANCH, + title: stateModule.BOOTSTRAP_PR_TITLE, + server: process.env.GITHUB_SERVER_URL, + }); + // context.sha is the default branch's exact commit this run executed on (the + // top-level exact default-branch ref gate + // guards both the push and workflow_dispatch trigger paths), so it is a + // reliable, zero-extra-API-call stand-in for "the base commit any fresh + // provenance record produced by this run would be bound to". + const baseSha = context.sha; + const existingFallback = stateModule.findExistingBootstrapPrFallbackIssue( + snapshot.issues, + stateModule.BOOTSTRAP_BRANCH, + { + repository, + baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + baseSha, + headSha: pushedRef.object.sha, + }, + ); + if (existingFallback) { + core.info( + `A fallback issue already requests manual Cast pull request creation: ${existingFallback.html_url}`, + ); + return; + } + // squad-review-guard's validateBootstrapPrFallbackAttribution() only authorizes a + // fallback provenance record whose referenced run has event === 'push' (mirroring + // the bot-authored path's own push-only trust model). A workflow_dispatch run that + // reaches this branch would mint a fallback issue no Cast PR could ever satisfy, + // and future reruns would dedupe against that permanently-unusable issue forever + // (findExistingBootstrapPrFallbackIssue does not consider triggering event). Fail + // closed instead of minting a dead-end issue. + if (context.eventName !== 'push') { + throw new Error( + 'Squad bootstrap cannot open a trusted fallback issue because this run was triggered by ' + + `'${context.eventName}', not 'push'. The Squad review workflow only authorizes a Cast ` + + "pull request against a push-triggered bootstrap run's provenance. Re-run this workflow " + + 'via a push to a squad-related path on the default branch (for example, merging the ' + + 'pending installation changes) so a push-triggered run can open an authorizable fallback issue.', + ); + } + const runUrl = `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.SQUAD_BOOTSTRAP_RUN_ID}`; + const provenanceLine = stateModule.buildBootstrapPrFallbackProvenanceLine({ repository, + runId: process.env.SQUAD_BOOTSTRAP_RUN_ID, baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, baseSha, + headBranch: stateModule.BOOTSTRAP_BRANCH, headSha: pushedRef.object.sha, - }, - ); - if (existingFallback) { - core.info( - `A fallback issue already requests manual Cast pull request creation: ${existingFallback.html_url}`, - ); - return; - } - // squad-review-guard's validateBootstrapPrFallbackAttribution() only authorizes a - // fallback provenance record whose referenced run has event === 'push' (mirroring - // the bot-authored path's own push-only trust model). A workflow_dispatch run that - // reaches this branch would mint a fallback issue no Cast PR could ever satisfy, - // and future reruns would dedupe against that permanently-unusable issue forever - // (findExistingBootstrapPrFallbackIssue does not consider triggering event). Fail - // closed instead of minting a dead-end issue. - if (context.eventName !== 'push') { - throw new Error( - 'Squad bootstrap cannot open a trusted fallback issue because this run was triggered by ' + - `'${context.eventName}', not 'push'. The Squad review workflow only authorizes a Cast ` + - "pull request against a push-triggered bootstrap run's provenance. Re-run this workflow " + - 'via a push to a squad-related path on the default branch (for example, merging the ' + - 'pending installation changes) so a push-triggered run can open an authorizable fallback issue.', - ); - } - const runUrl = `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.SQUAD_BOOTSTRAP_RUN_ID}`; - const provenanceLine = stateModule.buildBootstrapPrFallbackProvenanceLine({ - repository, - runId: process.env.SQUAD_BOOTSTRAP_RUN_ID, - baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - baseSha, - headBranch: stateModule.BOOTSTRAP_BRANCH, - headSha: pushedRef.object.sha, - compareUrl, - }); - const fallbackBody = stateModule.buildBootstrapPrFallbackIssueBody({ - repository, - baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, - headBranch: stateModule.BOOTSTRAP_BRANCH, - compareUrl, - runUrl, - provenanceLine, - }); - try { - const fallbackIssue = await github.rest.issues.create({ - ...context.repo, - title: stateModule.BOOTSTRAP_PR_FALLBACK_ISSUE_TITLE, - body: fallbackBody, + compareUrl, }); - core.warning( - `Opened a fallback issue for manual Cast pull request creation: ${fallbackIssue.data.html_url}`, - ); - return; - } catch (issueError) { - throw new Error( - `Failed to create the Cast pull request (${prError.message}) and failed to create the ` + - `fallback issue (${issueError.message}).`, - ); + const fallbackBody = stateModule.buildBootstrapPrFallbackIssueBody({ + repository, + baseBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + headBranch: stateModule.BOOTSTRAP_BRANCH, + compareUrl, + runUrl, + provenanceLine, + }); + try { + const fallbackIssue = await github.rest.issues.create({ + ...context.repo, + title: stateModule.BOOTSTRAP_PR_FALLBACK_ISSUE_TITLE, + body: fallbackBody, + }); + core.warning( + `Opened a fallback issue for manual Cast pull request creation: ${fallbackIssue.data.html_url}`, + ); + return; + } catch (issueError) { + throw new Error( + `Failed to create the Cast pull request (${prError.message}) and failed to create the ` + + `fallback issue (${issueError.message}).`, + ); + } } + pullRequest = { + number: created.data.number, + state: created.data.state, + merged: false, + url: created.data.html_url, + }; + } else { + const ref = pullRequest.merged + ? process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH + : stateModule.BOOTSTRAP_BRANCH; + await assertRemotePayload(ref); } - pullRequest = { - number: created.data.number, - state: created.data.state, - merged: false, - url: created.data.html_url, - }; - } else { - const ref = pullRequest.merged - ? process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH - : stateModule.BOOTSTRAP_BRANCH; - await assertRemotePayload(ref); - } - if (!pullRequest?.url) { - throw new Error('The deterministic Cast PR URL is unavailable after materialization.'); - } - const pullRequestDetails = (await github.rest.pulls.get({ - ...context.repo, - pull_number: pullRequest.number, - })).data; - const provenance = { - schema: 1, - repository: process.env.SQUAD_BOOTSTRAP_REPOSITORY, - run_id: process.env.SQUAD_BOOTSTRAP_RUN_ID, - install_sha: process.env.SQUAD_BOOTSTRAP_INSTALL_SHA, - cast_sha: pullRequestDetails.head.sha, - }; - if (provenance.repository !== `${context.repo.owner}/${context.repo.repo}` - || provenance.run_id !== String(context.runId) - || provenance.install_sha !== context.sha - || !/^[0-9a-f]{40}$/.test(provenance.install_sha) - || !/^[0-9a-f]{40}$/.test(provenance.cast_sha) - || pullRequestDetails.head.ref !== stateModule.BOOTSTRAP_BRANCH - || pullRequestDetails.head.repo?.full_name !== provenance.repository) { - throw new Error('Trusted bootstrap provenance inputs or Cast PR head identity are invalid.'); - } - const provenancePrefix = '<' + '!-- squad:bootstrap-provenance '; - const provenanceMarker = `${provenancePrefix}${JSON.stringify(provenance)} -->`; - const prBodyWithoutProvenance = String(pullRequestDetails.body || payload.pr_body) - .replace(new RegExp(`^${provenancePrefix}.* -->\\r?\\n?`, 'gm'), ''); - await github.rest.pulls.update({ - ...context.repo, - pull_number: pullRequest.number, - body: `${provenanceMarker}\n${prBodyWithoutProvenance}`, - }); - const provenanceComments = (await github.paginate( - github.rest.issues.listComments, - { - ...context.repo, - issue_number: pullRequest.number, - per_page: 100, - }, - )).filter((comment) => - String(comment.body || '').startsWith(provenancePrefix), - ); - if (provenanceComments.length > 1) { - throw new Error('Ambiguous bot-authenticated bootstrap provenance comments.'); - } - const provenanceCommentBody = - `${provenanceMarker}\nBase-controlled bootstrap provenance. Do not edit this comment.`; - if (provenanceComments.length === 1) { - if (provenanceComments[0].user?.login !== 'github-actions[bot]') { - throw new Error('Bootstrap provenance comment is not owned by GitHub Actions.'); + if (!pullRequest?.url) { + throw new Error('The deterministic Cast PR URL is unavailable after materialization.'); } - await github.rest.issues.updateComment({ + const pullRequestDetails = (await github.rest.pulls.get({ ...context.repo, - comment_id: provenanceComments[0].id, - body: provenanceCommentBody, - }); - } else { - await github.rest.issues.createComment({ - ...context.repo, - issue_number: pullRequest.number, - body: provenanceCommentBody, - }); - } - const finalPayload = { - ...payload, - issue_body: payload.issue_body.replace('{{CAST_PR_URL}}', pullRequest.url), - }; - validate(finalPayload, 'resolved'); - const issueBodyNewline = finalPayload.issue_body.indexOf('\n'); - if (issueBodyNewline < 0) { - throw new Error('Validated bootstrap issue body has no marker delimiter.'); - } - const markedIssueBody = `${finalPayload.issue_body.slice(0, issueBodyNewline)}\n${provenanceMarker}${finalPayload.issue_body.slice(issueBodyNewline)}`; - - snapshot = await listState(); - let issueNumber; - if (snapshot.state.issue) { - if (snapshot.state.issue.state !== 'open') { - core.info(`Bootstrap issue #${snapshot.state.issue.number} is closed; preserving human state.`); - return; + pull_number: pullRequest.number, + })).data; + const provenance = { + schema: 1, + repository: process.env.SQUAD_BOOTSTRAP_REPOSITORY, + run_id: process.env.SQUAD_BOOTSTRAP_RUN_ID, + install_sha: process.env.SQUAD_BOOTSTRAP_INSTALL_SHA, + cast_sha: pullRequestDetails.head.sha, + }; + if (provenance.repository !== `${context.repo.owner}/${context.repo.repo}` + || provenance.run_id !== String(context.runId) + || provenance.install_sha !== context.sha + || !/^[0-9a-f]{40}$/.test(provenance.install_sha) + || !/^[0-9a-f]{40}$/.test(provenance.cast_sha) + || pullRequestDetails.head.ref !== stateModule.BOOTSTRAP_BRANCH + || pullRequestDetails.head.repo?.full_name !== provenance.repository) { + throw new Error('Trusted bootstrap provenance inputs or Cast PR head identity are invalid.'); } - issueNumber = snapshot.state.issue.number; - await github.rest.issues.update({ - ...context.repo, - issue_number: issueNumber, - title: stateModule.BOOTSTRAP_ISSUE_TITLE, - body: markedIssueBody, - }); - } else { - const createdIssue = await github.rest.issues.create({ + const provenancePrefix = '<' + '!-- squad:bootstrap-provenance '; + const provenanceMarker = `${provenancePrefix}${JSON.stringify(provenance)} -->`; + const prBodyWithoutProvenance = String(pullRequestDetails.body || payload.pr_body) + .replace(new RegExp(`^${provenancePrefix}.* -->\\r?\\n?`, 'gm'), ''); + await github.rest.pulls.update({ ...context.repo, - title: stateModule.BOOTSTRAP_ISSUE_TITLE, - body: markedIssueBody, + pull_number: pullRequest.number, + body: `${provenanceMarker}\n${prBodyWithoutProvenance}`, }); - issueNumber = createdIssue.data.number; - } - - const researchBody = validatorModule.createBootstrapResearchComment( - finalPayload.issue_body, - issueNumber, - ); - const comments = await github.paginate(github.rest.issues.listComments, { - ...context.repo, - issue_number: issueNumber, - per_page: 100, - }); - const researchArtifacts = validatorModule.findBootstrapResearchArtifacts( - comments, - issueNumber, - ); - const currentResearch = researchArtifacts.at(-1); - if (currentResearch) { - if (validatorModule.isBootstrapResearchSeed(currentResearch)) { + const provenanceComments = (await github.paginate( + github.rest.issues.listComments, + { + ...context.repo, + issue_number: pullRequest.number, + per_page: 100, + }, + )).filter((comment) => + String(comment.body || '').startsWith(provenancePrefix), + ); + if (provenanceComments.length > 1) { + throw new Error('Ambiguous bot-authenticated bootstrap provenance comments.'); + } + const provenanceCommentBody = + `${provenanceMarker}\nBase-controlled bootstrap provenance. Do not edit this comment.`; + if (provenanceComments.length === 1) { + if (provenanceComments[0].user?.login !== 'github-actions[bot]') { + throw new Error('Bootstrap provenance comment is not owned by GitHub Actions.'); + } await github.rest.issues.updateComment({ ...context.repo, - comment_id: currentResearch.id, - body: researchBody, + comment_id: provenanceComments[0].id, + body: provenanceCommentBody, }); } else { - core.info( - `Preserving focused research artifact comment #${currentResearch.id}.`, - ); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: pullRequest.number, + body: provenanceCommentBody, + }); + } + const finalPayload = { + ...payload, + issue_body: payload.issue_body.replace('{{CAST_PR_URL}}', pullRequest.url), + }; + validate(finalPayload, 'resolved'); + const issueBodyNewline = finalPayload.issue_body.indexOf('\n'); + if (issueBodyNewline < 0) { + throw new Error('Validated bootstrap issue body has no marker delimiter.'); } - for (const duplicate of researchArtifacts.slice(0, -1)) { - await github.rest.issues.deleteComment({ + const markedIssueBody = `${finalPayload.issue_body.slice(0, issueBodyNewline)}\n${provenanceMarker}${finalPayload.issue_body.slice(issueBodyNewline)}`; + + snapshot = await listState(); + let issueNumber; + if (snapshot.state.issue) { + if (snapshot.state.issue.state !== 'open') { + core.info(`Bootstrap issue #${snapshot.state.issue.number} is closed; preserving human state.`); + return; + } + issueNumber = snapshot.state.issue.number; + await github.rest.issues.update({ ...context.repo, - comment_id: duplicate.id, + issue_number: issueNumber, + title: stateModule.BOOTSTRAP_ISSUE_TITLE, + body: markedIssueBody, }); + } else { + const createdIssue = await github.rest.issues.create({ + ...context.repo, + title: stateModule.BOOTSTRAP_ISSUE_TITLE, + body: markedIssueBody, + }); + issueNumber = createdIssue.data.number; } - } else { - await github.rest.issues.createComment({ + + const researchBody = validatorModule.createBootstrapResearchComment( + finalPayload.issue_body, + issueNumber, + ); + const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: issueNumber, - body: researchBody, + per_page: 100, }); + const researchArtifacts = validatorModule.findBootstrapResearchArtifacts( + comments, + issueNumber, + ); + const currentResearch = researchArtifacts.at(-1); + if (currentResearch) { + if (validatorModule.isBootstrapResearchSeed(currentResearch)) { + await github.rest.issues.updateComment({ + ...context.repo, + comment_id: currentResearch.id, + body: researchBody, + }); + } else { + core.info( + `Preserving focused research artifact comment #${currentResearch.id}.`, + ); + } + for (const duplicate of researchArtifacts.slice(0, -1)) { + await github.rest.issues.deleteComment({ + ...context.repo, + comment_id: duplicate.id, + }); + } + } else { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: issueNumber, + body: researchBody, + }); + } + } finally { + rmSync(candidate, { recursive: true, force: true }); } --- diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index fcef213ab..62b8b131b 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -68,9 +68,9 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "b609e809707bce01cddafed44cab90f70fe4a269f843eda4ee3979f2b6d8f117", - "lock_sha256": "d6fb3b52f7aa880bf4b5d93cb7cd95085ff340e09e900596a2802a003fdf5457", - "package_lock_sha256": "3f77f39d1c9fa72c8ea5f65af8dc978dbfd47cea7dbb7065415b168a06adb35d" + "source_sha256": "10f85600b3e4a8617bbdc70afc95ea975f8d98c1b9e673ca7c15abb293aed7ee", + "lock_sha256": "faf0b03fa9ddceefe1cbcc653edb123dc733b484f1a6d0ada8ce7cb08993081f", + "package_lock_sha256": "8857134b8a6551b19c4271f814b2cde348eca02efead2d5713eecdcdd45d895c" }, { "name": "squad-command-router",