diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index a1d878839..f380352d7 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -250,6 +250,64 @@ The validator reads the scope only from committed `HEAD` and fails closed on a malformed file. The scope only narrows focus; it never changes outputs or permissions. +### Explicit fresh bootstrap after a failed attempt + +Closing an unmerged Cast PR still means **opt out**. Ordinary pushes, blank +manual runs, renaming that PR, or deleting its branch do not undo that decision. +To intentionally start over without deleting history, a maintainer can prepare +an explicit generation on the default branch: + +1. Disable **Squad Bootstrap** while preparing the retry. Close the exact stale + unmerged Cast PR and any old bootstrap research or fallback issues you intend + to archive. Preserve unrelated issues and all old branches. +2. In a reviewed PR, install the complete coherent Squad package at an approved + immutable SHA and add `.squad/bootstrap-reset.json`. For example, if Cast PR + 3 is closed and there are no research/fallback issues to archive: + + ```json + { + "schema": "squad-bootstrap-reset/v1", + "id": "retry-1", + "archived_pull_requests": [3], + "archived_issues": [] + } + ``` + +3. Merge that preparation PR. The record only **arms** the generation: pushes + and blank manual runs cannot create it. Re-enable **Squad Bootstrap**, choose + the default branch in **Run workflow**, and enter `retry-1` in `fresh_start`. + The requester and rerun actor must be human repository collaborators with + write, maintain, or admin permission. +4. Review the new `squad/bootstrap-cast-retry-1` draft Cast PR (or open the exact + compare link from its new signed fallback issue). The Cast and research issue + titles end in `[reset:retry-1]`. Existing bot and fallback provenance checks + remain mandatory; the review guard additionally binds manual dispatch to the + committed reset record and authenticated actors. + +Use your actual closed artifact numbers, not the example numbers. All historical +Cast PRs and marked research issues must be explicitly accounted for; wrong, +missing, duplicate, open, foreign, or merged archived PRs fail closed. Closed +fallback issues may be included in `archived_issues`; unrelated issues cannot. +The operation never closes artifacts, deletes branches, or overwrites an +installed team or registry. A merged current-generation Cast may still finish +its linked research through ordinary recovery. + +Keep the same record and ID for retries: they select the same branch and artifact +identities. Existing branch contents must match the validated payload; divergent +contents are refused, never force-pushed. If the default branch advances during +initial materialization, dispatch again at its new tip. After the current Cast +exists, blank runs recover it rather than starting another generation. Closing +that new Cast again opts out, even with the same `fresh_start` input. Another +fresh attempt requires a new reviewed ID and an archive list that also names +that closed generation. Do not remove or edit the record while its Cast is under +review; its exact committed contents are part of review authorization. +Recovery preserves the original authenticated dispatch provenance, including +the signed fallback origin for a manually opened PR. A push is never a reset +trust root. An existing generation without that authenticated origin fails +closed. If the originating dispatch failed after publishing provenance, use +**Re-run all jobs** on that same run with its original inputs; another run +cannot promote a failed origin into successful provenance. + Review and merge the Cast PR, then rerun `/squad triage` on the linked issue to classify its existing bootstrap proposals. If a proposal needs deeper or newer evidence, use one of the issue's focused `/squad research ...` commands first; diff --git a/test/gh-aw-bootstrap-workflow.test.ts b/test/gh-aw-bootstrap-workflow.test.ts index 643c65096..9b7e908ba 100644 --- a/test/gh-aw-bootstrap-workflow.test.ts +++ b/test/gh-aw-bootstrap-workflow.test.ts @@ -29,6 +29,10 @@ import { PAYLOAD_MAX_BYTES, PAYLOAD_MAX_CHUNKS, bootstrapPrFallbackIssueMarker, + authorizeBootstrapReset, + bootstrapIdentity, + parseBootstrapReset, + readBootstrapReset, buildBootstrapPrFallbackCompareUrl, buildBootstrapPrFallbackIssueBody, buildBootstrapPrFallbackProvenanceLine, @@ -61,6 +65,118 @@ const builtins = [ { id: 'rai', name: 'Rai' }, { id: 'fact-checker', name: 'Fact Checker' }, ]; +const RESET = { + schema: 'squad-bootstrap-reset/v1', id: 'retry-1', + archived_pull_requests: [3], archived_issues: [], +}; + +function archivedCast() { + return { + number: 3, state: 'closed', merged: false, merged_at: null, + title: BOOTSTRAP_PR_TITLE, + user: { login: 'github-actions[bot]', type: 'Bot' }, + head: { ref: BOOTSTRAP_BRANCH, sha: 'a'.repeat(40), repo: { full_name: 'octo/example' } }, + base: { ref: 'main', repo: { full_name: 'octo/example' } }, + }; +} + +describe('explicit committed bootstrap reset', () => { + const classify = (extra = {}) => classifyBootstrapState({ + pullRequests: [archivedCast()], issues: [], defaultBranch: 'main', + repository: 'octo/example', reset: RESET, ...extra, + }); + + it('preserves legacy opt-out and arms a reset without creating it on ordinary runs', () => { + expect(classify({ reset: null }).action).toBe('opt_out'); + expect(classify().action).toBe('reset_armed'); + expect(classify({ resetAuthorized: true }).action).toBe('create_both'); + expect(classify().identity.BOOTSTRAP_BRANCH).toBe('squad/bootstrap-cast-retry-1'); + }); + + it.each([ + ['open', { state: 'open' }], + ['merged', { merged_at: '2026-10-08T00:00:00Z' }], + ['foreign', { head: { ...archivedCast().head, repo: { full_name: 'other/repo' } } }], + ['wrong base', { base: { ...archivedCast().base, ref: 'release' } }], + ['wrong title', { title: 'Human work' }], + ])('refuses %s archived history', (_label, change) => { + expect(() => classify({ pullRequests: [{ ...archivedCast(), ...change }] })).toThrow('Unsafe bootstrap reset'); + }); + + it('rejects installed teams, missing history, duplicate numbers and unarchived ambiguous history', () => { + expect(() => classify({ installedTeam: true, resetAuthorized: true })).toThrow('committed team'); + expect(() => classify({ pullRequests: [] })).toThrow('Unsafe bootstrap reset'); + expect(() => classify({ pullRequests: [archivedCast(), { ...archivedCast(), number: 4 }] })).toThrow('Ambiguous'); + expect(() => parseBootstrapReset({ ...RESET, archived_pull_requests: [3, 3] })).toThrow('unique'); + expect(() => parseBootstrapReset({ ...RESET, id: '../../branch' })).toThrow('Invalid'); + expect(() => parseBootstrapReset({ ...RESET, extra: true })).toThrow('Invalid'); + }); + + it('resumes the stable generation and preserves its new opt-out on repeated reset', () => { + const identity = bootstrapIdentity(RESET); + const current = { + ...archivedCast(), number: 5, state: 'open', title: identity.BOOTSTRAP_PR_TITLE, + head: { ...archivedCast().head, ref: identity.BOOTSTRAP_BRANCH }, + }; + expect(classify({ pullRequests: [archivedCast(), current] }).action).toBe('create_issue'); + expect(classify({ + pullRequests: [archivedCast(), { ...current, state: 'closed' }], resetAuthorized: true, + }).action).toBe('opt_out'); + expect(classify({ + pullRequests: [archivedCast(), { ...current, state: 'closed', merged: true }], installedTeam: true, + }).action).toBe('create_issue'); + expect(() => classify({ + pullRequests: [archivedCast(), current, { ...current, number: 6 }], + })).toThrow('Ambiguous'); + }); + + it('archives only exact closed research issues and keeps new research identity separate', () => { + const oldIssue = { number: 4, state: 'closed', title: BOOTSTRAP_ISSUE_TITLE, body: BOOTSTRAP_ISSUE_MARKER }; + const reset = { ...RESET, archived_issues: [4] }; + expect(classify({ reset, issues: [oldIssue], resetAuthorized: true }).action).toBe('create_both'); + expect(() => classify({ reset, issues: [{ ...oldIssue, state: 'open' }] })).toThrow('Unsafe'); + expect(() => classify({ reset, issues: [{ ...oldIssue, title: 'Unrelated retrospective' }] })).toThrow('Unsafe'); + expect(() => classify({ issues: [oldIssue] })).toThrow('Ambiguous'); + }); + + it('requires default-branch dispatch, exact committed ID and every human actor permission', async () => { + const context = { + eventName: 'workflow_dispatch', ref: 'refs/heads/main', actor: 'maintainer', + repo: { owner: 'octo', repo: 'example' }, + payload: { repository: { default_branch: 'main' }, sender: { login: 'maintainer', type: 'User' } }, + }; + let permission = 'write'; + const github = { rest: { repos: { getCollaboratorPermissionLevel: async ({ username }: { username: string }) => + ({ data: { permission, user: { login: username, type: 'User' } } }) } } }; + const args = { reset: RESET, input: 'retry-1', context, github }; + expect(await authorizeBootstrapReset(args)).toBe(true); + expect(await authorizeBootstrapReset({ ...args, input: '' })).toBe(false); + for (const change of [{ input: 'other' }, { reset: null }, + { context: { ...context, eventName: 'push' } }, { context: { ...context, ref: 'refs/tags/main' } }]) { + await expect(authorizeBootstrapReset({ ...args, ...change })).rejects.toThrow('Fresh bootstrap'); + } + permission = 'read'; + await expect(authorizeBootstrapReset(args)).rejects.toThrow('permission'); + await expect(authorizeBootstrapReset({ + ...args, context: { ...context, payload: { ...context.payload, sender: { login: 'bot', type: 'Bot' } } }, + })).rejects.toThrow('human'); + }); + + it('uses committed reset identity, never an agent-written working-tree record', () => { + const fixture = createFixture(); + write(fixture.root, '.squad/bootstrap-reset.json', JSON.stringify(RESET)); + expect(readBootstrapReset(fixture.root)).toBeNull(); + execFileSync('git', ['add', '.squad/bootstrap-reset.json'], { cwd: fixture.root }); + execFileSync('git', ['commit', '-qm', 'authorize reset'], { cwd: fixture.root }); + expect(readBootstrapReset(fixture.root)).toEqual(RESET); + write(fixture.root, '.squad/bootstrap-reset.json', JSON.stringify({ ...RESET, id: 'attacker' })); + expect(readBootstrapReset(fixture.root)).toEqual(RESET); + expect(validateBootstrapPayload({ + root: fixture.root, payloadText: JSON.stringify(fixture.payload), + repository: 'octo/example', defaultBranch: 'main', linkMode: 'placeholder', + })).toContain('payload: branch must be squad/bootstrap-cast-retry-1'); + }); +}); function write(root: string, path: string, content: string | Buffer): void { const target = join(root, ...path.split('/')); @@ -681,7 +797,7 @@ describe('automatic Squad bootstrap workflow', () => { linkMode: 'placeholder', }; expect(validateBootstrapPayload(options)).toContainEqual( - expect.stringContaining('registry base: committed HEAD is unavailable'), + expect.stringContaining('committed bootstrap identity unavailable'), ); expect(validateBootstrapPayload({ ...options, gitRoot: fixture.root })).toEqual([]); @@ -982,7 +1098,9 @@ describe('automatic Squad bootstrap workflow', () => { expect(lock).toContain('group: squad-bootstrap-${{ github.repository }}'); expect(lock).toContain('cancel-in-progress: false'); expect(lock).toMatch(/agent:[\s\S]*?permissions:\n\s+contents: read\n\s+copilot-requests: write\n\s+issues: read\n\s+pull-requests: read/); - expect(lock).toMatch(/materialize_bootstrap:[\s\S]*?permissions:\n\s+contents: write\n\s+issues: write\n\s+pull-requests: write/); + expect(parse(lock).jobs.materialize_bootstrap.permissions).toEqual({ + actions: 'read', contents: 'write', issues: 'write', 'pull-requests': 'write', + }); expect(lock).toContain('"payload_chunk_00"'); expect(lock).toContain('"payload_chunk_15"'); expect(lock).toContain('"payload_byte_length"'); @@ -1124,6 +1242,10 @@ describe('gh-aw: squad-bootstrap candidate lifetime', () => { const prUrl = 'https://github.com/octo/example/pull/3'; function writerScript(source: string): string { + if (!source.startsWith('---')) { + return parse(source).jobs.materialize_bootstrap.steps + .find((step: { name?: string }) => step.name === 'Validate and materialize both artifacts').with.script; + } 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; @@ -1134,8 +1256,19 @@ describe('gh-aw: squad-bootstrap candidate lifetime', () => { function writerFixture( scenario: 'success' | 'opt-out' | 'permission-denied' | 'api-error' = 'success', source = WORKFLOW, + fresh = false, ) { const fixture = createFixture(); + if (fresh) { + execFileSync('git', ['rm', '--cached', '--', ...fixture.payload.files.map(file => file.path)], { cwd: fixture.root }); + write(fixture.root, '.squad/bootstrap-reset.json', JSON.stringify(RESET)); + execFileSync('git', ['add', '.squad/bootstrap-reset.json'], { cwd: fixture.root }); + execFileSync('git', ['commit', '-qm', 'authorize fresh bootstrap without installed team'], { cwd: fixture.root }); + const identity = bootstrapIdentity(RESET); + fixture.payload.branch = identity.BOOTSTRAP_BRANCH; + fixture.payload.pr_title = identity.BOOTSTRAP_PR_TITLE; + fixture.payload.issue_title = identity.BOOTSTRAP_ISSUE_TITLE; + } const workspace = mkdtempSync(join(tmpdir(), 'gh-aw-bootstrap-writer-')); workspaces.push(workspace); const checkout = join(workspace, 'bootstrap-repo'); @@ -1162,6 +1295,8 @@ describe('gh-aw: squad-bootstrap candidate lifetime', () => { const issueCalls: Array<{ title: string; body: string }> = []; const commentCalls: Array<{ issue_number: number; body: string }> = []; const updates: Array<{ body: string }> = []; + const commitParents: string[][] = []; + const publishedRefs: string[] = []; const writesWithCandidate: boolean[] = []; const recordWrite = () => writesWithCandidate.push( existsSync(join(candidate, '.squad/team.md')) && @@ -1169,23 +1304,26 @@ describe('gh-aw: squad-bootstrap candidate lifetime', () => { existsSync(join(candidate, '.github/workflows/shared/builtins/scribe-charter.md')), ); const pull = { - number: 3, + number: fresh ? 5 : 3, state: scenario === 'opt-out' ? 'closed' : 'open', - title: BOOTSTRAP_PR_TITLE, - head: { ref: BOOTSTRAP_BRANCH, sha: castSha, repo: { full_name: 'octo/example' } }, + title: fixture.payload.pr_title, + head: { ref: fixture.payload.branch, sha: castSha, repo: { full_name: 'octo/example' } }, base: { ref: 'main' }, html_url: prUrl, body: fixture.payload.pr_body, merged_at: null, + user: { login: 'github-actions[bot]', type: 'Bot' }, }; let branchCreated = false; let pullCreated = false; + const persistedIssues: Array<{ number: number; state: string; title: string; body: string }> = []; + const persistedComments: Array<{ id: number; issue_number: number; body: string; user: { login: string; type: string } }> = []; const github = { - paginate: async (method: () => Promise) => method(), + paginate: async (method: (args: unknown) => Promise, args: unknown) => method(args), rest: { git: { getRef: async ({ ref }: { ref: string }) => { - if (ref === `heads/${BOOTSTRAP_BRANCH}` && !branchCreated) { + if (ref === `heads/${fixture.payload.branch}` && !branchCreated) { throw Object.assign(new Error('Not Found'), { status: 404 }); } return { data: { object: { sha: ref === 'heads/main' ? baseSha : castSha } } }; @@ -1193,11 +1331,17 @@ describe('gh-aw: squad-bootstrap candidate lifetime', () => { 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; }, + createCommit: async ({ parents }: { parents: string[] }) => { + commitParents.push(parents); + return { data: { sha: castSha } }; + }, + createRef: async ({ ref }: { ref: string }) => { branchCreated = true; publishedRefs.push(ref); }, }, pulls: { - list: async () => scenario === 'opt-out' || pullCreated ? [pull] : [], + list: async () => [ + ...(fresh ? [archivedCast()] : []), + ...(scenario === 'opt-out' || pullCreated ? [pull] : []), + ], create: async () => { recordWrite(); if (scenario === 'permission-denied') throw new Error(CREATE_PR_PERMISSION_DENIED_TEXT); @@ -1205,30 +1349,62 @@ describe('gh-aw: squad-bootstrap candidate lifetime', () => { return { data: pull }; }, get: async () => ({ data: pull }), - update: async (args: { body: string }) => { recordWrite(); updates.push(args); }, + update: async (args: { body: string }) => { recordWrite(); updates.push(args); pull.body = args.body; }, + }, + repos: { + getCollaboratorPermissionLevel: async ({ username }: { username: string }) => + ({ data: { permission: 'write', user: { login: username, type: 'User' } } }), + getContent: async ({ path }: { path: string }) => { + const content = path === '.squad/bootstrap-reset.json' + ? JSON.stringify(RESET) : fixture.payload.files.find(file => file.path === path)!.content; + return { data: { type: 'file', encoding: 'base64', size: Buffer.byteLength(content), + content: Buffer.from(content).toString('base64') } }; + }, + compareCommitsWithBasehead: async () => ({ + data: { + files: fixture.payload.files.map(file => ({ filename: file.path })), + status: 'identical', base_commit: { sha: baseSha }, merge_base_commit: { sha: baseSha }, + }, + }), + }, + actions: { + getWorkflowRun: async () => ({ data: { + event: 'workflow_dispatch', path: '.github/workflows/squad-bootstrap.lock.yml', + repository: { full_name: 'octo/example' }, head_sha: baseSha, head_branch: 'main', + status: 'completed', conclusion: 'success', + actor: { login: 'maintainer', type: 'User' }, + triggering_actor: { login: 'maintainer', type: 'User' }, + } }), }, issues: { - listForRepo: async () => [], - listComments: async () => [], + listForRepo: async () => persistedIssues, + listComments: async ({ issue_number }: { issue_number: number }) => + persistedComments.filter(comment => comment.issue_number === issue_number), create: async (args: { title: string; body: string }) => { recordWrite(); issueCalls.push(args); if (scenario === 'api-error') throw new Error('research issue API outage'); + persistedIssues.push({ ...args, number: 6, state: 'open' }); 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); + persistedComments.push({ + ...args, id: persistedComments.length + 1, user: { login: 'github-actions[bot]', type: 'Bot' }, + }); }, + updateComment: async () => {}, }, }, }; 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: { + const context = { + repo: { owner: 'octo', repo: 'example' }, sha: baseSha, runId: 123, + eventName: fresh ? 'workflow_dispatch' : 'push', ref: 'refs/heads/main', actor: 'maintainer', + payload: { repository: { default_branch: 'main' }, sender: { login: 'maintainer', type: 'User' } }, + }; + const runtimeEnv = { GITHUB_WORKSPACE: workspace, GH_AW_AGENT_OUTPUT: outputPath, GITHUB_SERVER_URL: 'https://github.com', @@ -1236,11 +1412,17 @@ describe('gh-aw: squad-bootstrap candidate lifetime', () => { SQUAD_BOOTSTRAP_REPOSITORY: 'octo/example', SQUAD_BOOTSTRAP_INSTALL_SHA: baseSha, SQUAD_BOOTSTRAP_RUN_ID: '123', - } }, + SQUAD_BOOTSTRAP_FRESH_START: fresh ? RESET.id : '', + }; + const run = () => runScript( + github, context, + { info: () => {}, warning: () => {}, setFailed: (message: string) => failures.push(message) }, + { env: runtimeEnv }, captureCandidate, ); return { - run, issueCalls, commentCalls, updates, writesWithCandidate, failures, + run, github, issueCalls, commentCalls, updates, writesWithCandidate, failures, commitParents, publishedRefs, + context, runtimeEnv, persistedIssues, persistedComments, pull, candidate: () => candidate, }; } @@ -1261,6 +1443,156 @@ describe('gh-aw: squad-bootstrap candidate lifetime', () => { expect(existsSync(fixture.candidate()), 'success must remove the entire candidate tree').toBe(false); }); + it('executes the compiled writer against historical Cast #3, creates a separate generation and reuses its PR', async () => { + const lock = compileWorkflow(); + const fixture = writerFixture('success', lock, true); + await fixture.run(); + expect(fixture.failures).toEqual([]); + expect(fixture.issueCalls).toHaveLength(1); + expect(fixture.issueCalls[0].title).toBe(bootstrapIdentity(RESET).BOOTSTRAP_ISSUE_TITLE); + expect(fixture.commentCalls.map(call => call.issue_number)).toEqual([5, 6]); + expect(fixture.updates).toHaveLength(1); + await fixture.run(); + expect(fixture.failures).toEqual([]); + expect(fixture.updates).toHaveLength(1); + expect(fixture.issueCalls).toHaveLength(1); + }, 180000); + + it('allows a signed manual fallback for an authenticated reset generation', async () => { + const fixture = writerFixture('permission-denied', WORKFLOW, true); + await fixture.run(); + expect(fixture.failures).toEqual([]); + expect(fixture.issueCalls).toHaveLength(1); + expect(fixture.issueCalls[0].body).toContain('squad/bootstrap-cast-retry-1'); + expect(fixture.commentCalls).toEqual([]); + }); + + it('preserves the original authenticated dispatch through compiled push recovery', async () => { + const fixture = writerFixture('success', compileWorkflow(), true); + await fixture.run(); + fixture.persistedIssues.length = 0; + fixture.context.eventName = 'push'; + fixture.context.runId = 456; + fixture.runtimeEnv.SQUAD_BOOTSTRAP_RUN_ID = '456'; + fixture.runtimeEnv.SQUAD_BOOTSTRAP_FRESH_START = ''; + await fixture.run(); + expect(fixture.failures).toEqual([]); + expect(fixture.updates.at(-1)!.body).toContain('"run_id":"123"'); + expect(fixture.issueCalls.at(-1)!.body).toContain('"run_id":"123"'); + expect(fixture.updates.at(-1)!.body).not.toContain('"run_id":"456"'); + }, 180000); + + it('refuses ordinary recovery of a generation PR without authenticated dispatch provenance', async () => { + const fixture = writerFixture('success', WORKFLOW, true); + await fixture.run(); + fixture.persistedIssues.length = 0; + fixture.persistedComments.length = 0; + fixture.context.eventName = 'push'; + fixture.runtimeEnv.SQUAD_BOOTSTRAP_FRESH_START = ''; + await expect(fixture.run()).rejects.toThrow('original bot-authenticated dispatch provenance'); + expect(fixture.updates).toHaveLength(1); + expect(fixture.issueCalls).toHaveLength(1); + }); + + it('recovers a manually opened generation from its signed dispatch fallback without rebinding the origin', async () => { + const fixture = writerFixture('success', WORKFLOW, true); + await fixture.run(); + fixture.persistedIssues.length = 0; + fixture.persistedComments.length = 0; + fixture.pull.user = { login: 'maintainer', type: 'User' }; + const date = '2026-10-08T00:00:00Z'; + Object.assign(fixture.pull, { created_at: date }); + const compareUrl = buildBootstrapPrFallbackCompareUrl({ + repository: 'octo/example', baseBranch: 'main', headBranch: fixture.pull.head.ref, + title: fixture.pull.title, + }); + fixture.persistedIssues.push(Object.assign({ + number: 7, state: 'open', title: BOOTSTRAP_PR_FALLBACK_ISSUE_TITLE, + body: bootstrapPrFallbackIssueMarker(fixture.pull.head.ref) + '\n' + buildBootstrapPrFallbackProvenanceLine({ + repository: 'octo/example', runId: '123', baseBranch: 'main', baseSha, + headBranch: fixture.pull.head.ref, headSha: castSha, compareUrl, + }), + }, { user: { login: 'github-actions[bot]', type: 'Bot' }, created_at: date, updated_at: date })); + fixture.context.eventName = 'push'; + fixture.context.runId = 456; + fixture.runtimeEnv.SQUAD_BOOTSTRAP_RUN_ID = '456'; + fixture.runtimeEnv.SQUAD_BOOTSTRAP_FRESH_START = ''; + await fixture.run(); + expect(fixture.failures).toEqual([]); + expect(fixture.issueCalls.at(-1)!.body).toContain('"run_id":"123"'); + }); + + it('permits an authenticated rerun of the origin but refuses another run promoting a failed origin', async () => { + const fixture = writerFixture('success', WORKFLOW, true); + await fixture.run(); + fixture.persistedIssues.length = 0; + const getRun = fixture.github.rest.actions.getWorkflowRun; + fixture.github.rest.actions.getWorkflowRun = async () => { + const result = await getRun(); + result.data.status = 'in_progress'; + result.data.conclusion = ''; + return result; + }; + await fixture.run(); + expect(fixture.failures).toEqual([]); + fixture.persistedIssues.length = 0; + fixture.context.runId = 456; + fixture.runtimeEnv.SQUAD_BOOTSTRAP_RUN_ID = '456'; + await expect(fixture.run()).rejects.toThrow('trust root'); + }); + + it('rejects a bot rerun actor in the executable writer before publishing anything', async () => { + const fixture = writerFixture('success', WORKFLOW, true); + const original = process.env.GITHUB_TRIGGERING_ACTOR; + process.env.GITHUB_TRIGGERING_ACTOR = 'automation[bot]'; + fixture.github.rest.repos.getCollaboratorPermissionLevel = async ({ username }) => ({ + data: { permission: 'write', user: { login: username, type: username === 'automation[bot]' ? 'Bot' : 'User' } }, + }); + try { + await expect(fixture.run()).rejects.toThrow('verified human collaborator'); + expect(fixture.publishedRefs).toEqual([]); + expect(fixture.issueCalls).toEqual([]); + } finally { + if (original === undefined) delete process.env.GITHUB_TRIGGERING_ACTOR; + else process.env.GITHUB_TRIGGERING_ACTOR = original; + } + }); + + it('pins the reset parent and refuses publication if the default branch advances mid-write', async () => { + const fixture = writerFixture('success', WORKFLOW, true); + const getRef = fixture.github.rest.git.getRef; + let baseReads = 0; + fixture.github.rest.git.getRef = async args => { + if (args.ref === 'heads/main' && ++baseReads > 1) { + return { data: { object: { sha: 'd'.repeat(40) } } }; + } + return getRef(args); + }; + await expect(fixture.run()).rejects.toThrow('advanced before fresh bootstrap publication'); + expect(fixture.commitParents).toEqual([[baseSha]]); + expect(fixture.publishedRefs).toEqual([]); + expect(fixture.updates).toEqual([]); + expect(fixture.issueCalls).toEqual([]); + }); + + it('fails the compiled authorization contract when the writer permission gate is removed', async () => { + const assertDenied = async (source: string) => { + const fixture = writerFixture('success', compileWorkflow(source), true); + fixture.github.rest.repos.getCollaboratorPermissionLevel = async ({ username }) => + ({ data: { permission: 'read', user: { login: username, type: 'User' } } }); + await expect(fixture.run()).rejects.toThrow('permission'); + expect(fixture.issueCalls).toEqual([]); + expect(fixture.updates).toEqual([]); + }; + await assertDenied(WORKFLOW); + const mutated = WORKFLOW.replace( + /const resetAuthorized = await stateModule\.authorizeBootstrapReset\(\{\n {16}reset, input: process\.env\.SQUAD_BOOTSTRAP_FRESH_START, context, github,\n {14}\}\);/, + 'const resetAuthorized = true;', + ); + expect(mutated).not.toBe(WORKFLOW); + await expect(assertDenied(mutated)).rejects.toThrow(); + }, 180000); + 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(); @@ -1342,7 +1674,7 @@ describe('gh-aw: squad-bootstrap pull-request-creation permission-denied fallbac function compileScript(script: string) { return compileFunction( - `return (async () => {\n${script}\n return pullRequest;\n})();`, + `return (async () => {\nconst reset = null; const resetAuthorized = false;\n${script}\n return pullRequest;\n})();`, ['snapshot', 'payload', 'stateModule', 'context', 'process', 'github', 'core', 'assertRemotePayload'], { importModuleDynamically: vmConstants.USE_MAIN_CONTEXT_DEFAULT_LOADER }, ) as (...args: unknown[]) => Promise; diff --git a/test/gh-aw-install-staging.test.ts b/test/gh-aw-install-staging.test.ts index a06ab2e71..2b18c0855 100644 --- a/test/gh-aw-install-staging.test.ts +++ b/test/gh-aw-install-staging.test.ts @@ -3,6 +3,8 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; import { CONTRACT_DESTINATION, OWNERSHIP_DESTINATION, @@ -71,6 +73,42 @@ afterEach(() => { }); describe('gh-aw: verified ownership staging under consumer ignore rules', () => { + it('installs a coherent reset-capable package and excludes only the named historical Cast', async () => { + const root = consumer(); + const reset = { + schema: 'squad-bootstrap-reset/v1', id: 'retry-1', + archived_pull_requests: [3], archived_issues: [], + }; + write(root, '.squad/bootstrap-reset.json', JSON.stringify(reset)); + git(root, 'add', '--', '.squad/bootstrap-reset.json'); + git(root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', + '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'reviewed reset preparation'); + expect(verifyInstall(root, { expectedRevision: revision }).failures).toEqual([]); + const installed = await import(pathToFileURL(join(root, + '.github/workflows/shared/squad-bootstrap-validator.mjs')).href); + const lock = parse(readFileSync(join(root, '.github/workflows/squad-bootstrap.lock.yml'), 'utf8')); + expect(lock.on.workflow_dispatch.inputs.fresh_start.type).toBe('string'); + const writer = lock.jobs.materialize_bootstrap.steps.find( + (step: { name?: string }) => step.name === 'Validate and materialize both artifacts', + ); + expect(writer.env.SQUAD_BOOTSTRAP_FRESH_START).toBe('${{ inputs.fresh_start }}'); + expect(writer.with.script).toContain('stateModule.authorizeBootstrapReset'); + expect(writer.with.script).toContain('...resetState'); + expect(installed.readBootstrapReset(root)).toEqual(reset); + expect(installed.hasCommittedBootstrapTeam(root)).toBe(false); + const state = { + reset, repository: 'octo/example', defaultBranch: 'main', issues: [], + pullRequests: [{ + number: 3, title: '[squad] Cast your Squad', state: 'closed', merged_at: null, + head: { ref: 'squad/bootstrap-cast', repo: { full_name: 'octo/example' } }, + base: { ref: 'main', repo: { full_name: 'octo/example' } }, + }], + }; + expect(installed.classifyBootstrapState(state).action).toBe('reset_armed'); + expect(installed.classifyBootstrapState({ ...state, resetAuthorized: true }).action).toBe('create_both'); + expect(installed.classifyBootstrapState({ ...state, reset: null }).action).toBe('opt_out'); + }); + it('bounds staged reads to required paths in a large unrelated consumer tree', () => { const root = consumer(); const unrelated = 'consumer-data'; diff --git a/test/gh-aw-review-guard.test.ts b/test/gh-aw-review-guard.test.ts index b61aa2fed..bb588d477 100644 --- a/test/gh-aw-review-guard.test.ts +++ b/test/gh-aw-review-guard.test.ts @@ -154,6 +154,9 @@ function fixture(relay = false) { } return encode(attribution); } + if (route.endsWith('/contents/.squad/bootstrap-reset.json')) { + throw Object.assign(new Error('Not Found'), { status: 404 }); + } if (route.endsWith('/contents/.squad/casting/registry.json')) { expect(fields?.ref).toBe(BASE); return encode(registry); @@ -297,6 +300,93 @@ function retainFirstAttempt(f: ReturnType) { return first; } +describe('generation-bound fresh bootstrap review', () => { + const reset = { + schema: 'squad-bootstrap-reset/v1', id: 'retry-1', + archived_pull_requests: [3], archived_issues: [], + }; + function freshFixture(manual = false) { + const f = fixture(); + makeBootstrap(f); + f.pr.head.ref = 'squad/bootstrap-cast-retry-1'; + f.pr.title += ' [reset:retry-1]'; + f.bootstrapRun.event = 'workflow_dispatch'; + const actors = { + actor: { login: 'maintainer', type: 'User' }, + triggering_actor: { login: 'maintainer', type: 'User' }, + }; + Object.assign(f.bootstrapRun, actors); + if (manual) { + f.pr.user = { login: 'human', type: 'User' }; + f.state.comments = []; + f.state.repoIssues = [makeBootstrapPrFallbackIssue({ + headBranch: f.pr.head.ref, baseBranch: f.pr.base.ref, + compareUrlOverride: buildBootstrapPrFallbackCompareUrl({ + repository: REPOSITORY, baseBranch: f.pr.base.ref, + headBranch: f.pr.head.ref, title: f.pr.title, server: 'https://github.com', + }), + })]; + } + const state = { reset, recordedReset: reset }; + const get = async (route: string, fields?: Record) => { + if (route.endsWith('/contents/.squad/bootstrap-reset.json')) { + return f.encode(fields?.ref === f.pr.base.sha ? state.reset : state.recordedReset); + } + return f.get(route, fields); + }; + return { ...f, get, actors, resetState: state }; + } + + it.each([false, true])('authorizes exact authenticated dispatch provenance (manual fallback=%s)', async manual => { + const f = freshFixture(manual); + expect((await reviewTarget(f.env, f.get, { requireBootstrapRunSuccess: true })).author_agent) + .toBe('@squad/base-controlled-bootstrap'); + expect(f.state.calls.some(route => route.includes('/collaborators/maintainer/permission'))).toBe(true); + }); + + it.each([false, true])('rejects wrong actor, identity, head and unsuccessful runs (fallback=%s)', async manual => { + for (const mutate of [ + (f: ReturnType) => { f.state.permission = 'read'; }, + (f: ReturnType) => { f.actors.triggering_actor.type = 'Bot'; }, + (f: ReturnType) => { f.resetState.reset = { ...reset, id: 'other' }; }, + (f: ReturnType) => { f.bootstrapRun.head_sha = HEAD; }, + (f: ReturnType) => { f.bootstrapRun.conclusion = 'failure'; }, + (f: ReturnType) => { f.bootstrapRun.path = '.github/workflows/other.yml'; }, + (f: ReturnType) => { f.bootstrapRun.event = 'push'; }, + ]) { + const f = freshFixture(manual); + mutate(f); + await expect(reviewTarget(f.env, f.get, { requireBootstrapRunSuccess: true })).rejects.toThrow(); + } + }); + + it('keeps legacy manual dispatch ineligible and refuses changed committed reset history', async () => { + const legacy = fixture(); + makeBootstrap(legacy); + legacy.bootstrapRun.event = 'workflow_dispatch'; + await expect(reviewTarget(legacy.env, legacy.get)).rejects.toThrow('base-controlled workflow run'); + const f = freshFixture(); + f.pr.base.sha = '9'.repeat(40); + f.env.SQUAD_REVIEW_WORKFLOW_SHA = f.pr.base.sha; + f.state.compareStatus = 'ahead'; + f.resetState.recordedReset = { ...reset, archived_pull_requests: [99] }; + await expect(reviewTarget(f.env, f.get)).rejects.toThrow('reset changed'); + }); + + it.each([false, true])('refuses reopened archived legacy Cast authorization (fallback=%s)', async manual => { + const f = fixture(); + if (manual) makeBootstrapPrFallback(f); + else makeBootstrap(f); + const get = async (route: string, fields?: Record) => { + if (route.endsWith('/contents/.squad/bootstrap-reset.json')) { + return f.encode({ ...reset, archived_pull_requests: [42] }); + } + return f.get(route, fields); + }; + await expect(reviewTarget(f.env, get)).rejects.toThrow('archived bootstrap pull requests'); + }); +}); + describe('independent Squad review guard', () => { it('uses reserved workflow roles only for the base-controlled post-install bootstrap PR', async () => { const f = fixture(); diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 2d80454a1..cd3a6661b 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -15,7 +15,12 @@ on: - .github/aw/packages/*.json - .github/aw/squad/runtime/** - .github/workflows/shared/squad-bootstrap-trigger-probe.json - workflow_dispatch: null + workflow_dispatch: + inputs: + fresh_start: + description: "Explicit fresh start: enter the ID from committed .squad/bootstrap-reset.json; otherwise leave blank" + type: string + required: false if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) permissions: contents: read @@ -48,6 +53,7 @@ pre-agent-steps: uses: actions/github-script@v9 env: SQUAD_BOOTSTRAP_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + SQUAD_BOOTSTRAP_FRESH_START: ${{ inputs.fresh_start }} with: script: | const { writeFileSync } = await import('node:fs'); @@ -57,6 +63,15 @@ pre-agent-steps: process.env.GITHUB_WORKSPACE, '.github/workflows/shared/squad-bootstrap-validator.mjs', )).href); + const reset = stateModule.readBootstrapReset(process.env.GITHUB_WORKSPACE); + const resetAuthorized = await stateModule.authorizeBootstrapReset({ + reset, input: process.env.SQUAD_BOOTSTRAP_FRESH_START, context, github, + }); + const resetState = { + reset, resetAuthorized, + repository: `${context.repo.owner}/${context.repo.repo}`, + installedTeam: stateModule.hasCommittedBootstrapTeam(process.env.GITHUB_WORKSPACE), + }; const pullRequests = await github.paginate(github.rest.pulls.list, { ...context.repo, state: 'all', @@ -70,6 +85,7 @@ pre-agent-steps: let state; try { const preliminary = stateModule.classifyBootstrapState({ + ...resetState, pullRequests, issues, defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, @@ -82,6 +98,7 @@ pre-agent-steps: }) : []; state = stateModule.classifyBootstrapState({ + ...resetState, pullRequests, issues, comments, @@ -154,7 +171,7 @@ pre-agent-steps: # BEGIN GENERATED RESOURCE DIGESTS check_hash "$install_verifier" "a279cd5c4adeb613ceb90c1bfbb9818265aeb6bc8986e2e3799e96f7c2d787e5" check_hash "$cast_validator" "c6d0b92aac71dc6f6d5727cac418a323b0bc9c12047400faa12d96150d548ada" - check_hash "$bootstrap_validator" "ea43df25b9813cbb78d85abaf1b0b906b64b202915c34bdb1562ddd05af03797" + check_hash "$bootstrap_validator" "ac969d1cc92353219ba312d0d9af8702b1a7a569b4e8d5c79ae71e4aa020aad6" # END GENERATED RESOURCE DIGESTS node "$bootstrap_validator" \ --encode-payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ @@ -185,6 +202,7 @@ safe-outputs: contents: write issues: write pull-requests: write + actions: read inputs: payload_encoding: description: Fixed bootstrap payload encoding; must be base64. @@ -238,7 +256,7 @@ safe-outputs: - name: Checkout trusted default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - ref: refs/heads/${{ github.event.repository.default_branch }} + ref: ${{ github.sha }} persist-credentials: false path: bootstrap-repo - name: Validate and materialize both artifacts @@ -248,6 +266,7 @@ safe-outputs: SQUAD_BOOTSTRAP_INSTALL_SHA: ${{ github.sha }} SQUAD_BOOTSTRAP_REPOSITORY: ${{ github.repository }} SQUAD_BOOTSTRAP_RUN_ID: ${{ github.run_id }} + SQUAD_BOOTSTRAP_FRESH_START: ${{ inputs.fresh_start }} with: script: | const { @@ -259,10 +278,20 @@ safe-outputs: const { pathToFileURL } = await import('node:url'); const checkout = join(process.env.GITHUB_WORKSPACE, 'bootstrap-repo'); - const stateModule = await import(pathToFileURL(join( + const bootstrapModule = await import(pathToFileURL(join( checkout, '.github/workflows/shared/squad-bootstrap-validator.mjs', )).href); + const reset = bootstrapModule.readBootstrapReset(checkout); + const stateModule = { ...bootstrapModule, ...bootstrapModule.bootstrapIdentity(reset) }; + const resetAuthorized = await stateModule.authorizeBootstrapReset({ + reset, input: process.env.SQUAD_BOOTSTRAP_FRESH_START, context, github, + }); + const resetState = { + reset, resetAuthorized, + repository: `${context.repo.owner}/${context.repo.repo}`, + installedTeam: stateModule.hasCommittedBootstrapTeam(checkout), + }; const validatorModule = await import(pathToFileURL(join( checkout, '.github/workflows/shared/squad-bootstrap-validator.mjs', @@ -357,6 +386,7 @@ safe-outputs: per_page: 100, })).filter((issue) => !issue.pull_request); const preliminary = stateModule.classifyBootstrapState({ + ...resetState, pullRequests, issues, defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, @@ -373,6 +403,7 @@ safe-outputs: issues, comments, state: stateModule.classifyBootstrapState({ + ...resetState, pullRequests, issues, comments, @@ -382,6 +413,10 @@ safe-outputs: }; let snapshot = await listState(); + if (snapshot.state.action === 'reset_armed') { + core.info('Fresh bootstrap is armed; a maintainer must manually dispatch the exact fresh_start ID.'); + return; + } if (snapshot.state.action === 'opt_out') { core.info('A closed-unmerged bootstrap Cast PR records human opt-out; no replacement was created.'); return; @@ -429,19 +464,28 @@ safe-outputs: }; let pullRequest = snapshot.state.pull_request; + const recoveringReset = reset && Boolean(pullRequest); if (!pullRequest) { + if (reset) { + const liveBase = await github.rest.git.getRef({ + ...context.repo, ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, + }); + if (liveBase.data.object.sha !== context.sha) { + throw new Error('Default branch advanced during fresh bootstrap; dispatch again against its new tip.'); + } + } 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({ + const baseSha = reset ? context.sha : (await github.rest.git.getRef({ ...context.repo, ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, - }); + })).data.object.sha; const baseCommit = await github.rest.git.getCommit({ ...context.repo, - commit_sha: baseRef.data.object.sha, + commit_sha: baseSha, }); const tree = []; for (const file of payload.files) { @@ -466,8 +510,16 @@ safe-outputs: ...context.repo, message: 'chore(squad): add repository-derived Squad', tree: createdTree.data.sha, - parents: [baseRef.data.object.sha], + parents: [baseSha], }); + if (reset) { + const liveBase = await github.rest.git.getRef({ + ...context.repo, ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, + }); + if (liveBase.data.object.sha !== baseSha) { + throw new Error('Default branch advanced before fresh bootstrap publication; dispatch again.'); + } + } await github.rest.git.createRef({ ...context.repo, ref: `refs/heads/${stateModule.BOOTSTRAP_BRANCH}`, @@ -525,14 +577,9 @@ safe-outputs: ); 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') { + // Legacy fallback provenance remains push-only. The only dispatch exception + // is a committed generation with independently checked maintainer authorization. + if (context.eventName !== 'push' && !(reset && resetAuthorized)) { 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 ` + @@ -596,7 +643,18 @@ safe-outputs: ...context.repo, pull_number: pullRequest.number, })).data; - const provenance = { + const provenance = recoveringReset + ? await stateModule.recoverBootstrapResetProvenance({ + reset, github, + repository: process.env.SQUAD_BOOTSTRAP_REPOSITORY, + defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + currentSha: context.sha, currentRunId: context.runId, resetAuthorized, + pullRequest: pullRequestDetails, issues: snapshot.issues, + comments: await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: pullRequest.number, per_page: 100, + }), + }) + : { schema: 1, repository: process.env.SQUAD_BOOTSTRAP_REPOSITORY, run_id: process.env.SQUAD_BOOTSTRAP_RUN_ID, @@ -604,8 +662,8 @@ safe-outputs: 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 + || (!recoveringReset && (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 @@ -746,6 +804,9 @@ Read `.github/workflows/squad-bootstrap-state.json` before doing any analysis. - `noop`: call `noop` and stop. Both artifacts already exist. - `opt_out`: call `noop` with a message that the closed-unmerged Cast PR records human opt-out, then stop. Never create a replacement. +- `reset_armed`: call `noop` and stop. Only an authenticated maintainer's manual + dispatch with `fresh_start` equal to the committed reset ID may create this + generation. Never turn an ordinary push or blank manual run into a reset. - `create_both`: generate one shared payload and request materialization. - `create_issue`: preserve the checked-out existing Cast tree, generate the issue from it, and request materialization. @@ -821,6 +882,12 @@ selected by this Cast, but never remove or rewrite the four built-ins. Write `.github/workflows/squad-bootstrap-payload.json` with this exact shape: +For a committed reset, use the exact `identity.BOOTSTRAP_BRANCH`, +`identity.BOOTSTRAP_PR_TITLE`, and `identity.BOOTSTRAP_ISSUE_TITLE` from the +deterministic state file instead of the legacy values shown below. The writer +and validator independently load this identity from committed Git history; +never choose or edit the reset ID, record, archived numbers, or output names. + ```json { "schema_version": "1", diff --git a/workflows/package/squad-review.md b/workflows/package/squad-review.md index 00b831465..6bea852e6 100644 --- a/workflows/package/squad-review.md +++ b/workflows/package/squad-review.md @@ -186,7 +186,12 @@ for the exact `squad/bootstrap-cast` PR created by the base-controlled bootstrap workflow. It verifies the GitHub Actions bot author, canonical title and branch, matching provenance in the PR body and a durable bot-authored PR comment, exact base/head SHAs, and the referenced default-branch `squad-bootstrap.lock.yml` -push run. It binds the reserved roles +push run. A fresh-reset generation instead uses the exact branch and title +derived from the maintainer-reviewed `.squad/bootstrap-reset.json`. Its manual +dispatch is eligible only when the guard independently verifies human actor +permissions and the same committed reset record at the recorded run SHA and +current PR base. No reset record broadens legacy manual-dispatch authorization. +It binds the reserved roles `@squad/base-controlled-bootstrap` and `@squad/base-controlled-review`; the clearing gate additionally requires that bootstrap run to finish successfully. All other missing, malformed, or non-404 attribution evidence is refused. diff --git a/workflows/shared/squad-bootstrap-validator.mjs b/workflows/shared/squad-bootstrap-validator.mjs index 5fa9367ae..2803ecb70 100644 --- a/workflows/shared/squad-bootstrap-validator.mjs +++ b/workflows/shared/squad-bootstrap-validator.mjs @@ -11,6 +11,154 @@ export const BOOTSTRAP_BRANCH = 'squad/bootstrap-cast'; export const BOOTSTRAP_PR_TITLE = '[squad] Cast your Squad'; export const BOOTSTRAP_ISSUE_TITLE = '[Research Proposals] Agent-discovered repo opportunities'; export const BOOTSTRAP_ISSUE_MARKER = ''; +export const BOOTSTRAP_RESET_PATH = '.squad/bootstrap-reset.json'; + +export function parseBootstrapReset(value) { + if (value === undefined) return null; + const fields = ['schema', 'id', 'archived_pull_requests', 'archived_issues']; + if (!value || typeof value !== 'object' || Array.isArray(value) || + Object.keys(value).sort().join() !== fields.sort().join() || + value.schema !== 'squad-bootstrap-reset/v1' || + typeof value.id !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(value.id)) { + throw new Error('Invalid committed bootstrap reset record.'); + } + for (const key of ['archived_pull_requests', 'archived_issues']) { + const numbers = value[key]; + if (!Array.isArray(numbers) || numbers.length > 100 || + numbers.some(number => !Number.isSafeInteger(number) || number < 1) || + new Set(numbers).size !== numbers.length) { + throw new Error(`Invalid bootstrap reset ${key}; expected unique positive artifact numbers.`); + } + } + if (!value.archived_pull_requests.length) { + throw new Error('A bootstrap reset must name at least one closed-unmerged Cast pull request.'); + } + return value; +} + +export function readBootstrapReset(root) { + const path = execFileSync('git', ['ls-tree', '--name-only', 'HEAD', '--', BOOTSTRAP_RESET_PATH], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + return path ? parseBootstrapReset(JSON.parse(execFileSync('git', ['show', `HEAD:${BOOTSTRAP_RESET_PATH}`], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }))) : null; +} + +export function bootstrapIdentity(reset = null) { + const suffix = reset ? ` [reset:${parseBootstrapReset(reset).id}]` : ''; + return { + BOOTSTRAP_BRANCH: BOOTSTRAP_BRANCH + (reset ? `-${reset.id}` : ''), + BOOTSTRAP_PR_TITLE: BOOTSTRAP_PR_TITLE + suffix, + BOOTSTRAP_ISSUE_TITLE: BOOTSTRAP_ISSUE_TITLE + suffix, + }; +} + +export function hasCommittedBootstrapTeam(root) { + return execFileSync('git', ['ls-tree', '--name-only', 'HEAD', '--', + '.squad/team.md', '.squad/casting/registry.json'], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim().length > 0; +} + +export async function authorizeBootstrapReset({ + reset, input, context, github, triggeringActor = process.env.GITHUB_TRIGGERING_ACTOR || context.actor, +}) { + if (!input && (!reset || context.eventName !== 'workflow_dispatch')) return false; + if (!reset || (input && input !== reset.id) || context.eventName !== 'workflow_dispatch' || + context.ref !== `refs/heads/${context.payload.repository.default_branch}`) { + throw new Error('Fresh bootstrap requires the committed reset ID and a default-branch manual dispatch.'); + } + for (const actor of new Set([context.actor, context.payload.sender?.login, + triggeringActor])) { + if (!actor || context.payload.sender?.type !== 'User') { + throw new Error('Fresh bootstrap requires an authenticated human maintainer.'); + } + const result = await github.rest.repos.getCollaboratorPermissionLevel({ ...context.repo, username: actor }); + if (result.data.user?.type !== 'User' || result.data.user?.login !== actor) { + throw new Error('Fresh bootstrap requires each actor to be a verified human collaborator.'); + } + if (!['write', 'maintain', 'admin'].includes(result.data.permission)) { + throw new Error('Fresh bootstrap requires write, maintain, or admin repository permission.'); + } + } + return Boolean(input); +} + +export async function recoverBootstrapResetProvenance({ + reset, github, repository, defaultBranch, currentSha, currentRunId, resetAuthorized, + pullRequest, issues, comments, +}) { + const identity = bootstrapIdentity(reset); + const prefix = '<' + '!-- squad:bootstrap-provenance '; + let provenance; + if (pullRequest.user?.login === 'github-actions[bot]' && pullRequest.user?.type === 'Bot') { + const records = String(pullRequest.body || '').split('\n').filter(line => line.startsWith(prefix)); + const authenticated = comments.filter(comment => comment.user?.login === 'github-actions[bot]' && + comment.user?.type === 'Bot' && String(comment.body || '').startsWith(prefix)); + if (records.length !== 1 || !records[0].endsWith(' -->') || authenticated.length !== 1 || + authenticated[0].body.split('\n')[0] !== records[0]) { + throw new Error('Reset recovery requires the original bot-authenticated dispatch provenance.'); + } + provenance = JSON.parse(records[0].slice(prefix.length, -4)); + } else if (pullRequest.user?.type === 'User') { + const fallback = findExistingBootstrapPrFallbackIssue(issues, identity.BOOTSTRAP_BRANCH, { + repository, baseBranch: defaultBranch, headSha: pullRequest.head?.sha, + }); + const record = fallback && parseBootstrapPrFallbackProvenance(fallback.body); + if (!record || record.compare_url !== buildBootstrapPrFallbackCompareUrl({ + repository, baseBranch: defaultBranch, headBranch: identity.BOOTSTRAP_BRANCH, + title: identity.BOOTSTRAP_PR_TITLE, server: process.env.GITHUB_SERVER_URL, + }) || !(Date.parse(fallback.created_at) <= Date.parse(pullRequest.created_at))) { + throw new Error('Reset recovery requires the original authenticated manual-fallback provenance.'); + } + provenance = { + schema: 1, repository, run_id: record.run_id, install_sha: record.base_sha, cast_sha: record.head_sha, + }; + } + if (!provenance || provenance.schema !== 1 || + Object.keys(provenance).sort().join() !== ['schema', 'repository', 'run_id', 'install_sha', 'cast_sha'].sort().join() || + provenance.repository !== repository || !/^[1-9]\d*$/.test(provenance.run_id) || + !Number.isSafeInteger(Number(provenance.run_id)) || + !/^[0-9a-f]{40}$/.test(provenance.install_sha) || provenance.cast_sha !== pullRequest.head?.sha || + pullRequest.head?.repo?.full_name !== repository || pullRequest.base?.ref !== defaultBranch || + pullRequest.head?.ref !== identity.BOOTSTRAP_BRANCH || pullRequest.title !== identity.BOOTSTRAP_PR_TITLE) { + throw new Error('Reset recovery provenance does not match the exact generation and Cast head.'); + } + const [owner, repo] = repository.split('/'); + const { data: run } = await github.rest.actions.getWorkflowRun({ owner, repo, run_id: Number(provenance.run_id) }); + const authorizedOriginRerun = resetAuthorized && String(currentRunId) === provenance.run_id && + run.status === 'in_progress'; + if (run.event !== 'workflow_dispatch' || run.path !== '.github/workflows/squad-bootstrap.lock.yml' || + run.repository?.full_name !== repository || run.head_sha !== provenance.install_sha || + run.head_branch !== defaultBranch || + (!authorizedOriginRerun && (run.status !== 'completed' || run.conclusion !== 'success')) || + run.actor?.type !== 'User' || run.triggering_actor?.type !== 'User') { + throw new Error('Reset recovery must retain a successful authenticated fresh-start dispatch as its trust root.'); + } + const { data: file } = await github.rest.repos.getContent({ + owner, repo, path: BOOTSTRAP_RESET_PATH, ref: provenance.install_sha, + }); + if (file?.type !== 'file' || file.encoding !== 'base64' || !Number.isSafeInteger(file.size) || + file.size > 16000 || typeof file.content !== 'string' || + Buffer.from(file.content, 'base64').length !== file.size || + JSON.stringify(parseBootstrapReset(JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')))) !== JSON.stringify(reset)) { + throw new Error('Reset recovery record changed since its original dispatch.'); + } + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ + owner, repo, basehead: `${provenance.install_sha}...${currentSha}`, + }); + if (!['identical', 'ahead'].includes(comparison.status) || + comparison.base_commit?.sha !== provenance.install_sha || + comparison.merge_base_commit?.sha !== provenance.install_sha) { + throw new Error('Reset recovery origin is not an ancestor of the current default branch.'); + } + await authorizeBootstrapReset({ + reset, input: reset.id, github, triggeringActor: run.triggering_actor.login, + context: { + repo: { owner, repo }, actor: run.actor.login, ref: `refs/heads/${defaultBranch}`, + eventName: run.event, payload: { repository: { default_branch: defaultBranch }, sender: run.actor }, + }, + }); + return provenance; +} export const BOOTSTRAP_RESEARCH_TITLE = '## 🔬 Squad Research — Bootstrap proposals'; export const RESEARCH_SCOPE_PATH = '.squad/research-scope.json'; export const RESEARCH_SCOPE_SCHEMA = 'squad-research-scope/v1'; @@ -534,20 +682,58 @@ export function buildBootstrapPrFallbackIssueBody({ ); } -export function classifyBootstrapState({ pullRequests, issues, comments, defaultBranch }) { +export function classifyBootstrapState({ + pullRequests, issues, comments, defaultBranch, reset = null, repository, + resetAuthorized = false, installedTeam = false, +}) { if (!Array.isArray(pullRequests) || !Array.isArray(issues) || !defaultBranch) { throw new Error('Bootstrap state requires pullRequests, issues, and defaultBranch.'); } + const identity = bootstrapIdentity(reset); + const canonicalPull = pull => { + const head = pullHead(pull); + const suffix = head?.slice(BOOTSTRAP_BRANCH.length); + return (head === BOOTSTRAP_BRANCH || /^-[a-z][a-z0-9-]{0,31}$/.test(suffix)) && + pull.title === BOOTSTRAP_PR_TITLE + (suffix ? ` [reset:${suffix.slice(1)}]` : '') && + pullBase(pull) === defaultBranch; + }; + if (reset) { + parseBootstrapReset(reset); + for (const number of reset.archived_pull_requests) { + const matches = pullRequests.filter(pull => pull.number === number); + const pull = matches[0]; + if (matches.length !== 1 || !canonicalPull(pull) || pull.state !== 'closed' || + pull.merged_at || pull.merged === true || !repository || + pull.head?.repo?.full_name !== repository || pull.base?.repo?.full_name !== repository || + pullHead(pull) === identity.BOOTSTRAP_BRANCH) { + throw new Error(`Unsafe bootstrap reset archive pull request #${number}.`); + } + } + for (const number of reset.archived_issues) { + const matches = issues.filter(issue => issue.number === number); + const issue = matches[0]; + const research = issue && (issue.title === BOOTSTRAP_ISSUE_TITLE || + /^\[Research Proposals\] Agent-discovered repo opportunities \[reset:[a-z][a-z0-9-]{0,31}\]$/.test(issue.title)) && + occurrences(issueBody(issue), BOOTSTRAP_ISSUE_MARKER) === 1; + const fallback = issue?.title === BOOTSTRAP_PR_FALLBACK_ISSUE_TITLE && + parseBootstrapPrFallbackProvenance(issueBody(issue))?.repository === repository; + if (matches.length !== 1 || issue.state !== 'closed' || (!research && !fallback) || + issue.title === identity.BOOTSTRAP_ISSUE_TITLE) { + throw new Error(`Unsafe bootstrap reset archive issue #${number}.`); + } + } + } const relevantPullRequests = pullRequests.filter( (pullRequest) => - pullHead(pullRequest) === BOOTSTRAP_BRANCH || - pullRequest?.title === BOOTSTRAP_PR_TITLE, + !reset?.archived_pull_requests.includes(pullRequest.number) && + (pullHead(pullRequest)?.startsWith(BOOTSTRAP_BRANCH) || + pullRequest?.title?.startsWith(BOOTSTRAP_PR_TITLE)), ); const malformedPullRequest = relevantPullRequests.find( (pullRequest) => - pullHead(pullRequest) !== BOOTSTRAP_BRANCH || - pullRequest?.title !== BOOTSTRAP_PR_TITLE || + pullHead(pullRequest) !== identity.BOOTSTRAP_BRANCH || + pullRequest?.title !== identity.BOOTSTRAP_PR_TITLE || pullBase(pullRequest) !== defaultBranch, ); if (malformedPullRequest) { @@ -563,12 +749,13 @@ export function classifyBootstrapState({ pullRequests, issues, comments, default const relevantIssues = issues.filter( (issue) => - issue?.title === BOOTSTRAP_ISSUE_TITLE || - issueBody(issue).includes(BOOTSTRAP_ISSUE_MARKER), + !reset?.archived_issues.includes(issue.number) && + (issue?.title?.startsWith(BOOTSTRAP_ISSUE_TITLE) || + issueBody(issue).includes(BOOTSTRAP_ISSUE_MARKER)), ); const malformedIssue = relevantIssues.find( (issue) => - issue?.title !== BOOTSTRAP_ISSUE_TITLE || + issue?.title !== identity.BOOTSTRAP_ISSUE_TITLE || occurrences(issueBody(issue), BOOTSTRAP_ISSUE_MARKER) !== 1, ); if (malformedIssue) { @@ -584,6 +771,9 @@ export function classifyBootstrapState({ pullRequests, issues, comments, default const pullRequest = relevantPullRequests[0] ?? null; const issue = relevantIssues[0] ?? null; + if (reset && installedTeam && !pullRequest?.merged_at && pullRequest?.merged !== true) { + throw new Error('Fresh bootstrap cannot replace a committed team or registry.'); + } const researchArtifacts = issue && comments !== undefined ? findBootstrapResearchArtifacts(comments, issue.number) @@ -601,9 +791,11 @@ export function classifyBootstrapState({ pullRequests, issues, comments, default else if (pullRequest) action = 'create_issue'; else if (issue) action = 'create_pr'; else action = 'create_both'; + if (reset && !pullRequest && !resetAuthorized) action = 'reset_armed'; return { action, + identity, pull_request: pullRequest ? { number: pullRequest.number, @@ -893,13 +1085,19 @@ export function validateBootstrapPayload({ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return ['payload: expected a JSON object']; } + let identity; + try { + identity = bootstrapIdentity(readBootstrapReset(gitRoot)); + } catch (error) { + return [`payload: committed bootstrap identity unavailable (${error.message})`]; + } if (payload.schema_version !== '1') errors.push('payload: schema_version must be "1"'); if (payload.repository !== repository) errors.push('payload: repository does not match the runtime repository'); if (payload.default_branch !== defaultBranch) errors.push('payload: default_branch does not match the runtime default'); - if (payload.branch !== BOOTSTRAP_BRANCH) errors.push(`payload: branch must be ${BOOTSTRAP_BRANCH}`); - if (payload.pr_title !== BOOTSTRAP_PR_TITLE) errors.push(`payload: pr_title must be ${BOOTSTRAP_PR_TITLE}`); - if (payload.issue_title !== BOOTSTRAP_ISSUE_TITLE) { - errors.push(`payload: issue_title must be ${BOOTSTRAP_ISSUE_TITLE}`); + if (payload.branch !== identity.BOOTSTRAP_BRANCH) errors.push(`payload: branch must be ${identity.BOOTSTRAP_BRANCH}`); + if (payload.pr_title !== identity.BOOTSTRAP_PR_TITLE) errors.push(`payload: pr_title must be ${identity.BOOTSTRAP_PR_TITLE}`); + if (payload.issue_title !== identity.BOOTSTRAP_ISSUE_TITLE) { + errors.push(`payload: issue_title must be ${identity.BOOTSTRAP_ISSUE_TITLE}`); } if (!Array.isArray(payload.files) || payload.files.length === 0) { errors.push('payload: files must be a non-empty array'); diff --git a/workflows/shared/squad-review-guard.mjs b/workflows/shared/squad-review-guard.mjs index 037b952d8..c4ae77b2f 100644 --- a/workflows/shared/squad-review-guard.mjs +++ b/workflows/shared/squad-review-guard.mjs @@ -1,6 +1,9 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { evaluateImplementDispatchInputs } from './squad-retro-provenance.mjs'; import { + BOOTSTRAP_RESET_PATH, + bootstrapIdentity, + parseBootstrapReset, BOOTSTRAP_PR_FALLBACK_ISSUE_TITLE, bootstrapPrFallbackIssueMarker, buildBootstrapPrFallbackCompareUrl, @@ -19,7 +22,6 @@ const ID = /^[a-z][a-z0-9-]*$/; const BOT = 'github-actions[bot]'; const BOT_ID = 41898282; const BOOTSTRAP_BRANCH = 'squad/bootstrap-cast'; -const BOOTSTRAP_TITLE = '[squad] Cast your Squad'; const BOOTSTRAP_AUTHOR = '@squad/base-controlled-bootstrap'; const BOOTSTRAP_REVIEWER = '@squad/base-controlled-review'; const BOOTSTRAP_PREFIX = '<' + '!-- squad:bootstrap-provenance '; @@ -174,6 +176,38 @@ async function requireRecordedBaseAncestor(get, repository, recordedSha, liveSha ); } +async function bootstrapReviewIdentity(get, repository, pr) { + const reset = parseBootstrapReset(await committedJson( + get, repository, BOOTSTRAP_RESET_PATH, pr.base.sha, { allowMissing: true }, + )); + requireThat(!reset?.archived_pull_requests.includes(pr.number), + 'archived bootstrap pull requests cannot regain review authorization'); + if (pr.head.ref === BOOTSTRAP_BRANCH) return { reset: null, ...bootstrapIdentity() }; + requireThat(/^squad\/bootstrap-cast-[a-z][a-z0-9-]{0,31}$/.test(pr.head.ref), + 'invalid base-controlled bootstrap pull request branch'); + requireThat(reset && bootstrapIdentity(reset).BOOTSTRAP_BRANCH === pr.head.ref, + 'bootstrap generation does not match the committed reset'); + return { reset, ...bootstrapIdentity(reset) }; +} + +async function trustedBootstrapEvent(get, repository, run, identity) { + if (!identity.reset) return run.event === 'push'; + const recordedReset = parseBootstrapReset(await committedJson( + get, repository, BOOTSTRAP_RESET_PATH, run.head_sha, + )); + requireThat(JSON.stringify(recordedReset) === JSON.stringify(identity.reset), + 'bootstrap reset changed since the provenance run'); + if (run.event !== 'workflow_dispatch') return false; + for (const actor of [run.actor, run.triggering_actor]) { + requireThat(actor?.type === 'User' && typeof actor.login === 'string', + 'fresh bootstrap run requires an authenticated human maintainer'); + const access = await get(`repos/${repository}/collaborators/${encodeURIComponent(actor.login)}/permission`); + requireThat(['write', 'maintain', 'admin'].includes(access.permission), + 'fresh bootstrap run actor lacks maintainer permission'); + } + return true; +} + // The post-install bootstrap PR fallback (documented alongside `can_approve_pull_request_reviews: false` // in docs/src/content/docs/guide/gh-aw.md) asks a human to open the Cast PR manually from a compare-URL // link after `pulls.create` is permission-denied. That PR is human-authored, so it cannot satisfy the @@ -189,15 +223,16 @@ async function requireRecordedBaseAncestor(get, repository, recordedSha, liveSha // check (SHA pinning, default-branch targeting, workflow-source binding, etc.) is enforced unchanged by // the surrounding `reviewTarget`. This never relies on `pulls.create`/review-approval permissions. async function validateBootstrapPrFallbackAttribution(env, get, repository, pr, requireRunSuccess) { - requireThat(pr.head.ref === BOOTSTRAP_BRANCH && pr.user?.type === 'User', + const identity = await bootstrapReviewIdentity(get, repository, pr); + requireThat(pr.head.ref === identity.BOOTSTRAP_BRANCH && pr.user?.type === 'User', 'pull request does not match the documented manual Cast fallback shape'); - // Mirrors validateBootstrapAttribution's own `pr.title === BOOTSTRAP_TITLE` requirement below: + // Mirrors the bot-authored path's canonical title requirement: // without it, a manually opened PR with an arbitrary title passes this review path (the compare // URL a human is asked to open already pre-fills this exact title via its `title` query param), // but classifyBootstrapState() rejects any PR on this branch with another title, so a manual // bootstrap rerun — or the post-merge bootstrap run itself — would then fail before ever creating // the linked research-proposals issue. - requireThat(pr.title === BOOTSTRAP_TITLE, + requireThat(pr.title === identity.BOOTSTRAP_PR_TITLE, 'pull request does not match the documented manual Cast fallback shape'); // Deliberately omits `baseSha` here (unlike squad-bootstrap.md's own push-triggered dedupe // call): a human may open this Cast PR well after the default branch has legitimately @@ -207,7 +242,7 @@ async function validateBootstrapPrFallbackAttribution(env, get, repository, pr, // the default branch. The recorded (immutable) `base_sha` is instead validated for live // ancestry below, and the base-controlled run binding is pinned to that same immutable value. const issues = await list(get, `repos/${repository}/issues`); - const fallbackIssue = findExistingBootstrapPrFallbackIssue(issues, BOOTSTRAP_BRANCH, { + const fallbackIssue = findExistingBootstrapPrFallbackIssue(issues, identity.BOOTSTRAP_BRANCH, { repository, baseBranch: pr.base.ref, headSha: pr.head.sha, @@ -221,7 +256,7 @@ async function validateBootstrapPrFallbackAttribution(env, get, repository, pr, requireThat(timestamp(fallbackIssue.updated_at) === timestamp(fallbackIssue.created_at), 'bootstrap PR fallback issue was edited after creation'); const body = String(fallbackIssue.body ?? ''); - requireThat(body.includes(bootstrapPrFallbackIssueMarker(BOOTSTRAP_BRANCH)), + requireThat(body.includes(bootstrapPrFallbackIssueMarker(identity.BOOTSTRAP_BRANCH)), 'bootstrap PR fallback issue is missing its branch marker'); const provenance = parseBootstrapPrFallbackProvenance(body); requireThat(provenance, 'missing or malformed bootstrap PR fallback provenance record'); @@ -259,7 +294,7 @@ async function validateBootstrapPrFallbackAttribution(env, get, repository, pr, repository, baseBranch: provenance.base_branch, headBranch: provenance.head_branch, - title: BOOTSTRAP_TITLE, + title: identity.BOOTSTRAP_PR_TITLE, server: env.GITHUB_SERVER_URL, }); requireThat(provenance.compare_url === expectedCompareUrl, @@ -273,7 +308,7 @@ async function validateBootstrapPrFallbackAttribution(env, get, repository, pr, // exact base commit recorded in this provenance. const run = await get(`repos/${repository}/actions/runs/${provenance.run_id}`); requireThat( - run.event === 'push' && + await trustedBootstrapEvent(get, repository, run, identity) && run.path === BOOTSTRAP_WORKFLOW && run.repository?.full_name === repository && run.head_sha === provenance.base_sha && @@ -307,9 +342,10 @@ async function validateBootstrapAttribution(get, repository, pr, requireRunSucce ); requireThat(JSON.stringify(commentProvenance) === JSON.stringify(provenance), 'bootstrap PR and bot-authenticated comment provenance differ'); + const identity = await bootstrapReviewIdentity(get, repository, pr); requireThat( - pr.title === BOOTSTRAP_TITLE && - pr.head.ref === BOOTSTRAP_BRANCH && + pr.title === identity.BOOTSTRAP_PR_TITLE && + pr.head.ref === identity.BOOTSTRAP_BRANCH && pr.user?.login === BOT && pr.user?.type === 'Bot' && provenance.repository === repository && @@ -327,7 +363,7 @@ async function validateBootstrapAttribution(get, repository, pr, requireRunSucce ); const run = await get(`repos/${repository}/actions/runs/${provenance.run_id}`); requireThat( - run.event === 'push' && + await trustedBootstrapEvent(get, repository, run, identity) && run.path === BOOTSTRAP_WORKFLOW && run.repository?.full_name === repository && run.head_sha === provenance.install_sha && @@ -398,7 +434,7 @@ export async function reviewTarget( if (value === undefined) { requireThat(relay === false && env.GITHUB_EVENT_NAME === 'pull_request_target', 'missing committed attribution outside base-controlled bootstrap activation'); - attribution = pr.head.ref === BOOTSTRAP_BRANCH && pr.user?.type !== 'Bot' + attribution = pr.head.ref.startsWith(BOOTSTRAP_BRANCH) && pr.user?.type !== 'Bot' ? await validateBootstrapPrFallbackAttribution(env, get, repository, pr, requireBootstrapRunSuccess) : await validateBootstrapAttribution(get, repository, pr, requireBootstrapRunSuccess); } else { diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 64705a511..391f19e56 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -16,6 +16,11 @@ on: - ".github/aw/squad/runtime/**" - ".github/workflows/shared/squad-bootstrap-trigger-probe.json" workflow_dispatch: + inputs: + fresh_start: + description: "Explicit fresh start: enter the ID from committed .squad/bootstrap-reset.json; otherwise leave blank" + type: string + required: false if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) permissions: contents: read @@ -55,6 +60,7 @@ pre-agent-steps: uses: actions/github-script@v9 env: SQUAD_BOOTSTRAP_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + SQUAD_BOOTSTRAP_FRESH_START: ${{ inputs.fresh_start }} with: script: | const { writeFileSync } = await import('node:fs'); @@ -64,6 +70,15 @@ pre-agent-steps: process.env.GITHUB_WORKSPACE, '.github/workflows/shared/squad-bootstrap-validator.mjs', )).href); + const reset = stateModule.readBootstrapReset(process.env.GITHUB_WORKSPACE); + const resetAuthorized = await stateModule.authorizeBootstrapReset({ + reset, input: process.env.SQUAD_BOOTSTRAP_FRESH_START, context, github, + }); + const resetState = { + reset, resetAuthorized, + repository: `${context.repo.owner}/${context.repo.repo}`, + installedTeam: stateModule.hasCommittedBootstrapTeam(process.env.GITHUB_WORKSPACE), + }; const pullRequests = await github.paginate(github.rest.pulls.list, { ...context.repo, state: 'all', @@ -77,6 +92,7 @@ pre-agent-steps: let state; try { const preliminary = stateModule.classifyBootstrapState({ + ...resetState, pullRequests, issues, defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, @@ -89,6 +105,7 @@ pre-agent-steps: }) : []; state = stateModule.classifyBootstrapState({ + ...resetState, pullRequests, issues, comments, @@ -161,7 +178,7 @@ pre-agent-steps: # BEGIN GENERATED RESOURCE DIGESTS check_hash "$install_verifier" "a279cd5c4adeb613ceb90c1bfbb9818265aeb6bc8986e2e3799e96f7c2d787e5" check_hash "$cast_validator" "c6d0b92aac71dc6f6d5727cac418a323b0bc9c12047400faa12d96150d548ada" - check_hash "$bootstrap_validator" "ea43df25b9813cbb78d85abaf1b0b906b64b202915c34bdb1562ddd05af03797" + check_hash "$bootstrap_validator" "ac969d1cc92353219ba312d0d9af8702b1a7a569b4e8d5c79ae71e4aa020aad6" # END GENERATED RESOURCE DIGESTS node "$bootstrap_validator" \ --encode-payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ @@ -192,6 +209,7 @@ safe-outputs: contents: write issues: write pull-requests: write + actions: read inputs: payload_encoding: description: Fixed bootstrap payload encoding; must be base64. @@ -229,7 +247,7 @@ safe-outputs: - name: Checkout trusted default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: refs/heads/${{ github.event.repository.default_branch }} + ref: ${{ github.sha }} persist-credentials: false path: bootstrap-repo - name: Validate and materialize both artifacts @@ -239,6 +257,7 @@ safe-outputs: SQUAD_BOOTSTRAP_INSTALL_SHA: ${{ github.sha }} SQUAD_BOOTSTRAP_REPOSITORY: ${{ github.repository }} SQUAD_BOOTSTRAP_RUN_ID: ${{ github.run_id }} + SQUAD_BOOTSTRAP_FRESH_START: ${{ inputs.fresh_start }} with: script: | const { @@ -250,10 +269,20 @@ safe-outputs: const { pathToFileURL } = await import('node:url'); const checkout = join(process.env.GITHUB_WORKSPACE, 'bootstrap-repo'); - const stateModule = await import(pathToFileURL(join( + const bootstrapModule = await import(pathToFileURL(join( checkout, '.github/workflows/shared/squad-bootstrap-validator.mjs', )).href); + const reset = bootstrapModule.readBootstrapReset(checkout); + const stateModule = { ...bootstrapModule, ...bootstrapModule.bootstrapIdentity(reset) }; + const resetAuthorized = await stateModule.authorizeBootstrapReset({ + reset, input: process.env.SQUAD_BOOTSTRAP_FRESH_START, context, github, + }); + const resetState = { + reset, resetAuthorized, + repository: `${context.repo.owner}/${context.repo.repo}`, + installedTeam: stateModule.hasCommittedBootstrapTeam(checkout), + }; const validatorModule = await import(pathToFileURL(join( checkout, '.github/workflows/shared/squad-bootstrap-validator.mjs', @@ -348,6 +377,7 @@ safe-outputs: per_page: 100, })).filter((issue) => !issue.pull_request); const preliminary = stateModule.classifyBootstrapState({ + ...resetState, pullRequests, issues, defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, @@ -364,6 +394,7 @@ safe-outputs: issues, comments, state: stateModule.classifyBootstrapState({ + ...resetState, pullRequests, issues, comments, @@ -373,6 +404,10 @@ safe-outputs: }; let snapshot = await listState(); + if (snapshot.state.action === 'reset_armed') { + core.info('Fresh bootstrap is armed; a maintainer must manually dispatch the exact fresh_start ID.'); + return; + } if (snapshot.state.action === 'opt_out') { core.info('A closed-unmerged bootstrap Cast PR records human opt-out; no replacement was created.'); return; @@ -420,19 +455,28 @@ safe-outputs: }; let pullRequest = snapshot.state.pull_request; + const recoveringReset = reset && Boolean(pullRequest); if (!pullRequest) { + if (reset) { + const liveBase = await github.rest.git.getRef({ + ...context.repo, ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, + }); + if (liveBase.data.object.sha !== context.sha) { + throw new Error('Default branch advanced during fresh bootstrap; dispatch again against its new tip.'); + } + } 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({ + const baseSha = reset ? context.sha : (await github.rest.git.getRef({ ...context.repo, ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, - }); + })).data.object.sha; const baseCommit = await github.rest.git.getCommit({ ...context.repo, - commit_sha: baseRef.data.object.sha, + commit_sha: baseSha, }); const tree = []; for (const file of payload.files) { @@ -457,8 +501,16 @@ safe-outputs: ...context.repo, message: 'chore(squad): add repository-derived Squad', tree: createdTree.data.sha, - parents: [baseRef.data.object.sha], + parents: [baseSha], }); + if (reset) { + const liveBase = await github.rest.git.getRef({ + ...context.repo, ref: `heads/${process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH}`, + }); + if (liveBase.data.object.sha !== baseSha) { + throw new Error('Default branch advanced before fresh bootstrap publication; dispatch again.'); + } + } await github.rest.git.createRef({ ...context.repo, ref: `refs/heads/${stateModule.BOOTSTRAP_BRANCH}`, @@ -516,14 +568,9 @@ safe-outputs: ); 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') { + // Legacy fallback provenance remains push-only. The only dispatch exception + // is a committed generation with independently checked maintainer authorization. + if (context.eventName !== 'push' && !(reset && resetAuthorized)) { 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 ` + @@ -587,7 +634,18 @@ safe-outputs: ...context.repo, pull_number: pullRequest.number, })).data; - const provenance = { + const provenance = recoveringReset + ? await stateModule.recoverBootstrapResetProvenance({ + reset, github, + repository: process.env.SQUAD_BOOTSTRAP_REPOSITORY, + defaultBranch: process.env.SQUAD_BOOTSTRAP_DEFAULT_BRANCH, + currentSha: context.sha, currentRunId: context.runId, resetAuthorized, + pullRequest: pullRequestDetails, issues: snapshot.issues, + comments: await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: pullRequest.number, per_page: 100, + }), + }) + : { schema: 1, repository: process.env.SQUAD_BOOTSTRAP_REPOSITORY, run_id: process.env.SQUAD_BOOTSTRAP_RUN_ID, @@ -595,8 +653,8 @@ safe-outputs: 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 + || (!recoveringReset && (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 @@ -736,6 +794,9 @@ Read `.github/workflows/squad-bootstrap-state.json` before doing any analysis. - `noop`: call `noop` and stop. Both artifacts already exist. - `opt_out`: call `noop` with a message that the closed-unmerged Cast PR records human opt-out, then stop. Never create a replacement. +- `reset_armed`: call `noop` and stop. Only an authenticated maintainer's manual + dispatch with `fresh_start` equal to the committed reset ID may create this + generation. Never turn an ordinary push or blank manual run into a reset. - `create_both`: generate one shared payload and request materialization. - `create_issue`: preserve the checked-out existing Cast tree, generate the issue from it, and request materialization. @@ -811,6 +872,12 @@ selected by this Cast, but never remove or rewrite the four built-ins. Write `.github/workflows/squad-bootstrap-payload.json` with this exact shape: +For a committed reset, use the exact `identity.BOOTSTRAP_BRANCH`, +`identity.BOOTSTRAP_PR_TITLE`, and `identity.BOOTSTRAP_ISSUE_TITLE` from the +deterministic state file instead of the legacy values shown below. The writer +and validator independently load this identity from committed Git history; +never choose or edit the reset ID, record, archived numbers, or output names. + ```json { "schema_version": "1", diff --git a/workflows/squad-review.md b/workflows/squad-review.md index 17f66f513..0eb381409 100644 --- a/workflows/squad-review.md +++ b/workflows/squad-review.md @@ -176,7 +176,12 @@ for the exact `squad/bootstrap-cast` PR created by the base-controlled bootstrap workflow. It verifies the GitHub Actions bot author, canonical title and branch, matching provenance in the PR body and a durable bot-authored PR comment, exact base/head SHAs, and the referenced default-branch `squad-bootstrap.lock.yml` -push run. It binds the reserved roles +push run. A fresh-reset generation instead uses the exact branch and title +derived from the maintainer-reviewed `.squad/bootstrap-reset.json`. Its manual +dispatch is eligible only when the guard independently verifies human actor +permissions and the same committed reset record at the recorded run SHA and +current PR base. No reset record broadens legacy manual-dispatch authorization. +It binds the reserved roles `@squad/base-controlled-bootstrap` and `@squad/base-controlled-review`; the clearing gate additionally requires that bootstrap run to finish successfully. All other missing, malformed, or non-404 attribution evidence is refused. diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index 6d3853d73..599d8c716 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -32,9 +32,9 @@ "source": "workflows/package/squad-review.md", "destination": ".github/workflows/squad-review.md", "lock": ".github/workflows/squad-review.lock.yml", - "source_sha256": "a596d69081c6026f358f88352fb94b91bfc40335a68e94f7d73b7d20ce5e898f", - "lock_sha256": "d1d3ac5004188940f27015e696460294c370521bf34555783ebee9216cc5f907", - "package_lock_sha256": "a9d47ef3743e21083f7f63a3404d6dbb93003c6cc6c546613c1ff897dc38f44e" + "source_sha256": "0d7b4f60a7916e05b08f5c6831dc42d42e8ff555abade7d27896ddd91c7bd79e", + "lock_sha256": "077f0e54a34e4449c8d87877717bc73e7417bdeede932163e494ac10b6dcd5ee", + "package_lock_sha256": "062fdcdf11274b188ce4d9b444acf32c5c9fbac98fa3f683af64c213716923c7" }, { "name": "squad-deps-worker", @@ -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": "10f85600b3e4a8617bbdc70afc95ea975f8d98c1b9e673ca7c15abb293aed7ee", - "lock_sha256": "faf0b03fa9ddceefe1cbcc653edb123dc733b484f1a6d0ada8ce7cb08993081f", - "package_lock_sha256": "8857134b8a6551b19c4271f814b2cde348eca02efead2d5713eecdcdd45d895c" + "source_sha256": "d787850f965dcc6e7353349f5426c2d7f951fc81ccbfd3045dccb48714085fad", + "lock_sha256": "2f3b157b747932ff2a48cddd8c5eeb585945e293d22385452eff49dba68820c2", + "package_lock_sha256": "69ce712d26e7beef9ad35cf511bd2b9d1990a058ad5665ae43c549156a7fef62" }, { "name": "squad-command-router", @@ -89,7 +89,7 @@ "package_destination": ".github/workflows/shared/squad-review-guard.mjs", "destination": ".github/workflows/shared/squad-review-guard.mjs", "ownership": "manifest", - "sha256": "b499ad06b33a52f55f3618ac37cfe739b7b59c45b867ea2b5f47d81598b874c6" + "sha256": "d59b3eb7d0f6dbccaba7d93f54fa42e6dc197146d0ac5e36d6ddd5c211c824d8" }, { "path": "shared/squad-install-verifier.mjs", @@ -121,7 +121,7 @@ "package_destination": ".github/workflows/shared/squad-bootstrap-validator.mjs", "destination": ".github/workflows/shared/squad-bootstrap-validator.mjs", "ownership": "manifest", - "sha256": "ea43df25b9813cbb78d85abaf1b0b906b64b202915c34bdb1562ddd05af03797" + "sha256": "ac969d1cc92353219ba312d0d9af8702b1a7a569b4e8d5c79ae71e4aa020aad6" }, { "path": "shared/squad-improvement-gate.mjs",