Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/src/content/docs/guide/gh-aw.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
374 changes: 353 additions & 21 deletions test/gh-aw-bootstrap-workflow.test.ts

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions test/gh-aw-install-staging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down
90 changes: 90 additions & 0 deletions test/gh-aw-review-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -297,6 +300,93 @@ function retainFirstAttempt(f: ReturnType<typeof fixture>) {
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<string, unknown>) => {
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<typeof freshFixture>) => { f.state.permission = 'read'; },
(f: ReturnType<typeof freshFixture>) => { f.actors.triggering_actor.type = 'Bot'; },
(f: ReturnType<typeof freshFixture>) => { f.resetState.reset = { ...reset, id: 'other' }; },
(f: ReturnType<typeof freshFixture>) => { f.bootstrapRun.head_sha = HEAD; },
(f: ReturnType<typeof freshFixture>) => { f.bootstrapRun.conclusion = 'failure'; },
(f: ReturnType<typeof freshFixture>) => { f.bootstrapRun.path = '.github/workflows/other.yml'; },
(f: ReturnType<typeof freshFixture>) => { 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<string, unknown>) => {
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();
Expand Down
Loading
Loading