diff --git a/test/gh-aw-deps-worker-workflow.test.ts b/test/gh-aw-deps-worker-workflow.test.ts index 08cf1fef7..adf3dd5f5 100644 --- a/test/gh-aw-deps-worker-workflow.test.ts +++ b/test/gh-aw-deps-worker-workflow.test.ts @@ -1,7 +1,6 @@ import { afterAll, describe, expect, it } from 'vitest'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; -import { tmpdir } from 'node:os'; import { dirname, matchesGlob, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { extractSafeOutputsConfigJson } from './helpers/gh-aw-lock.js'; @@ -83,11 +82,17 @@ afterAll(() => { * `create_pull_request` safe-output config gh-aw bakes into `.lock.yml`. */ function compileWorker(workflowId: string): Record { - const workspace = mkdtempSync(resolve(tmpdir(), `${workflowId}-contract-`)); + const workspace = mkdtempSync(resolve(ROOT, `.${workflowId}-contract-`)); compileWorkspaces.push(workspace); const workflowDir = resolve(workspace, '.github', 'workflows'); mkdirSync(workflowDir, { recursive: true }); cpSync(resolve(ROOT, 'workflows'), workflowDir, { recursive: true }); + if (workflowId === 'squad-deps-worker') { + cpSync( + resolve(ROOT, 'workflows/package/squad-deps-worker.md'), + resolve(workflowDir, 'squad-deps-worker.md'), + ); + } execFileSync('git', ['init', '--quiet'], { cwd: workspace }); execFileSync( 'gh', @@ -171,8 +176,8 @@ describe('gh-aw squad-deps-worker S2: Wave 1 protected-files.exclude (#1748)', ( expect(excludeList.length, 'exclude list must contain exactly the Wave-1 manifest basenames').toBe(WAVE_1_MANIFEST_BASENAMES.length); }); - // ── T2: always-protected basenames absent from exclude, present in compiled ─ - it('always-protected basenames are absent from authored exclude and present in compiled protected_files (T2)', () => { + // ── T2: always-protected basenames remain outside protected-files.exclude ── + it('always-protected basenames are absent from authored protected-files.exclude (T2)', () => { const protectedFiles = yamlBlock(depsWorkerFrontmatter, 'protected-files'); const excludeList = listInBlock(protectedFiles, 'exclude'); @@ -215,10 +220,17 @@ describe('gh-aw squad-deps-worker S2: Wave 1 protected-files.exclude (#1748)', ( ).not.toContain(basename); } - // T5: always-protected still in compiled protected_files + // T5: gh-aw catalog entries remain in compiled protected_files. + // CHANGELOG.md is not in gh-aw's built-in catalog, so the canonical + // workflow protects it structurally through compiled excluded_files. for (const basename of ALWAYS_PROTECTED_BASENAMES) { + if (basename === 'CHANGELOG.md') continue; expect(compiledProtectedFiles, `${basename} must remain in compiled protected_files`).toContain(basename); } + expect( + compiledExcludedFiles, + 'CHANGELOG.md must remain structurally protected in compiled excluded_files', + ).toContain('CHANGELOG.md'); // .npmrc and .yarnrc.yml are protected structurally by absence from // allowed-files -- gh-aw's built-in catalog does not include them in // the compiled protected_files list, so we assert only that they are @@ -233,7 +245,7 @@ describe('gh-aw squad-deps-worker S2: Wave 1 protected-files.exclude (#1748)', ( // T8: vendored/generated paths in excluded_files expect(compiledExcludedFiles).toEqual( - expect.arrayContaining(['node_modules/**', 'vendor/**', '.squad/**']), + expect.arrayContaining(['CHANGELOG.md', 'node_modules/**', 'vendor/**', '.squad/**']), ); }, 20000, @@ -273,6 +285,7 @@ describe('gh-aw squad-deps-worker S2: Wave 1 protected-files.exclude (#1748)', ( expect(excludedFiles).toEqual( expect.arrayContaining([ + 'CHANGELOG.md', 'node_modules/**', '**/node_modules/**', 'vendor/**', @@ -461,6 +474,51 @@ describe('gh-aw squad-deps-worker S2: Wave 1 protected-files.exclude (#1748)', ( ); }); + it( + 'detects: removing CHANGELOG.md protection from the live canonical source changes compiled output', + () => { + const workspace = mkdtempSync(resolve(ROOT, '.deps-worker-changelog-mutation-')); + compileWorkspaces.push(workspace); + const workflowDir = resolve(workspace, '.github', 'workflows'); + mkdirSync(workflowDir, { recursive: true }); + cpSync(resolve(ROOT, 'workflows'), workflowDir, { recursive: true }); + + const canonicalSource = read('workflows/package/squad-deps-worker.md'); + const mutatedSource = canonicalSource.replace(/^\s+- CHANGELOG\.md\s*$/m, ''); + expect(mutatedSource, 'mutation must remove CHANGELOG.md from the canonical source') + .not.toBe(canonicalSource); + writeFileSync(resolve(workflowDir, 'squad-deps-worker.md'), mutatedSource, 'utf-8'); + + execFileSync('git', ['init', '--quiet'], { cwd: workspace }); + execFileSync( + 'gh', + ['aw', 'compile', 'squad-deps-worker', '--strict', '--no-check-update'], + { cwd: workspace, encoding: 'utf8', stdio: 'pipe' }, + ); + + const compiled = readFileSync( + resolve(workflowDir, 'squad-deps-worker.lock.yml'), + 'utf8', + ); + const jsonText = extractSafeOutputsConfigJson(compiled); + expect(jsonText, 'mutated dependency worker must compile a parseable safe-output config') + .toBeDefined(); + const safeOutputs = JSON.parse(jsonText!) as Record>; + const compiledExcludedFiles = ( + safeOutputs.create_pull_request?.excluded_files ?? [] + ) as string[]; + + expect( + () => expect( + compiledExcludedFiles, + 'CHANGELOG.md must remain structurally protected in compiled excluded_files', + ).toContain('CHANGELOG.md'), + 'compiled regression assertion must fail after removing CHANGELOG.md from the canonical source', + ).toThrow(); + }, + 20000, + ); + it( 'detects: Wave-1 exclusion leaked into general worker — compiled contract catches package.json in implement-worker exclude', () => { @@ -471,7 +529,7 @@ describe('gh-aw squad-deps-worker S2: Wave 1 protected-files.exclude (#1748)', ( // protected_files will drop it and this assertion throws -- proving the // guard turns red on a genuine source mutation rather than a hand-built // local array. - const workspace = mkdtempSync(resolve(tmpdir(), 'implement-worker-mutation-')); + const workspace = mkdtempSync(resolve(ROOT, '.implement-worker-mutation-')); compileWorkspaces.push(workspace); const workflowDir = resolve(workspace, '.github', 'workflows'); mkdirSync(workflowDir, { recursive: true }); diff --git a/workflows/package/squad-deps-worker.md b/workflows/package/squad-deps-worker.md index 1cb8ce4b2..199e85b55 100644 --- a/workflows/package/squad-deps-worker.md +++ b/workflows/package/squad-deps-worker.md @@ -388,6 +388,7 @@ safe-outputs: - go.mod - go.sum excluded-files: + - CHANGELOG.md - node_modules/** - "**/node_modules/**" - vendor/** diff --git a/workflows/squad-deps-worker.md b/workflows/squad-deps-worker.md index 23f7812d0..a35611479 100644 --- a/workflows/squad-deps-worker.md +++ b/workflows/squad-deps-worker.md @@ -244,6 +244,9 @@ safe-outputs: - go.mod - go.sum excluded-files: + # Release history remains human-controlled even though gh-aw does not + # currently include CHANGELOG.md in its built-in protected-file catalog. + - "CHANGELOG.md" # Never authorize vendored or generated dependency content, even once a # manifest basename above is excluded from protection in a later slice. # `excluded-files` strips these paths from the patch structurally, before diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index d227cdefe..f9298e670 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -35,7 +35,7 @@ "source": "workflows/package/squad-deps-worker.md", "destination": ".github/workflows/squad-deps-worker.md", "lock": ".github/workflows/squad-deps-worker.lock.yml", - "source_sha256": "e7234fb7e22ad648414f51c1ffa0647e16650c160c1cdbc15c734697e4615e62" + "source_sha256": "f800dcb2bf711aac318853fe0d562dbf1f0d8c458c255d4f9a2aea122cdcc47e" }, { "name": "squad-retro",