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
3 changes: 2 additions & 1 deletion .github/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ GitHub Agentic Workflows (`gh-aw`) are composable AI workflows triggered by slas
gh aw add \
bradygaster/squad/workflows/squad.md@dev \
bradygaster/squad/workflows/squad-implement-worker.md@dev \
bradygaster/squad/workflows/squad-deps-worker.md@dev \
bradygaster/squad/workflows/squad-review.md@dev
```

This command:

1. Fetches the Squad dispatcher, implementation worker, and advisory reviewer
1. Fetches the Squad dispatcher, general and dependency workers, and advisory reviewer
2. Compiles them into GitHub Actions–compatible workflows
3. Adds the workflow sources and generated files to your repository's `.github/` directory

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ If you use [GitHub Agentic Workflows](https://github.blog/changelog/2025-05-19-g
gh aw add \
bradygaster/squad/workflows/squad.md@dev \
bradygaster/squad/workflows/squad-implement-worker.md@dev \
bradygaster/squad/workflows/squad-deps-worker.md@dev \
bradygaster/squad/workflows/squad-review.md@dev
git add -- \
.github/aw/ \
Expand Down
10 changes: 7 additions & 3 deletions docs/src/content/docs/guide/gh-aw.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ gh api --method PUT repos/{owner}/{repo}/actions/permissions/workflow \
gh aw add \
bradygaster/squad/workflows/squad.md@dev \
bradygaster/squad/workflows/squad-implement-worker.md@dev \
bradygaster/squad/workflows/squad-deps-worker.md@dev \
bradygaster/squad/workflows/squad-review.md@dev

# 4. Commit and push the workflow sources and generated files
Expand Down Expand Up @@ -85,12 +86,14 @@ approve their own pull requests.
gh aw add \
bradygaster/squad/workflows/squad.md@dev \
bradygaster/squad/workflows/squad-implement-worker.md@dev \
bradygaster/squad/workflows/squad-deps-worker.md@dev \
bradygaster/squad/workflows/squad-review.md@dev
```

Keep the dispatcher first. `gh aw add` discovers its implementation-worker and
reviewer dependencies while compiling it; the explicit worker and reviewer
entries then confirm the complete install surface without creating duplicates.
Keep the dispatcher first. `gh aw add` discovers its general worker, dependency
worker, and reviewer dependencies while compiling it; the explicit worker and
reviewer entries then confirm the complete install surface without creating
duplicates.
The installed top-level workflow set is:

- `squad.md` and `squad.lock.yml`
Expand Down Expand Up @@ -978,6 +981,7 @@ To update your compiled workflow after pulling upstream changes:
gh aw add \
bradygaster/squad/workflows/squad.md@dev \
bradygaster/squad/workflows/squad-implement-worker.md@dev \
bradygaster/squad/workflows/squad-deps-worker.md@dev \
bradygaster/squad/workflows/squad-review.md@dev
```

Expand Down
50 changes: 50 additions & 0 deletions test/fixtures/gh-aw-deps-routing.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
[
{
"name": "explicit npm dependency addition",
"title": "Add lodash as a runtime dependency",
"body": "Add lodash 4.17.21 and regenerate the npm lockfile.",
"requiredFiles": ["package.json", "package-lock.json"],
"config": "{\"version\":1}",
"expectedWorkflow": "squad-deps-worker"
},
{
"name": "ordinary source task with dependency relationship",
"title": "Fix login validation",
"body": "Depends on: #41. Correct the validator without changing packages.",
"requiredFiles": ["src/login.ts", "test/login.test.ts"],
"config": "{\"version\":1}",
"expectedWorkflow": "squad-implement-worker"
},
{
"name": "mixed dependency and source task",
"title": "Add Serilog and wire request logging",
"body": "Add the package, configure the request pipeline, and add tests.",
"requiredFiles": ["Directory.Packages.props", "src/App/Program.cs", "test/App.Tests.cs"],
"config": "{\"squadDeps\":\"allow\"}",
"expectedWorkflow": "squad-implement-worker"
},
{
"name": "unsupported dependency ecosystem",
"title": "Add pytest",
"body": "Add pytest to the Python development dependencies.",
"requiredFiles": ["pyproject.toml"],
"config": "{\"squadDeps\":\"allow\"}",
"expectedWorkflow": "squad-implement-worker"
},
{
"name": "dependency addition denied by repository policy",
"title": "Add zod",
"body": "Add zod and regenerate package-lock.json.",
"requiredFiles": ["package.json", "package-lock.json"],
"config": "{\"squadDeps\":\"deny\"}",
"expectedWorkflow": "denied"
},
{
"name": "dependency addition denied by malformed config",
"title": "Add testify",
"body": "Add testify to the Go module.",
"requiredFiles": ["go.mod", "go.sum"],
"config": "{\"squadDeps\":",
"expectedWorkflow": "denied"
}
]
250 changes: 250 additions & 0 deletions test/gh-aw-deps-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
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 { basename, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const WAVE_1_BASENAMES = new Set([
'package.json',
'package-lock.json',
'npm-shrinkwrap.json',
'yarn.lock',
'pnpm-lock.yaml',
'Directory.Packages.props',
'go.mod',
'go.sum',
]);

interface RoutingFixture {
name: string;
title: string;
body: string;
requiredFiles: string[];
config: string;
expectedWorkflow: 'squad-deps-worker' | 'squad-implement-worker' | 'denied';
}

function read(relativePath: string): string {
return readFileSync(resolve(ROOT, relativePath), 'utf8');
}

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 indent = lines[start].match(keyPattern)![1].length;
const block = [lines[start]];
for (let index = start + 1; index < lines.length; index++) {
const line = lines[index];
if (line.trim() !== '' && line.search(/\S/) <= indent) break;
block.push(line);
}
return block.join('\n');
}

function listInBlock(block: string, key: string): string[] {
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);
}
return [];
}

function explicitDependencyIntent(title: string, body: string): boolean {
const task = `${title}\n${body}`.toLowerCase();
return (
/\b(add|install|remove|uninstall|update|upgrade|bump)\b[\s\S]*\b(package|dependency|dependencies|module|lockfile)\b/.test(
task,
) ||
/\bregenerate\b[\s\S]*\blockfile\b/.test(task) ||
/\badd\b[\s\S]*\b(package\.json|package-lock\.json|go\.mod|go\.sum|directory\.packages\.props)\b/.test(
task,
)
);
}

function dependencyConfigAllows(configText: string): boolean {
let config: unknown;
try {
config = JSON.parse(configText);
} catch {
return false;
}
if (config === null || typeof config !== 'object' || Array.isArray(config)) return false;
const object = config as Record<string, unknown>;
if (!Object.hasOwn(object, 'squadDeps')) return true;
return object.squadDeps === 'allow';
}

function routeFixture(fixture: RoutingFixture): RoutingFixture['expectedWorkflow'] {
const dependencyOnly =
fixture.requiredFiles.length > 0 &&
fixture.requiredFiles.every(file => WAVE_1_BASENAMES.has(basename(file)));
if (!explicitDependencyIntent(fixture.title, fixture.body) || !dependencyOnly) {
return 'squad-implement-worker';
}
return dependencyConfigAllows(fixture.config) ? 'squad-deps-worker' : 'denied';
}

const compileWorkspaces: string[] = [];

afterAll(() => {
for (const workspace of compileWorkspaces) {
rmSync(workspace, { recursive: true, force: true });
}
});

function compileSafeOutputs(workflowId: string): Record<string, Record<string, unknown>> {
const workspace = mkdtempSync(resolve(tmpdir(), `${workflowId}-routing-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', workflowId, '--strict', '--no-check-update'],
{ cwd: workspace, encoding: 'utf8', stdio: 'pipe' },
);

const compiled = readFileSync(resolve(workflowDir, `${workflowId}.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, `${workflowId} must compile safe-output config`).toBeGreaterThanOrEqual(0);
expect(delimiter, `${workflowId} safe-output delimiter must be present`).toBeDefined();
expect(configEnd, `${workflowId} safe-output config must terminate`).toBeGreaterThan(configStart);
return JSON.parse(lines.slice(configStart + 1, configEnd).join('\n')) as Record<
string,
Record<string, unknown>
>;
}

describe('gh-aw dependency dispatcher routing (#1748 S3)', () => {
const dispatcher = read('workflows/squad.md');
const depsWorker = read('workflows/squad-deps-worker.md');
const generalWorker = read('workflows/squad-implement-worker.md');
const fixtures = JSON.parse(
read('test/fixtures/gh-aw-deps-routing.json'),
) as RoutingFixture[];
const dispatcherTargets = listInBlock(
yamlBlock(frontmatter(dispatcher), 'dispatch-workflow'),
'workflows',
);

it('wires both workers and keeps the dependency route conservative', () => {
expect(dispatcherTargets).toEqual(
expect.arrayContaining(['squad-implement-worker', 'squad-deps-worker']),
);
expect(dispatcher).toContain('Choose `squad_deps_worker` only when **all**');
expect(dispatcher).toContain(
'Choose `squad_implement_worker` for every other task.',
);
expect(dispatcher).toContain('Never call both workers for one issue.');
expect(dispatcher).toContain(
'do not reroute it to the general worker',
);
});

it.each(fixtures)('fixture: $name -> $expectedWorkflow', fixture => {
const routed = routeFixture(fixture);
expect(routed).toBe(fixture.expectedWorkflow);
if (routed !== 'denied') {
expect(dispatcherTargets).toContain(routed);
const toolName = routed.replaceAll('-', '_');
expect(dispatcher).toContain(`\`${toolName}\``);
}
});

it('fails closed for every unrecognized squadDeps value', () => {
for (const denied of [
'{"squadDeps":"ALLOW"}',
'{"squadDeps":"unexpected"}',
'{"squadDeps":true}',
'{"squadDeps":1}',
'{"squadDeps":null}',
'{"squadDeps":[]}',
'{"squadDeps":{}}',
'[]',
'not-json',
]) {
expect(dependencyConfigAllows(denied), denied).toBe(false);
}
expect(dependencyConfigAllows('{}')).toBe(true);
expect(dependencyConfigAllows('{"squadDeps":"allow"}')).toBe(true);
expect(dependencyConfigAllows('{"squadDeps":"deny"}')).toBe(false);
expect(dispatcher).toContain('Every other value, including any other string');
expect(depsWorker).toContain('Any other value -- including another string');
});

it(
'strict-compiles dispatcher targets and preserves dependency/general file boundaries',
() => {
const dispatcherSafeOutputs = compileSafeOutputs('squad');
const depsSafeOutputs = compileSafeOutputs('squad-deps-worker');
const generalSafeOutputs = compileSafeOutputs('squad-implement-worker');
const dispatch = dispatcherSafeOutputs.dispatch_workflow;
const depsPullRequest = depsSafeOutputs.create_pull_request;
const generalPullRequest = generalSafeOutputs.create_pull_request;

expect(dispatch.aw_context_workflows).toEqual(
expect.arrayContaining([
'squad-implement-worker',
'squad-deps-worker',
]),
);

const depsAllowed = depsPullRequest.allowed_files as string[];
const depsExcluded = depsPullRequest.excluded_files as string[];
const depsProtected = depsPullRequest.protected_files as string[];
const generalProtected = generalPullRequest.protected_files as string[];
expect(depsAllowed).toEqual(
expect.arrayContaining(['package.json', 'package-lock.json', 'go.mod', 'go.sum']),
);
expect(depsExcluded).toEqual(
expect.arrayContaining([
'node_modules/**',
'vendor/**',
'.github/workflows/**',
'.squad/**',
]),
);
expect(depsProtected).not.toContain('package.json');
expect(depsProtected).toContain('NuGet.Config');
expect(generalProtected).toContain('package.json');
expect(generalProtected).toContain('go.mod');
},
30000,
);

it('keeps the general worker structurally unchanged', () => {
const protectedFiles = yamlBlock(frontmatter(generalWorker), 'protected-files');
expect(protectedFiles).toContain('- README.md');
expect(protectedFiles).not.toContain('- package.json');
expect(protectedFiles).not.toContain('- go.mod');
});
});
4 changes: 2 additions & 2 deletions test/gh-aw-deps-worker-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,15 +353,15 @@ describe('gh-aw squad-deps-worker S2: Wave 1 protected-files.exclude (#1748)', (
});

// ── S1 guards preserved (existing behavior) ───────────────────────────────
it('is a standalone workflow_dispatch worker, not yet wired into the dispatcher', () => {
it('is a standalone workflow_dispatch worker wired only through 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);

const dispatcherDispatch = yamlBlock(dispatcherFrontmatter, 'dispatch-workflow');
expect(listInBlock(dispatcherDispatch, 'workflows')).not.toContain('squad-deps-worker');
expect(listInBlock(dispatcherDispatch, 'workflows')).toContain('squad-deps-worker');
});

it('declares Wave 1 extensionless manifest/lockfile basenames in allowed-files', () => {
Expand Down
Loading