From a6628115848e69641ed78624f84484d6c42b7ae6 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 28 Sep 2026 12:30:40 -0700 Subject: [PATCH] fix: allow gh-aw cast genesis state Closes #2067 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/validate-gh-aw-cast.mjs | 37 ++++++++++- test/gh-aw-cast-validator.test.ts | 80 ++++++++++++++++++++++- test/gh-aw-quality.test.ts | 5 +- workflows/package/squad-bootstrap.md | 2 +- workflows/package/squad.md | 2 +- workflows/shared/squad-cast-validator.mjs | 37 ++++++++++- workflows/squad-bootstrap.md | 2 +- workflows/squad-workflows.manifest.json | 6 +- workflows/squad.md | 2 +- 9 files changed, 159 insertions(+), 14 deletions(-) diff --git a/scripts/validate-gh-aw-cast.mjs b/scripts/validate-gh-aw-cast.mjs index a6d83d489..0f6c3a326 100644 --- a/scripts/validate-gh-aw-cast.mjs +++ b/scripts/validate-gh-aw-cast.mjs @@ -474,15 +474,48 @@ function parseCastingPair(registryRaw, historyRaw, source, errors, options = {}) } function committedRegistry(root, errors) { + try { + execFileSync( + 'git', + ['rev-parse', '--verify', 'HEAD'], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + } catch (error) { + errors.push(`registry base: committed HEAD is unavailable (${error.message})`); + return null; + } + + const registryPath = '.squad/casting/registry.json'; + const historyPath = '.squad/casting/history.json'; + let committedPaths; + try { + committedPaths = new Set(execFileSync( + 'git', + ['ls-tree', '-r', '--name-only', 'HEAD', '--', registryPath, historyPath], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ).trim().split('\n').filter(Boolean)); + } catch (error) { + errors.push(`registry base: committed registry/history pair is unavailable (${error.message})`); + return null; + } + const hasRegistry = committedPaths.has(registryPath); + const hasHistory = committedPaths.has(historyPath); + + if (!hasRegistry && !hasHistory) return null; + if (hasRegistry !== hasHistory) { + errors.push('registry base: complete committed registry/history pair is required'); + return null; + } + try { const registryRaw = execFileSync( 'git', - ['show', 'HEAD:.squad/casting/registry.json'], + ['show', `HEAD:${registryPath}`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, ); const historyRaw = execFileSync( 'git', - ['show', 'HEAD:.squad/casting/history.json'], + ['show', `HEAD:${historyPath}`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, ); return parseCastingPair( diff --git a/test/gh-aw-cast-validator.test.ts b/test/gh-aw-cast-validator.test.ts index 9ca6501ae..ab0e3b558 100644 --- a/test/gh-aw-cast-validator.test.ts +++ b/test/gh-aw-cast-validator.test.ts @@ -329,6 +329,18 @@ function commitRegistry(root: string): void { expect(spawnSync('git', ['commit', '-qm', 'versioned registry'], { cwd: root }).status).toBe(0); } +function removeCommittedCastingFiles(root: string, ...paths: string[]): void { + expect(spawnSync('git', [ + 'rm', + '--cached', + '--quiet', + '--force', + '--', + ...paths, + ], { cwd: root }).status).toBe(0); + expect(spawnSync('git', ['commit', '-qm', 'remove committed casting files'], { cwd: root }).status).toBe(0); +} + function advanceHistory(root: string, revision: number, generatedAt: string): void { const historyPath = join(root, '.squad', 'casting', 'history.json'); const history = JSON.parse(readFileSync(historyPath, 'utf8')); @@ -491,6 +503,69 @@ describe('GH-AW Cast final-tree validator', () => { expect(commandDigest).toBe(sha256(canonical)); }); + it.each([ + { + name: 'no committed pair', + mutateBase: (root: string) => removeCommittedCastingFiles( + root, + '.squad/casting/registry.json', + '.squad/casting/history.json', + ), + expectedStatus: 0, + expectedError: undefined, + }, + { + name: 'only a committed registry', + mutateBase: (root: string) => removeCommittedCastingFiles( + root, + '.squad/casting/history.json', + ), + expectedStatus: 1, + expectedError: /complete committed registry\/history pair is required/, + }, + { + name: 'only a committed history', + mutateBase: (root: string) => removeCommittedCastingFiles( + root, + '.squad/casting/registry.json', + ), + expectedStatus: 1, + expectedError: /complete committed registry\/history pair is required/, + }, + { + name: 'a valid committed pair', + mutateBase: (_root: string) => {}, + expectedStatus: 0, + expectedError: undefined, + }, + { + name: 'a malformed committed pair', + mutateBase: (root: string) => { + write(root, '.squad/casting/registry.json', '{'); + expect(spawnSync('git', [ + 'add', + '.squad/casting/registry.json', + '.squad/casting/history.json', + ], { cwd: root }).status).toBe(0); + expect(spawnSync('git', ['commit', '-qm', 'malformed committed pair'], { cwd: root }).status).toBe(0); + writeCanonicalGenesisPair(root); + }, + expectedStatus: 1, + expectedError: /registry base: committed registry\/history pair is unavailable or malformed/, + }, + ])('handles $name at HEAD through the distributed validator', ({ + mutateBase, + expectedStatus, + expectedError, + }) => { + const fixture = createFixture(); + mutateBase(fixture.root); + const result = validateWorkflowResource(fixture.root, fixture.payload); + expect(result.status, result.stderr).toBe(expectedStatus); + if (expectedError) expect(result.stderr).toMatch(expectedError); + else expect(result.stderr).not.toContain('registry base:'); + }); + it.each([ { name: 'missing casting pair', @@ -827,9 +902,10 @@ describe('GH-AW Cast final-tree validator', () => { installResource(fixture.root, "console.log('Cast validation passed.');\n"); const result = runValidatorCommand(fixture); expect(result.status).not.toBe(0); - expect(result.stderr).toMatch( - /Cast validator SHA-256 mismatch: expected 62fbf47b51639fd1878c143e5176ee3099e390065997411511e9d483d467bbce, got [a-f0-9]{64}\./, + expect(result.stderr).toContain( + `Cast validator SHA-256 mismatch: expected ${sha256(resourceSource())}, got `, ); + expect(result.stderr).toMatch(/got [a-f0-9]{64}\./); expect(result.stdout).not.toContain('Cast validation passed.'); expect(authorizesPullRequest(result)).toBe(false); }); diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index b5f282b55..2d267956a 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1724,7 +1724,10 @@ describe('gh-aw: compiled workflow shell input security contract', () => { '--payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-cast-payload.json"', ); expect(normalizedRunnerStep).not.toContain('RUNNER_TEMP'); - expect(normalizedRunnerStep).toContain('validator_expected_sha256="62fbf47b51639fd1878c143e5176ee3099e390065997411511e9d483d467bbce"'); + const validatorDigest = createHash('sha256') + .update(readFileSync(join(SHARED_DIR, 'squad-cast-validator.mjs'))) + .digest('hex'); + expect(normalizedRunnerStep).toContain(`validator_expected_sha256="${validatorDigest}"`); expect(normalizedRunnerStep).toContain("outcome: 'cast_failure'"); expect(normalizedRunnerStep).toContain('chmod 500 "$validator_runner"'); // Prepared as a pre-agent-step (see the built-in fidelity ordering test below): diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index b6fa49a6c..b0120e934 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -152,7 +152,7 @@ pre-agent-steps: } # BEGIN GENERATED RESOURCE DIGESTS check_hash "$install_verifier" "0da2b31a80b47e428cb215a00d4655b54b7b66e4a6a2522f0f4da8f119d0346e" - check_hash "$cast_validator" "62fbf47b51639fd1878c143e5176ee3099e390065997411511e9d483d467bbce" + check_hash "$cast_validator" "0988e04aeef316f4d7a0107c902bbbcf6538b8899b9fffba5f62150717967685" check_hash "$bootstrap_validator" "d449b9204f7fad133ff7133c1a30c9381c87e3c0c9d481352819ca93ea1a1dad" # END GENERATED RESOURCE DIGESTS node "$bootstrap_validator" \ diff --git a/workflows/package/squad.md b/workflows/package/squad.md index d8db72bef..3cb7d62a2 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -798,7 +798,7 @@ pre-agent-steps: validator_script="$(cd "$(dirname "$validator_script")" && pwd -P)/$(basename "$validator_script")" # BEGIN GENERATED RESOURCE DIGEST - validator_expected_sha256="62fbf47b51639fd1878c143e5176ee3099e390065997411511e9d483d467bbce" + validator_expected_sha256="0988e04aeef316f4d7a0107c902bbbcf6538b8899b9fffba5f62150717967685" # END GENERATED RESOURCE DIGEST : > "$stderr_file" validator_actual_sha256="$( diff --git a/workflows/shared/squad-cast-validator.mjs b/workflows/shared/squad-cast-validator.mjs index a6d83d489..0f6c3a326 100644 --- a/workflows/shared/squad-cast-validator.mjs +++ b/workflows/shared/squad-cast-validator.mjs @@ -474,15 +474,48 @@ function parseCastingPair(registryRaw, historyRaw, source, errors, options = {}) } function committedRegistry(root, errors) { + try { + execFileSync( + 'git', + ['rev-parse', '--verify', 'HEAD'], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + } catch (error) { + errors.push(`registry base: committed HEAD is unavailable (${error.message})`); + return null; + } + + const registryPath = '.squad/casting/registry.json'; + const historyPath = '.squad/casting/history.json'; + let committedPaths; + try { + committedPaths = new Set(execFileSync( + 'git', + ['ls-tree', '-r', '--name-only', 'HEAD', '--', registryPath, historyPath], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ).trim().split('\n').filter(Boolean)); + } catch (error) { + errors.push(`registry base: committed registry/history pair is unavailable (${error.message})`); + return null; + } + const hasRegistry = committedPaths.has(registryPath); + const hasHistory = committedPaths.has(historyPath); + + if (!hasRegistry && !hasHistory) return null; + if (hasRegistry !== hasHistory) { + errors.push('registry base: complete committed registry/history pair is required'); + return null; + } + try { const registryRaw = execFileSync( 'git', - ['show', 'HEAD:.squad/casting/registry.json'], + ['show', `HEAD:${registryPath}`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, ); const historyRaw = execFileSync( 'git', - ['show', 'HEAD:.squad/casting/history.json'], + ['show', `HEAD:${historyPath}`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, ); return parseCastingPair( diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 3c4e9d9b7..0264163f2 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -159,7 +159,7 @@ pre-agent-steps: } # BEGIN GENERATED RESOURCE DIGESTS check_hash "$install_verifier" "0da2b31a80b47e428cb215a00d4655b54b7b66e4a6a2522f0f4da8f119d0346e" - check_hash "$cast_validator" "62fbf47b51639fd1878c143e5176ee3099e390065997411511e9d483d467bbce" + check_hash "$cast_validator" "0988e04aeef316f4d7a0107c902bbbcf6538b8899b9fffba5f62150717967685" check_hash "$bootstrap_validator" "d449b9204f7fad133ff7133c1a30c9381c87e3c0c9d481352819ca93ea1a1dad" # END GENERATED RESOURCE DIGESTS node "$bootstrap_validator" \ diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index f9298e670..3d48a851d 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -14,7 +14,7 @@ "source": "workflows/package/squad.md", "destination": ".github/workflows/squad.md", "lock": ".github/workflows/squad.lock.yml", - "source_sha256": "ae015aa7d55efd0c9c6a5e6ef759a3b6456735e7929487554402185dcaefb4a3" + "source_sha256": "70eb04edba8c0ca129f910f1d7c11d9378c7832e737a8922d5fa928278a670e6" }, { "name": "squad-implement-worker", @@ -56,7 +56,7 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "4362c1c3b9d686bc920a76991cd8756cadd0beaf3021fe12171834188df665b7" + "source_sha256": "f17065511fdc32fb57c2bb41758afd922dfeddbbc6168339d6800498f2470209" } ], "shared_runtime": [ @@ -74,7 +74,7 @@ "package_destination": ".github/workflows/shared/squad-cast-validator.mjs", "destination": ".github/workflows/shared/squad-cast-validator.mjs", "ownership": "manifest", - "sha256": "62fbf47b51639fd1878c143e5176ee3099e390065997411511e9d483d467bbce" + "sha256": "0988e04aeef316f4d7a0107c902bbbcf6538b8899b9fffba5f62150717967685" }, { "path": "shared/squad-bootstrap-validator.mjs", diff --git a/workflows/squad.md b/workflows/squad.md index 73db798a6..ebf34bc83 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -136,7 +136,7 @@ pre-agent-steps: validator_script="$(cd "$(dirname "$validator_script")" && pwd -P)/$(basename "$validator_script")" # BEGIN GENERATED RESOURCE DIGEST - validator_expected_sha256="62fbf47b51639fd1878c143e5176ee3099e390065997411511e9d483d467bbce" + validator_expected_sha256="0988e04aeef316f4d7a0107c902bbbcf6538b8899b9fffba5f62150717967685" # END GENERATED RESOURCE DIGEST : > "$stderr_file" validator_actual_sha256="$(