diff --git a/test/gh-aw-deps-worker-workflow.test.ts b/test/gh-aw-deps-worker-workflow.test.ts new file mode 100644 index 000000000..4fdf850aa --- /dev/null +++ b/test/gh-aw-deps-worker-workflow.test.ts @@ -0,0 +1,336 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// #1748 slice S1: `squad-deps-worker.md` is a *scaffold*. It exists so the +// extensionless manifest/lockfile basenames (`go.mod`, `go.sum`, `yarn.lock`, +// `package-lock.json`, ...) have an explicit home in `allowed-files` before +// slice S2 adds Wave 1 `protected-files.exclude` entries. Until S2 lands, this +// worker's `protected-files` carries NO exclusions -- every manifest write +// still falls back to a review issue, identical to `squad-implement-worker`. +// These tests assert that current, deliberately inert, fail-closed state and +// guard the general worker against silently gaining manifest authority. + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function read(relativePath: string): string { + return readFileSync(resolve(ROOT, relativePath), 'utf-8'); +} + +function frontmatter(markdown: string): string { + return markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; +} + +function yamlBlock(yaml: string, key: string): string { + const lines = yaml.split(/\r?\n/); + const keyPattern = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*(.*)$`); + const start = lines.findIndex(line => keyPattern.test(line)); + if (start === -1) return ''; + + const match = lines[start].match(keyPattern)!; + const indent = match[1].length; + const block = [lines[start]]; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() !== '' && line.search(/\S/) <= indent) break; + block.push(line); + } + return block.join('\n'); +} + +function scalarInBlock(block: string, key: string): string | undefined { + const match = block.match(new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*(.+)$`, 'm')); + return match?.[1].trim().replace(/^['"]|['"]$/g, ''); +} + +function listInBlock(block: string, key: string): string[] { + const lines = block.split(/\r?\n/); + const inline = block.match(new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*\\[(.*)\\]\\s*$`, 'm')); + if (inline) { + return inline[1] + .split(',') + .map(item => item.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); + } + + const keyPattern = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*$`); + const start = lines.findIndex(line => keyPattern.test(line)); + if (start === -1) return []; + + const indent = lines[start].match(keyPattern)![1].length; + const items: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() !== '' && line.search(/\S/) <= indent) break; + const item = line.match(/^\s+-\s+(.+)$/)?.[1]; + if (item) items.push(item.trim().replace(/^['"]|['"]$/g, '')); + } + return items; +} + +const compileWorkspaces: string[] = []; + +afterAll(() => { + for (const workspace of compileWorkspaces) { + rmSync(workspace, { recursive: true, force: true }); + } +}); + +/** + * Compiles `squad-deps-worker` in an isolated workspace and returns the + * decoded `create_pull_request` safe-output config gh-aw baked into the + * `.lock.yml`. This is the only place the *compiled* (as opposed to + * hand-authored source) `protected_files`/`allowed_files` contract is + * observable -- gh-aw's built-in manifest catalog is merged in at compile + * time and is not otherwise visible from the workflow source file. + */ +function compileDepsWorker(): Record { + const workspace = mkdtempSync(resolve(tmpdir(), 'squad-deps-worker-contract-')); + compileWorkspaces.push(workspace); + const workflowDir = resolve(workspace, '.github', 'workflows'); + mkdirSync(workflowDir, { recursive: true }); + cpSync(resolve(ROOT, 'workflows'), workflowDir, { recursive: true }); + 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 lines = compiled.split(/\r?\n/); + const configStart = lines.findIndex(line => line.includes('/safeoutputs/config.json') && line.includes('<<')); + const delimiter = lines[configStart]?.match(/<< '([^']+)'/)?.[1]; + const configEnd = delimiter + ? lines.findIndex((line, index) => index > configStart && line.trim() === delimiter) + : -1; + + expect(configStart, 'compiled deps worker must write the safe-output config').toBeGreaterThanOrEqual(0); + expect(delimiter, 'safe-output config must use a parseable heredoc delimiter').toBeDefined(); + expect(configEnd, 'safe-output config heredoc must be terminated').toBeGreaterThan(configStart); + + const safeOutputs = JSON.parse(lines.slice(configStart + 1, configEnd).join('\n')) as Record< + string, + Record + >; + return safeOutputs.create_pull_request; +} + +// Wave 1 (npm/yarn/pnpm + NuGet CPM + Go) manifest/lockfile basenames that +// gh-aw's basename-anywhere protected-files matching cannot resolve from an +// extension pattern alone (issue #1748's Flight Decision comment, APPROVED -- +// IMPLEMENTATION-READY, 2026-08-25, "allowed-files gap"). +const WAVE_1_EXTENSIONLESS_BASENAMES = ['go.mod', 'go.sum', 'yarn.lock']; +const WAVE_1_MANIFEST_BASENAMES = [ + 'go.mod', + 'go.sum', + 'yarn.lock', + 'package-lock.json', + 'package.json', + 'pnpm-lock.yaml', + 'npm-shrinkwrap.json', + 'Directory.Packages.props', +]; + +// Registry/install config, SDK/tool pins, and governance docs that must stay +// protected in every wave regardless of ecosystem scope or opt-out (issue +// #1748's Flight Decision comment, APPROVED -- IMPLEMENTATION-READY, +// 2026-08-25, "Always-protected" list + "bunfig.toml ruling"). +const ALWAYS_PROTECTED_BASENAMES = [ + 'bunfig.toml', + 'NuGet.Config', + 'global.json', + 'CODEOWNERS', + 'SECURITY.md', + 'CONTRIBUTING.md', + 'CHANGELOG.md', + 'CODE_OF_CONDUCT.md', + 'DESIGN.md', + 'AGENTS.md', +]; + +describe('gh-aw squad-deps-worker scaffold (#1748 slice S1)', () => { + const dispatcher = read('workflows/squad.md'); + const generalWorker = read('workflows/squad-implement-worker.md'); + const depsWorker = read('workflows/squad-deps-worker.md'); + const dispatcherFrontmatter = frontmatter(dispatcher); + const generalWorkerFrontmatter = frontmatter(generalWorker); + const depsWorkerFrontmatter = frontmatter(depsWorker); + + it('is a standalone workflow_dispatch worker, not yet wired into the dispatcher', () => { + expect(depsWorkerFrontmatter).toMatch(/^on:\r?\n\s+bots: \["github-actions\[bot\]"\]\r?\n\s+workflow_dispatch:/m); + expect(depsWorker).not.toContain('slash_command:'); + expect(depsWorkerFrontmatter).toContain('issue_number:'); + expect(depsWorkerFrontmatter).toContain('aw_context:'); + expect(depsWorker).toMatch(/^tools:\r?\n\s+edit:/m); + + // S3 (dispatcher/config routing) has not landed yet -- the deps worker + // must not be reachable from `squad.md`'s dispatch-workflow allowlist + // until that slice explicitly wires it in. + const dispatcherDispatch = yamlBlock(dispatcherFrontmatter, 'dispatch-workflow'); + expect(listInBlock(dispatcherDispatch, 'workflows')).not.toContain('squad-deps-worker'); + }); + + it('declares Wave 1 extensionless manifest/lockfile basenames in allowed-files (T3)', () => { + const allowedFiles = listInBlock(yamlBlock(depsWorkerFrontmatter, 'allowed-files'), 'allowed-files'); + + for (const basename of WAVE_1_EXTENSIONLESS_BASENAMES) { + expect(allowedFiles, `${basename} must be explicitly allowed`).toContain(basename); + } + expect(allowedFiles).toContain('package-lock.json'); + expect(allowedFiles).toContain('package.json'); + }); + + it('keeps dependency-manifest authority narrow: no broad source-file globs', () => { + const allowedFiles = listInBlock(yamlBlock(depsWorkerFrontmatter, 'allowed-files'), 'allowed-files'); + + // The general worker's broad source-tree allowlist (extensions, `src/**`, + // language directories, etc.) must not leak into the deps worker -- its + // entire reason to exist is that it can touch nothing but dependency + // manifests and lockfiles. + const broadPatternsFromGeneralWorker = [ + '*.ts', + '**/*.ts', + '*.py', + '**/*.py', + '*.md', + '**/*.md', + 'src/**', + 'docs/**', + 'Makefile', + ]; + for (const pattern of broadPatternsFromGeneralWorker) { + expect(allowedFiles, `${pattern} must not appear in the deps worker's allowed-files`).not.toContain(pattern); + } + + // Registry/install config and governance basenames must never be + // authorized at all -- being absent from `allowed-files` blocks them + // structurally before `protected-files` is even evaluated. + for (const basename of ALWAYS_PROTECTED_BASENAMES) { + expect(allowedFiles, `${basename} must not be in allowed-files`).not.toContain(basename); + } + expect(allowedFiles).not.toContain('.npmrc'); + expect(allowedFiles).not.toContain('.yarnrc.yml'); + }); + + it('carries no protected-files exclusions yet (S2 has not landed)', () => { + const protectedFiles = yamlBlock(depsWorkerFrontmatter, 'protected-files'); + + expect(scalarInBlock(protectedFiles, 'policy')).toBe('fallback-to-issue'); + expect(scalarInBlock(protectedFiles, 'policy')).not.toBe('request_review'); + expect(scalarInBlock(protectedFiles, 'policy')).not.toBe('allowed'); + // No `exclude:` key at all -- every manifest basename still falls back to + // a review issue today. Slice S2 introduces the Wave 1 exclude list. + expect(listInBlock(protectedFiles, 'exclude')).toEqual([]); + }); + + it('structurally strips vendored/generated content from any produced patch', () => { + const excludedFiles = listInBlock(yamlBlock(depsWorkerFrontmatter, 'excluded-files'), 'excluded-files'); + + expect(excludedFiles).toEqual( + expect.arrayContaining([ + 'node_modules/**', + '**/node_modules/**', + 'vendor/**', + '**/vendor/**', + '.github/workflows/**', + '**/.github/workflows/**', + '.github/agents/**', + '**/.github/agents/**', + '.github/aw/**', + '**/.github/aw/**', + '.squad/**', + '**/.squad/**', + ]), + ); + }); + + it('leaves squad-implement-worker with no manifest exclusions (general path unchanged)', () => { + const protectedFiles = yamlBlock(generalWorkerFrontmatter, 'protected-files'); + const excludeList = listInBlock(protectedFiles, 'exclude'); + + expect(scalarInBlock(protectedFiles, 'policy')).toBe('fallback-to-issue'); + expect(excludeList).toEqual(['README.md']); + for (const basename of [...WAVE_1_MANIFEST_BASENAMES, ...ALWAYS_PROTECTED_BASENAMES]) { + expect(excludeList, `${basename} must not be excluded on the general path`).not.toContain(basename); + } + }); + + it( + 'compiles cleanly and bakes in a fail-closed protected-files contract', + () => { + const config = compileDepsWorker(); + + expect(config.protected_files_policy).toBe('fallback-to-issue'); + expect(config.protect_top_level_dot_folders).toBe(true); + + const compiledProtectedFiles = config.protected_files as string[]; + const compiledAllowedFiles = config.allowed_files as string[]; + const compiledExcludedFiles = config.excluded_files as string[]; + + // Nothing has been excluded from protection yet: every Wave 1 manifest + // basename this worker is allowed to *see* still resolves to + // `fallback-to-issue` when it appears in a patch. + for (const basename of WAVE_1_MANIFEST_BASENAMES) { + expect(compiledProtectedFiles, `${basename} must still be protected (S2 has not landed)`).toContain( + basename, + ); + } + // Registry/config and governance files stay protected in gh-aw's + // built-in catalog regardless of this worker's allowed-files scope. + for (const basename of ALWAYS_PROTECTED_BASENAMES) { + expect(compiledProtectedFiles, `${basename} must remain protected`).toContain(basename); + } + + for (const basename of WAVE_1_EXTENSIONLESS_BASENAMES) { + expect(compiledAllowedFiles).toContain(basename); + } + expect(compiledExcludedFiles).toEqual( + expect.arrayContaining(['node_modules/**', 'vendor/**', '.squad/**']), + ); + }, + 20000, + ); + + it('does not change squad-implement-worker.md at all', () => { + // Structural regression guard: compiling the general worker in the same + // workspace must still exclude only README.md -- the new deps worker file + // must have zero effect on the general worker's compiled contract. + const workspace = mkdtempSync(resolve(tmpdir(), 'squad-implement-worker-unaffected-')); + compileWorkspaces.push(workspace); + const workflowDir = resolve(workspace, '.github', 'workflows'); + mkdirSync(workflowDir, { recursive: true }); + cpSync(resolve(ROOT, 'workflows'), workflowDir, { recursive: true }); + execFileSync('git', ['init', '--quiet'], { cwd: workspace }); + execFileSync( + 'gh', + ['aw', 'compile', 'squad-implement-worker', '--strict', '--no-check-update'], + { cwd: workspace, encoding: 'utf8', stdio: 'pipe' }, + ); + + const compiled = readFileSync(resolve(workflowDir, 'squad-implement-worker.lock.yml'), 'utf8'); + const lines = compiled.split(/\r?\n/); + const configStart = lines.findIndex(line => line.includes('/safeoutputs/config.json') && line.includes('<<')); + const delimiter = lines[configStart]?.match(/<< '([^']+)'/)?.[1]; + const configEnd = delimiter + ? lines.findIndex((line, index) => index > configStart && line.trim() === delimiter) + : -1; + + expect(configStart, 'compiled general worker must write the safe-output config').toBeGreaterThanOrEqual(0); + expect(delimiter, 'safe-output config must use a parseable heredoc delimiter').toBeDefined(); + expect(configEnd, 'safe-output config heredoc must be terminated').toBeGreaterThan(configStart); + + const safeOutputs = JSON.parse(lines.slice(configStart + 1, configEnd).join('\n')) as Record< + string, + Record + >; + const compiledProtectedFiles = safeOutputs.create_pull_request.protected_files as string[]; + + for (const basename of WAVE_1_MANIFEST_BASENAMES) { + expect(compiledProtectedFiles).toContain(basename); + } + }, 20000); +}); diff --git a/workflows/squad-deps-worker.md b/workflows/squad-deps-worker.md new file mode 100644 index 000000000..7518fc4bb --- /dev/null +++ b/workflows/squad-deps-worker.md @@ -0,0 +1,172 @@ +--- +name: Squad Dependency Worker +run-name: "Squad deps — ${{ github.event.inputs.issue_number }}" +description: >- + Add, remove, or update package dependencies for one Squad issue under narrow + dependency-manifest/lockfile authority (Wave 1: npm/yarn/pnpm, NuGet CPM, Go) +private: false +on: + bots: ["github-actions[bot]"] + workflow_dispatch: + inputs: + issue_number: + description: Issue number requesting a dependency change + required: true + type: string + aw_context: + description: Originating agentic workflow context + required: false + type: string +permissions: + contents: read + copilot-requests: write + issues: read + pull-requests: read +concurrency: + group: "squad-deps-${{ github.event.inputs.issue_number }}" + cancel-in-progress: false +network: + allowed: + - defaults + - containers + - dotnet + - go + - node +imports: + - shared/squad.md +tools: + edit: + bash: true + github: + mode: gh-proxy + toolsets: [default] +safe-outputs: + create-pull-request: + title-prefix: "[squad-deps] " + labels: [squad] + max: 1 + allowed-base-branches: + - "squad/*" + allowed-branches: + - "squad/deps-*" + # Narrow, dependency-manifest/lockfile-only authority (Wave 1: npm/yarn/pnpm, + # NuGet central package management, Go). This worker MUST NOT gain the broad + # source-file authority `squad-implement-worker` has -- its entire reason to + # exist is that it can touch nothing else. Extensionless basenames (`go.mod`, + # `go.sum`, `yarn.lock`) match no existing extension pattern and must be + # listed explicitly; `package.json`/`package-lock.json`/`pnpm-lock.yaml`/ + # `npm-shrinkwrap.json`/`Directory.Packages.props` are listed explicitly too, + # even though their extensions would otherwise match a broader glob, so this + # list stays the single source of truth for what the worker may touch. + allowed-files: + - "package.json" + - "**/package.json" + - "package-lock.json" + - "**/package-lock.json" + - "npm-shrinkwrap.json" + - "**/npm-shrinkwrap.json" + - "yarn.lock" + - "**/yarn.lock" + - "pnpm-lock.yaml" + - "**/pnpm-lock.yaml" + - "Directory.Packages.props" + - "**/Directory.Packages.props" + - "go.mod" + - "**/go.mod" + - "go.sum" + - "**/go.sum" + # No manifest is excluded from protection yet -- Wave 1 exclusions land in a + # follow-up slice (S2). Until then this worker's manifest writes fall back to + # a review issue exactly like `squad-implement-worker`'s do, so nothing here + # can produce a manifest PR before S2 lands. Registry/install config + # (`NuGet.Config`, `bunfig.toml`, `.npmrc`, `.yarnrc.yml`), SDK/tool pins + # (`global.json`), and governance docs (`CODEOWNERS`, `SECURITY.md`, + # `CONTRIBUTING.md`, `CHANGELOG.md`, `CODE_OF_CONDUCT.md`, `DESIGN.md`, + # `AGENTS.md`) stay protected in every wave -- see issue #1748's Flight + # Decision comment (APPROVED -- IMPLEMENTATION-READY, 2026-08-25), + # "bunfig.toml ruling" and "Always-protected" list. + protected-files: + policy: fallback-to-issue + excluded-files: + # 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 + # protected-files evaluation -- the correct mechanism per issue #1748's + # Flight Decision comment (APPROVED -- IMPLEMENTATION-READY, 2026-08-25), + # "Vendored/generated dependency content" threat-model row. + - "node_modules/**" + - "**/node_modules/**" + - "vendor/**" + - "**/vendor/**" + - "bin/**" + - "**/bin/Debug/**" + - "**/bin/Release/**" + - "obj/**" + - "**/obj/**" + - ".github/workflows/**" + - "**/.github/workflows/**" + - ".github/agents/**" + - "**/.github/agents/**" + - ".github/aw/**" + - "**/.github/aw/**" + - ".squad/**" + - "**/.squad/**" + max-patch-files: 25 + expires: 14d + add-comment: + max: 3 + target: "*" +--- + +# Squad Dependency Worker + +This workflow adds, removes, or updates a package dependency for one Squad +issue and opens a focused pull request. It exists as a dedicated dispatch path +so that dependency-manifest authority never leaks into the general +`squad-implement-worker` path: that worker's `protected-files` carries no +manifest exclusions and is unchanged by this workflow's existence. + +This slice (S1) only scaffolds the worker and backfills the extensionless +manifest/lockfile basenames (`go.mod`, `go.sum`, `yarn.lock`, +`package-lock.json`, and related Wave 1 files) into `allowed-files`. No +manifest is yet excluded from `protected-files`, so every manifest write still +falls back to a review issue today -- identical to `squad-implement-worker`. +Wave 1 `protected-files.exclude` entries, the `squadDeps` opt-out guard, and +the `dependency-change` PR presentation rules are separate follow-up slices. + +## Gather Context + +1. Read the issue title, body, labels, state, and relevant comments. +2. Stop with a comment if the issue is closed. +3. Check for an existing open pull request whose branch starts with + `squad/deps-${{ github.event.inputs.issue_number }}-` or whose body closes + this issue. If one exists, comment with its URL and stop. +4. Read `.squad/team.md` and `.squad/routing.md`. Route work to the member + named by the `squad:{member}` label, or let the Lead choose specialists. + +## Implement + +1. Inspect the repository and identify the smallest dependency-manifest change + satisfying the issue's acceptance criteria, limited to the ecosystems this + worker currently supports (npm, yarn, pnpm, NuGet central package + management, Go). +2. Do not change `.github/workflows/`, `.github/agents/`, `.github/aw/`, or + `.squad/`. +3. Do not touch `node_modules/`, `vendor/`, build output directories, or any + other vendored/generated content -- this worker is never authorized to + commit vendored or generated dependency content. +4. Run the smallest existing build, test, and lint commands covering the + change. + +## Open Pull Request + +Use the `create-pull-request` safe-output: + +- Branch: `squad/deps-${{ github.event.inputs.issue_number }}-{short-slug}` +- Title: `Update dependencies for #${{ github.event.inputs.issue_number }}: {issue-title}` +- Body: summarize the dependency change and validation, including + `Closes #${{ github.event.inputs.issue_number }}`. +- Files: include only files required for this issue. + +If the repository already satisfies the issue, comment with evidence and do +not create an empty pull request.