From 0aeda3f5ddc116801487a312e27b3dcb98b7968f Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 14 Sep 2026 13:47:50 -0700 Subject: [PATCH 1/4] fix(gh-aw): chunk bootstrap payload transport (#2009) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/fix-bootstrap-payload-transport.md | 7 + test/gh-aw-bootstrap-workflow.test.ts | 141 ++++++++++++++++ .../shared/squad-bootstrap-validator.mjs | 150 +++++++++++++++++- workflows/squad-bootstrap.md | 70 ++++++-- 4 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-bootstrap-payload-transport.md diff --git a/.changeset/fix-bootstrap-payload-transport.md b/.changeset/fix-bootstrap-payload-transport.md new file mode 100644 index 000000000..2c7df7380 --- /dev/null +++ b/.changeset/fix-bootstrap-payload-transport.md @@ -0,0 +1,7 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +Transport automatic bootstrap Cast and research payloads through bounded, +integrity-checked chunks so large validated payloads reach the typed writer. diff --git a/test/gh-aw-bootstrap-workflow.test.ts b/test/gh-aw-bootstrap-workflow.test.ts index 31f76ce8f..b10ee6f0a 100644 --- a/test/gh-aw-bootstrap-workflow.test.ts +++ b/test/gh-aw-bootstrap-workflow.test.ts @@ -16,7 +16,13 @@ import { BOOTSTRAP_ISSUE_MARKER, BOOTSTRAP_ISSUE_TITLE, BOOTSTRAP_PR_TITLE, + PAYLOAD_CHUNK_BYTES, + PAYLOAD_CHUNK_STRING_MAX_BYTES, + PAYLOAD_MAX_BYTES, + PAYLOAD_MAX_CHUNKS, classifyBootstrapState, + createBootstrapPayloadEnvelope, + reconstructBootstrapPayload, } from '../workflows/shared/squad-bootstrap-validator.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -42,6 +48,10 @@ function write(root: string, path: string, content: string | Buffer): void { writeFileSync(target, content); } +function payloadChunkField(index: number): string { + return `payload_chunk_${String(index).padStart(2, '0')}`; +} + function teamMarkdown(): string { return `# Project Squad @@ -390,6 +400,131 @@ describe('automatic Squad bootstrap workflow', () => { expect(resolvedResult.stdout).toBe('Squad bootstrap validation passed.\n'); }); + it('transports a shared payload larger than the old single-string limit byte-identically', () => { + const fixture = createFixture(); + const targetBytes = 34_843; + const basePayloadText = JSON.stringify({ + ...fixture.payload, + transport_regression: '', + }); + const payloadText = JSON.stringify({ + ...fixture.payload, + transport_regression: 'x'.repeat(targetBytes - Buffer.byteLength(basePayloadText, 'utf8')), + }); + expect(Buffer.byteLength(payloadText, 'utf8')).toBe(targetBytes); + expect(Buffer.byteLength(payloadText, 'utf8')).toBeGreaterThan(10_240); + + const transportPath = join(fixture.root, 'transport-payload.json'); + writeFileSync(transportPath, payloadText); + const encoded = spawnSync(process.execPath, [VALIDATOR, '--encode-payload', transportPath], { + encoding: 'utf8', + }); + expect(encoded.status, encoded.stderr).toBe(0); + const envelope = JSON.parse(encoded.stdout) as Record; + expect(envelope.payload_chunk_count).toBe('6'); + expect(reconstructBootstrapPayload(envelope)).toBe(payloadText); + expect(Buffer.byteLength(reconstructBootstrapPayload(envelope), 'utf8')).toBe( + Buffer.byteLength(payloadText, 'utf8'), + ); + for (let index = 0; index < Number(envelope.payload_chunk_count); index++) { + const chunk = envelope[payloadChunkField(index)]; + expect(Buffer.byteLength(chunk, 'utf8')).toBeLessThanOrEqual(PAYLOAD_CHUNK_STRING_MAX_BYTES); + expect(Buffer.byteLength(chunk, 'utf8')).toBeLessThan(10_240); + } + }); + + it('fails closed for every chunk transport corruption class', () => { + const payloadText = JSON.stringify({ + shared: 'Cast and research stay in one validated payload.', + content: 'x'.repeat(PAYLOAD_CHUNK_BYTES * 2), + }); + const valid = createBootstrapPayloadEnvelope(payloadText) as Record; + expect(Number(valid.payload_chunk_count)).toBeGreaterThanOrEqual(3); + + const mutations: Array<[string, (envelope: Record) => void, RegExp]> = [ + ['missing', (envelope) => delete envelope.payload_chunk_00, /payload_chunk_00 is missing/], + [ + 'duplicate', + (envelope) => { envelope.payload_chunk_01 = envelope.payload_chunk_00; }, + /duplicate, missing, or reordered chunk ordinal/, + ], + [ + 'reordered', + (envelope) => { + [envelope.payload_chunk_00, envelope.payload_chunk_01] = + [envelope.payload_chunk_01, envelope.payload_chunk_00]; + }, + /duplicate, missing, or reordered chunk ordinal/, + ], + [ + 'oversized', + (envelope) => { envelope.payload_chunk_00 += 'AAAA'; }, + new RegExp(`exceeds ${PAYLOAD_CHUNK_STRING_MAX_BYTES} bytes`), + ], + ['invalid encoding', (envelope) => { envelope.payload_encoding = 'utf8'; }, /must be base64/], + [ + 'invalid Base64', + (envelope) => { envelope.payload_chunk_00 = '00:not-base64!'; }, + /canonical Base64/, + ], + [ + 'invalid UTF-8', + (envelope) => { + envelope.payload_byte_length = '1'; + envelope.payload_chunk_count = '1'; + envelope.payload_chunk_00 = '00:/w=='; + envelope.payload_sha256 = 'a8100ae6aa1940d0b663bb31cd466142ebbdbd5187131b92d93818987832eb89'; + for (let index = 1; index < PAYLOAD_MAX_CHUNKS; index++) { + delete envelope[payloadChunkField(index)]; + } + }, + /not valid UTF-8/, + ], + [ + 'length mismatch', + (envelope) => { envelope.payload_byte_length = String(Number(envelope.payload_byte_length) - 1); }, + /decoded length does not match|length mismatch/, + ], + ['hash mismatch', (envelope) => { envelope.payload_sha256 = '0'.repeat(64); }, /SHA-256 mismatch/], + [ + 'trailing data', + (envelope) => { + const index = Number(envelope.payload_chunk_count); + envelope[payloadChunkField(index)] = `${String(index).padStart(2, '0')}:QQ==`; + }, + /trailing data/, + ], + [ + 'excessive total size', + (envelope) => { envelope.payload_byte_length = String(PAYLOAD_MAX_BYTES + 1); }, + new RegExp(`between 1 and ${PAYLOAD_MAX_BYTES}`), + ], + [ + 'excessive chunk count', + (envelope) => { envelope.payload_chunk_count = String(PAYLOAD_MAX_CHUNKS + 1); }, + new RegExp(`between 1 and ${PAYLOAD_MAX_CHUNKS}`), + ], + ]; + + for (const [name, mutate, error] of mutations) { + const envelope = structuredClone(valid); + mutate(envelope); + expect( + () => reconstructBootstrapPayload(envelope), + `${name} corruption must fail closed`, + ).toThrow(error); + } + }); + + it('enforces encoder total-size and fixed chunk-count bounds', () => { + expect(() => createBootstrapPayloadEnvelope('x'.repeat(PAYLOAD_MAX_BYTES + 1))).toThrow( + new RegExp(`between 1 and ${PAYLOAD_MAX_BYTES}`), + ); + const maximum = createBootstrapPayloadEnvelope('x'.repeat(PAYLOAD_MAX_BYTES)) as Record; + expect(maximum.payload_chunk_count).toBe(String(PAYLOAD_MAX_CHUNKS)); + expect(reconstructBootstrapPayload(maximum)).toBe('x'.repeat(PAYLOAD_MAX_BYTES)); + }); + it('rejects roster divergence, broken links, invalid commands, and output override attempts', () => { const fixture = createFixture(); const mutated = { @@ -430,6 +565,12 @@ describe('automatic Squad bootstrap workflow', () => { 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(lock).toContain('"payload_chunk_00"'); + expect(lock).toContain('"payload_chunk_15"'); + expect(lock).toContain('"payload_byte_length"'); + expect(lock).toContain('"payload_sha256"'); + expect(lock).not.toMatch(/"materialize-bootstrap":\{"inputs":\{"payload":/); + expect(lock).toContain('reconstructBootstrapPayload(items[0])'); expect(lock).not.toMatch(/\$\{\{[^}]*\\u00(?:26|3[cCeE])/); }, 180000); diff --git a/workflows/shared/squad-bootstrap-validator.mjs b/workflows/shared/squad-bootstrap-validator.mjs index 2f66b10ef..9487cddee 100644 --- a/workflows/shared/squad-bootstrap-validator.mjs +++ b/workflows/shared/squad-bootstrap-validator.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { createHash } from 'node:crypto'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; @@ -9,6 +10,10 @@ 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 PAYLOAD_CHUNK_BYTES = 6000; +export const PAYLOAD_MAX_CHUNKS = 16; +export const PAYLOAD_MAX_BYTES = PAYLOAD_CHUNK_BYTES * PAYLOAD_MAX_CHUNKS; +export const PAYLOAD_CHUNK_STRING_MAX_BYTES = 3 + Math.ceil(PAYLOAD_CHUNK_BYTES / 3) * 4; const REQUIRED_ISSUE_HEADINGS = [ '## Repository snapshot', @@ -25,6 +30,137 @@ const REQUIRED_COMMANDS = [ '/squad activate', ]; const LINK_PLACEHOLDER = '{{CAST_PR_URL}}'; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const CANONICAL_DECIMAL_PATTERN = /^(0|[1-9][0-9]*)$/; +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +function chunkField(index) { + return `payload_chunk_${String(index).padStart(2, '0')}`; +} + +function parseBoundedDecimal(value, field, { minimum, maximum }) { + if (typeof value !== 'string' || !CANONICAL_DECIMAL_PATTERN.test(value)) { + throw new Error(`${field} must be a canonical decimal string.`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(`${field} must be between ${minimum} and ${maximum}.`); + } + return parsed; +} + +function decodeCanonicalBase64(value, field) { + if (!BASE64_PATTERN.test(value)) { + throw new Error(`${field} must contain canonical Base64.`); + } + const decoded = Buffer.from(value, 'base64'); + if (decoded.toString('base64') !== value) { + throw new Error(`${field} must contain canonical Base64.`); + } + return decoded; +} + +export function createBootstrapPayloadEnvelope(payloadText) { + if (typeof payloadText !== 'string') { + throw new Error('Bootstrap payload transport requires a UTF-8 string.'); + } + const payloadBytes = Buffer.from(payloadText, 'utf8'); + if (payloadBytes.length === 0 || payloadBytes.length > PAYLOAD_MAX_BYTES) { + throw new Error(`Bootstrap payload must be between 1 and ${PAYLOAD_MAX_BYTES} bytes.`); + } + + const chunkCount = Math.ceil(payloadBytes.length / PAYLOAD_CHUNK_BYTES); + const envelope = { + payload_encoding: 'base64', + payload_byte_length: String(payloadBytes.length), + payload_sha256: createHash('sha256').update(payloadBytes).digest('hex'), + payload_chunk_count: String(chunkCount), + }; + for (let index = 0; index < chunkCount; index++) { + const start = index * PAYLOAD_CHUNK_BYTES; + const encoded = payloadBytes.subarray(start, start + PAYLOAD_CHUNK_BYTES).toString('base64'); + envelope[chunkField(index)] = `${String(index).padStart(2, '0')}:${encoded}`; + } + return envelope; +} + +export function reconstructBootstrapPayload(envelope) { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { + throw new Error('Bootstrap payload transport must be an object.'); + } + if (envelope.payload_encoding !== 'base64') { + throw new Error('payload_encoding must be base64.'); + } + + const chunkCount = parseBoundedDecimal(envelope.payload_chunk_count, 'payload_chunk_count', { + minimum: 1, + maximum: PAYLOAD_MAX_CHUNKS, + }); + const expectedLength = parseBoundedDecimal(envelope.payload_byte_length, 'payload_byte_length', { + minimum: 1, + maximum: PAYLOAD_MAX_BYTES, + }); + if (typeof envelope.payload_sha256 !== 'string' || !SHA256_PATTERN.test(envelope.payload_sha256)) { + throw new Error('payload_sha256 must be a lowercase SHA-256 digest.'); + } + + const chunks = []; + for (let index = 0; index < PAYLOAD_MAX_CHUNKS; index++) { + const field = chunkField(index); + const value = envelope[field]; + if (index >= chunkCount) { + if (value !== undefined) { + throw new Error(`${field} is trailing data beyond payload_chunk_count.`); + } + continue; + } + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${field} is missing.`); + } + if (Buffer.byteLength(value, 'utf8') > PAYLOAD_CHUNK_STRING_MAX_BYTES) { + throw new Error(`${field} exceeds ${PAYLOAD_CHUNK_STRING_MAX_BYTES} bytes.`); + } + const prefix = `${String(index).padStart(2, '0')}:`; + if (!value.startsWith(prefix)) { + throw new Error(`${field} has a duplicate, missing, or reordered chunk ordinal.`); + } + const decoded = decodeCanonicalBase64(value.slice(prefix.length), field); + const expectedChunkLength = + index === chunkCount - 1 + ? expectedLength - (PAYLOAD_CHUNK_BYTES * (chunkCount - 1)) + : PAYLOAD_CHUNK_BYTES; + if (expectedChunkLength < 1 || expectedChunkLength > PAYLOAD_CHUNK_BYTES) { + throw new Error('payload_byte_length is inconsistent with payload_chunk_count.'); + } + if (decoded.length !== expectedChunkLength) { + throw new Error(`${field} decoded length does not match the deterministic chunk boundary.`); + } + chunks.push(decoded); + } + + const payloadBytes = Buffer.concat(chunks); + if (payloadBytes.length > PAYLOAD_MAX_BYTES) { + throw new Error(`Bootstrap payload exceeds ${PAYLOAD_MAX_BYTES} bytes.`); + } + if (payloadBytes.length !== expectedLength) { + throw new Error(`Bootstrap payload length mismatch: expected ${expectedLength}, got ${payloadBytes.length}.`); + } + const actualHash = createHash('sha256').update(payloadBytes).digest('hex'); + if (actualHash !== envelope.payload_sha256) { + throw new Error('Bootstrap payload SHA-256 mismatch.'); + } + + let payloadText; + try { + payloadText = new TextDecoder('utf-8', { fatal: true }).decode(payloadBytes); + } catch { + throw new Error('Bootstrap payload is not valid UTF-8.'); + } + if (!Buffer.from(payloadText, 'utf8').equals(payloadBytes)) { + throw new Error('Bootstrap payload UTF-8 reconstruction is not byte-identical.'); + } + return payloadText; +} function pullHead(pullRequest) { return pullRequest?.head?.ref ?? pullRequest?.headRefName ?? ''; @@ -356,9 +492,21 @@ export function validateBootstrapPayload({ } function main() { + const cliArgs = process.argv.slice(2); + if (cliArgs.length === 2 && cliArgs[0] === '--encode-payload') { + try { + const payloadText = readFileSync(resolve(cliArgs[1]), 'utf8'); + process.stdout.write(`${JSON.stringify(createBootstrapPayloadEnvelope(payloadText), null, 2)}\n`); + } catch (error) { + console.error(`Squad bootstrap payload encoding failed:\n- ${error.message}`); + process.exitCode = 1; + } + return; + } + let options; try { - options = parseArgs(process.argv.slice(2)); + options = parseArgs(cliArgs); } catch (error) { console.error(`Squad bootstrap validation failed:\n- ${error.message}`); process.exitCode = 1; diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 0621e615f..f873fbd64 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -131,13 +131,16 @@ pre-agent-steps: node --check "$path" >/dev/null } check_hash "$cast_validator" "f0c79694d9832c53070f059d4bff181a8ccd857e1be49d24b8d5b72ed8887251" - check_hash "$bootstrap_validator" "a37ba8185d5831a340661c89ece02631b0d99dde38d8deb75bd642fd61518750" + check_hash "$bootstrap_validator" "c88770b82a6fc6ffb888f1eb5f3f3ab3be0f67ac69fb72a39565378a8d313945" node "$bootstrap_validator" \ --root "$PWD" \ --payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ --repository "${GITHUB_REPOSITORY:?}" \ --default-branch "${SQUAD_BOOTSTRAP_DEFAULT_BRANCH:?}" \ --link-mode placeholder + node "$bootstrap_validator" \ + --encode-payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ + > "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-envelope.json" SQUAD_BOOTSTRAP_VALIDATOR chmod 500 "$runner" safe-outputs: @@ -158,10 +161,38 @@ safe-outputs: issues: write pull-requests: write inputs: - payload: - description: Complete validated Squad bootstrap payload JSON. + payload_encoding: + description: Fixed bootstrap payload encoding; must be base64. + required: true + type: string + payload_byte_length: + description: Canonical decimal UTF-8 byte length, at most 96000. + required: true + type: string + payload_sha256: + description: Lowercase SHA-256 of the complete UTF-8 payload bytes. + required: true + type: string + payload_chunk_count: + description: Canonical decimal count of populated chunks, from 1 through 16. required: true type: string + payload_chunk_00: { type: string } + payload_chunk_01: { type: string } + payload_chunk_02: { type: string } + payload_chunk_03: { type: string } + payload_chunk_04: { type: string } + payload_chunk_05: { type: string } + payload_chunk_06: { type: string } + payload_chunk_07: { type: string } + payload_chunk_08: { type: string } + payload_chunk_09: { type: string } + payload_chunk_10: { type: string } + payload_chunk_11: { type: string } + payload_chunk_12: { type: string } + payload_chunk_13: { type: string } + payload_chunk_14: { type: string } + payload_chunk_15: { type: string } steps: - name: Checkout trusted default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -196,11 +227,13 @@ safe-outputs: core.setFailed(`Expected exactly one materialize_bootstrap item, found ${items.length}.`); return; } + let payloadText; let payload; try { - payload = JSON.parse(String(items[0].payload || '')); + payloadText = validatorModule.reconstructBootstrapPayload(items[0]); + payload = JSON.parse(payloadText); } catch (error) { - core.setFailed(`Bootstrap payload is invalid JSON: ${error.message}`); + core.setFailed(`Bootstrap payload transport is invalid: ${error.message}`); return; } const payloadPath = join(checkout, '.github/workflows/squad-bootstrap-payload.json'); @@ -209,7 +242,7 @@ safe-outputs: mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, String(file.content || '')); } - writeFileSync(payloadPath, `${JSON.stringify(payload)}\n`); + writeFileSync(payloadPath, payloadText); const validate = (candidate, linkMode) => { writeFileSync(payloadPath, `${JSON.stringify(candidate)}\n`); @@ -533,7 +566,26 @@ Run exactly: ``` Only exit status zero with stdout exactly -`Squad bootstrap validation passed.` authorizes one call to -`materialize_bootstrap`, with the complete payload file serialized as its -`payload` string. Any other result is terminal: emit no materialization output, +`Squad bootstrap validation passed.` authorizes reading +`.github/workflows/squad-bootstrap-envelope.json`. That file is the only +transport source for one `materialize_bootstrap` call. + +The envelope contains: + +- `payload_encoding`: exactly `base64` +- `payload_byte_length`: canonical decimal UTF-8 byte length, maximum 96,000 +- `payload_sha256`: lowercase SHA-256 of the complete payload bytes +- `payload_chunk_count`: canonical decimal from 1 through 16 +- `payload_chunk_00` through `payload_chunk_15`: only the populated fixed slots + +Each populated chunk is `NN:` followed by canonical Base64 for at most 6,000 +payload bytes, so every string is at most 8,003 bytes and stays conservatively +below gh-aw's 10,240-byte per-string input limit. Pass every property from the +envelope byte-for-byte to the typed safe-output call. Do not reserialize the +payload, recompute metadata, rename slots, add unused slots, or split semantic +generation into separate Cast and research outputs. The writer reconstructs the +one shared payload and verifies order, count, bounds, UTF-8, total byte length, +and SHA-256 before parsing any JSON. + +Any validation or envelope error is terminal: emit no materialization output, report the exact validator stderr, and stop. From 221dffb9d03eebc8953186b65f457b1faccf58db Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 14 Sep 2026 13:56:18 -0700 Subject: [PATCH 2/4] docs(gh-aw): document bootstrap refresh steps (#2009) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/guide/gh-aw.md | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 0004b5800..f4f295fd8 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -1562,6 +1562,7 @@ for shared_file in \ squad-planning-ontology.md \ squad-planning-policy.md \ squad-cast-validator.mjs \ + squad-bootstrap-validator.mjs \ squad-improvement-gate.mjs \ squad-retro-evidence.mjs \ squad-retro-provenance.mjs \ @@ -1583,6 +1584,40 @@ the workflow diff, then commit them together. With gh-aw v0.87.10, do not use `gh aw update` for this immutable-pin flow: its stored source branch and cooldown can leave the installed sources at a different revision than the SHA you intend. +To refresh only the first-run bootstrap workflow, install its source and every +runtime resource it uses at the same immutable SHA, then recompile: + +```bash +SQUAD_SHA="<40-character-commit-sha>" + +gh aw add \ + bradygaster/squad/workflows/squad-bootstrap.md@${SQUAD_SHA} \ + --force + +mkdir -p .github/workflows/shared + +for shared_file in \ + squad-cast-validator.mjs \ + squad-bootstrap-validator.mjs \ + builtins/scribe-charter.md \ + builtins/ralph-charter.md \ + builtins/rai-charter.md \ + builtins/fact-checker-charter.md; do + mkdir -p ".github/workflows/shared/$(dirname "$shared_file")" + curl --fail --silent --show-error --location \ + "https://raw.githubusercontent.com/bradygaster/squad/${SQUAD_SHA}/workflows/shared/${shared_file}" \ + --output ".github/workflows/shared/${shared_file}" +done + +gh aw compile --strict +``` + +Review and commit `squad-bootstrap.md`, its generated lock, and the refreshed +shared resources together. Do not rerun the bootstrap workflow until that commit +is on the repository's default branch. The default-branch push triggers the +bootstrap workflow automatically; its push and branch gates intentionally reject +other refs. + --- ## Troubleshooting From cd449385bcc7dbe6711eaab28d1842ce0ff694fb Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 14 Sep 2026 13:58:37 -0700 Subject: [PATCH 3/4] test(gh-aw): verify bootstrap validator integrity pin (#2009) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-bootstrap-workflow.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/gh-aw-bootstrap-workflow.test.ts b/test/gh-aw-bootstrap-workflow.test.ts index b10ee6f0a..677b687b8 100644 --- a/test/gh-aw-bootstrap-workflow.test.ts +++ b/test/gh-aw-bootstrap-workflow.test.ts @@ -8,6 +8,7 @@ import { writeFileSync, } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -339,6 +340,16 @@ afterAll(() => { }); describe('automatic Squad bootstrap workflow', () => { + it('pins the canonical bootstrap validator digest in the authenticated runner', () => { + const canonical = readFileSync(VALIDATOR); + const commandDigest = WORKFLOW.match( + /check_hash "\$bootstrap_validator" "([a-f0-9]{64})"/, + )?.[1]; + + expect(commandDigest, 'runtime command must pin the canonical validator digest').toBeDefined(); + expect(commandDigest).toBe(createHash('sha256').update(canonical).digest('hex')); + }); + it('classifies the fresh and partial-recovery matrix without duplicates', () => { const pull = (state = 'open', merged = false) => ({ number: 3, From 9f9f3d28475c0f38657f8af373eaf7252fc9c7d1 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 14 Sep 2026 14:05:31 -0700 Subject: [PATCH 4/4] docs(gh-aw): keep upgrade installs coherent (#2009) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/guide/gh-aw.md | 40 +++++----------------------- 1 file changed, 7 insertions(+), 33 deletions(-) diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index f4f295fd8..e7e997354 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -1584,39 +1584,13 @@ the workflow diff, then commit them together. With gh-aw v0.87.10, do not use `gh aw update` for this immutable-pin flow: its stored source branch and cooldown can leave the installed sources at a different revision than the SHA you intend. -To refresh only the first-run bootstrap workflow, install its source and every -runtime resource it uses at the same immutable SHA, then recompile: - -```bash -SQUAD_SHA="<40-character-commit-sha>" - -gh aw add \ - bradygaster/squad/workflows/squad-bootstrap.md@${SQUAD_SHA} \ - --force - -mkdir -p .github/workflows/shared - -for shared_file in \ - squad-cast-validator.mjs \ - squad-bootstrap-validator.mjs \ - builtins/scribe-charter.md \ - builtins/ralph-charter.md \ - builtins/rai-charter.md \ - builtins/fact-checker-charter.md; do - mkdir -p ".github/workflows/shared/$(dirname "$shared_file")" - curl --fail --silent --show-error --location \ - "https://raw.githubusercontent.com/bradygaster/squad/${SQUAD_SHA}/workflows/shared/${shared_file}" \ - --output ".github/workflows/shared/${shared_file}" -done - -gh aw compile --strict -``` - -Review and commit `squad-bootstrap.md`, its generated lock, and the refreshed -shared resources together. Do not rerun the bootstrap workflow until that commit -is on the repository's default branch. The default-branch push triggers the -bootstrap workflow automatically; its push and branch gates intentionally reject -other refs. +Use the complete upgrade block even when a failure appears limited to the +first-run bootstrap workflow. Updating only `squad-bootstrap.md` can leave its +validator or the rest of the workflow set at a different revision. The shared +resource loop above explicitly refreshes `squad-bootstrap-validator.mjs` along +with every other runtime dependency. Commit the refreshed sources, generated +locks, and shared resources together. The default-branch push triggers bootstrap +automatically; its push and branch gates intentionally reject other refs. ---