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
8 changes: 8 additions & 0 deletions .changeset/gh-aw-compiled-expression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

Keep the installed Squad agentic workflow valid at GitHub runtime by avoiding
JSON-escaped operators inside compiled expressions, and add an emitted-lock
verification gate to the enlistment guidance.
12 changes: 12 additions & 0 deletions .github/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ GitHub Agentic Workflows (`gh-aw`) are composable AI workflows triggered by slas

### Install the Squad workflows

> **Agent action required:** If a user asks you to install or set up Squad
> Agentic Workflows, execute the complete
> [seven-step quick start](../docs/src/content/docs/guide/gh-aw.md#quick-start)
> in their repository. Do not only quote or run the `gh aw add` command below.
> The supported path configures workflow-created pull requests, installs and
> validates the complete generated artifact set, commits it on an isolated
> branch, and opens a human-reviewed bootstrap PR. Stop at that PR; the human
> merges it. `/squad` slash commands become active only after that merge reaches
> the repository's default branch.

The quick start installs this workflow set:

```bash
gh aw add \
bradygaster/squad/workflows/squad.md@dev \
Expand Down
10 changes: 10 additions & 0 deletions .squad-templates/skills/gh-aw-enlistment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,21 @@ for workflow in squad squad-implement-worker squad-review squad-deps-worker squa
test -f ".github/workflows/${workflow}.md" || { echo "MISSING ${workflow}.md"; exit 1; }
test -f ".github/workflows/${workflow}.lock.yml" || { echo "MISSING ${workflow}.lock.yml"; exit 1; }
done

# gh-aw strict compilation can still emit a JSON-escaped operator inside a
# GitHub expression. GitHub rejects that workflow before any job starts.
if grep -nE '\$\{\{[^}]*\\u00(26|3[cCeE])' .github/workflows/*.lock.yml; then
Comment thread
bradygaster marked this conversation as resolved.
Resolved
echo "Invalid JSON-escaped operator in compiled GitHub expression" >&2
exit 1
fi
```

- **STOP** and rerun `gh aw compile --strict` if any `.lock.yml` is missing. Do not
open or merge the bootstrap PR until all **twelve** files exist and strict
compilation passes.
- **STOP** if the escaped-operator scan prints any line. Compile success alone is
insufficient: GitHub rejects these emitted expressions before creating jobs,
so the resulting failed run has no job logs to inspect.

> On Windows PowerShell, the `for`/`test -f` loop above is POSIX. Use an
> equivalent guard (e.g. `Test-Path`) or run it under Git Bash; the *logic* — all
Expand Down
59 changes: 45 additions & 14 deletions docs/src/content/docs/guide/gh-aw.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,26 @@ This guide covers setup, every slash command, and daily usage patterns.

Seven steps from zero to a validated, reviewable Squad bootstrap:

> **Agent action required:** When a user asks an AI coding agent to install Squad
> Agentic Workflows, the agent must execute this entire block in the target
> repository — not merely paste the commands for the user. The block resolves
> the repository at runtime, installs and validates the workflows, and opens the
> human-reviewed bootstrap pull request. The agent stops at the pull request;
> a human reviews and merges it.

```bash
# 1. Install the gh-aw extension (one-time)
gh extension install github/gh-aw
# 1. Verify GitHub access, resolve this repo, and install gh-aw if needed
gh auth status
owner_repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')"
default_branch="$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')"
gh extension list | grep -q 'github/gh-aw' || gh extension install github/gh-aw

# 2. Allow GitHub Actions to create pull requests
gh api --method PUT repos/{owner}/{repo}/actions/permissions/workflow \
gh api --method PUT "repos/${owner_repo}/actions/permissions/workflow" \
-f default_workflow_permissions=read \
-F can_approve_pull_request_reviews=true

# 3. Create a bootstrap branch
default_branch="$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')"
git switch -c chore/squad-gh-aw-bootstrap

# 4. Add the Squad workflows to your repo
Expand All @@ -52,6 +61,13 @@ for workflow in squad squad-implement-worker squad-review squad-deps-worker squa
test -f ".github/workflows/${workflow}.lock.yml"
done

# Strict compilation validates gh-aw's source contract; also reject JSON-escaped
# operators inside emitted GitHub expressions, which GitHub rejects before jobs start.
if grep -nE '\$\{\{[^}]*\\u00(26|3[cCeE])' .github/workflows/*.lock.yml; then
echo "Invalid JSON-escaped operator in compiled GitHub expression" >&2
exit 1
fi

# 7. Commit the generated files and open the bootstrap PR
git add -- .gitattributes .github/aw/ .github/workflows/ .github/skills/
git diff --cached --stat
Expand All @@ -71,9 +87,15 @@ gh pr checks --watch
> Downloaded workflow logs are local diagnostic output — see [ignoring downloaded
> logs](#open-the-bootstrap-pull-request) before you commit.

After the bootstrap PR is reviewed and merged, open an issue in your repo and
write `/squad cast` in the body or a comment. Squad analyzes your codebase,
composes a team of specialist agents, and opens a second PR with the result.
The bootstrap PR installs the GitHub Agentic Workflow sources, their compiled
GitHub Actions lockfiles, the Squad dispatcher and workers, shared assets, and
the skills they use. **The slash commands are not active from the bootstrap
branch alone.** After a human reviews and merges that PR into the default branch,
the `/squad` command surface is live.

Then open an issue in your repo and write `/squad cast` in the body or a comment.
Squad analyzes your codebase, composes a team of specialist agents, and opens a
second, human-reviewed Cast PR with the result.

---

Expand All @@ -99,16 +121,17 @@ You can also enable it from the command line while keeping the default workflow
token read-only:

```bash
gh api --method PUT repos/{owner}/{repo}/actions/permissions/workflow \
owner_repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')"
gh api --method PUT "repos/${owner_repo}/actions/permissions/workflow" \
-f default_workflow_permissions=read \
-F can_approve_pull_request_reviews=true
```

Replace `{owner}` and `{repo}` with the repository owner and name. Without this
setting, Squad pushes the generated branch but falls back to an issue containing
a link for you to create the pull request manually. A manually created pull
request is authored by your account, and GitHub does not allow authors to
approve their own pull requests.
Resolve the repository identity at runtime as shown; do not hardcode an example
owner or repository. Without this setting, Squad pushes the generated branch but
falls back to an issue containing a link for you to create the pull request
manually. A manually created pull request is authored by your account, and
GitHub does not allow authors to approve their own pull requests.

### Create a bootstrap branch

Expand Down Expand Up @@ -160,6 +183,12 @@ configuration and compiles the workflow definitions into deterministic
--strict` explicitly before review so every installed source is validated
together and the PR contains the exact generated lockfiles that passed.

Strict compilation is necessary but does not prove GitHub will accept every
emitted expression. The verification step also scans the lockfiles for
JSON-escaped operators such as `\u0026` inside `${{ ... }}`. If that scan finds
anything, stop: GitHub rejects that workflow before any job starts, producing a
failed run with no jobs or logs.

### Review first-install safe updates

On a clean repository, `gh aw add` reports these expected safe-update changes:
Expand Down Expand Up @@ -250,7 +279,9 @@ committed. If a `.gitignore` is missing from `.github/aw/logs/`, add one there:
!.gitignore
```

Once pushed, the `/squad` slash command is live on your repo.
Once the bootstrap PR is merged into the default branch, the `/squad` slash
command is live on your repo. Pushing the bootstrap branch or merely opening the
PR does not activate the workflow.

### Optional: pin a CLI version

Expand Down
10 changes: 10 additions & 0 deletions packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,21 @@ for workflow in squad squad-implement-worker squad-review squad-deps-worker squa
test -f ".github/workflows/${workflow}.md" || { echo "MISSING ${workflow}.md"; exit 1; }
test -f ".github/workflows/${workflow}.lock.yml" || { echo "MISSING ${workflow}.lock.yml"; exit 1; }
done

# gh-aw strict compilation can still emit a JSON-escaped operator inside a
# GitHub expression. GitHub rejects that workflow before any job starts.
if grep -nE '\$\{\{[^}]*\\u00(26|3[cCeE])' .github/workflows/*.lock.yml; then
echo "Invalid JSON-escaped operator in compiled GitHub expression" >&2
exit 1
fi
```

- **STOP** and rerun `gh aw compile --strict` if any `.lock.yml` is missing. Do not
open or merge the bootstrap PR until all **twelve** files exist and strict
compilation passes.
- **STOP** if the escaped-operator scan prints any line. Compile success alone is
insufficient: GitHub rejects these emitted expressions before creating jobs,
so the resulting failed run has no job logs to inspect.

> On Windows PowerShell, the `for`/`test -f` loop above is POSIX. Use an
> equivalent guard (e.g. `Test-Path`) or run it under Git Bash; the *logic* — all
Expand Down
10 changes: 10 additions & 0 deletions packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,21 @@ for workflow in squad squad-implement-worker squad-review squad-deps-worker squa
test -f ".github/workflows/${workflow}.md" || { echo "MISSING ${workflow}.md"; exit 1; }
test -f ".github/workflows/${workflow}.lock.yml" || { echo "MISSING ${workflow}.lock.yml"; exit 1; }
done

# gh-aw strict compilation can still emit a JSON-escaped operator inside a
# GitHub expression. GitHub rejects that workflow before any job starts.
if grep -nE '\$\{\{[^}]*\\u00(26|3[cCeE])' .github/workflows/*.lock.yml; then
echo "Invalid JSON-escaped operator in compiled GitHub expression" >&2
exit 1
fi
```

- **STOP** and rerun `gh aw compile --strict` if any `.lock.yml` is missing. Do not
open or merge the bootstrap PR until all **twelve** files exist and strict
compilation passes.
- **STOP** if the escaped-operator scan prints any line. Compile success alone is
insufficient: GitHub rejects these emitted expressions before creating jobs,
so the resulting failed run has no job logs to inspect.

> On Windows PowerShell, the `for`/`test -f` loop above is POSIX. Use an
> equivalent guard (e.g. `Test-Path`) or run it under Git Bash; the *logic* — all
Expand Down
10 changes: 10 additions & 0 deletions templates/skills/gh-aw-enlistment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,21 @@ for workflow in squad squad-implement-worker squad-review squad-deps-worker squa
test -f ".github/workflows/${workflow}.md" || { echo "MISSING ${workflow}.md"; exit 1; }
test -f ".github/workflows/${workflow}.lock.yml" || { echo "MISSING ${workflow}.lock.yml"; exit 1; }
done

# gh-aw strict compilation can still emit a JSON-escaped operator inside a
# GitHub expression. GitHub rejects that workflow before any job starts.
if grep -nE '\$\{\{[^}]*\\u00(26|3[cCeE])' .github/workflows/*.lock.yml; then
echo "Invalid JSON-escaped operator in compiled GitHub expression" >&2
exit 1
fi
```

- **STOP** and rerun `gh aw compile --strict` if any `.lock.yml` is missing. Do not
open or merge the bootstrap PR until all **twelve** files exist and strict
compilation passes.
- **STOP** if the escaped-operator scan prints any line. Compile success alone is
insufficient: GitHub rejects these emitted expressions before creating jobs,
so the resulting failed run has no job logs to inspect.

> On Windows PowerShell, the `for`/`test -f` loop above is POSIX. Use an
> equivalent guard (e.g. `Test-Path`) or run it under Git Bash; the *logic* — all
Expand Down
36 changes: 36 additions & 0 deletions test/gh-aw-enlistment-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const ROOT = resolve(__dirname, '..');
const SKILL_ID = 'gh-aw-enlistment';

const CANONICAL = `.squad-templates/skills/${SKILL_ID}/SKILL.md`;
const GUIDE = 'docs/src/content/docs/guide/gh-aw.md';
const AGENT_GUIDE = '.github/agents.md';
const MIRRORS = [
`packages/squad-cli/templates/skills/${SKILL_ID}/SKILL.md`,
`packages/squad-sdk/templates/skills/${SKILL_ID}/SKILL.md`,
Expand Down Expand Up @@ -203,6 +205,40 @@ describe('gh-aw-enlistment skill', () => {
});
});

describe('gh-aw bootstrap documentation', () => {
const guide = readLF(GUIDE);
const agentGuide = readLF(AGENT_GUIDE);

it('requires agents to execute the complete quick start through PR creation', () => {
expect(guide).toContain('**Agent action required:**');
expect(guide).toContain('the agent must execute this entire block');
expect(guide).toContain('gh pr create');
expect(agentGuide).toContain('execute the complete');
expect(agentGuide).toContain('Do not only quote or run the `gh aw add` command');
});

it('resolves the repository dynamically before changing Actions permissions', () => {
expect(guide).toContain(
'owner_repo="$(gh repo view --json nameWithOwner --jq \'.nameWithOwner\')"',
);
expect(guide).toContain(
'gh api --method PUT "repos/${owner_repo}/actions/permissions/workflow"',
);
expect(guide).not.toContain('repos/{owner}/{repo}/actions/permissions/workflow');
});

it('activates slash commands only after the bootstrap PR reaches the default branch', () => {
expect(guide).toContain(
'Once the bootstrap PR is merged into the default branch, the `/squad` slash',
);
expect(guide).toContain('Pushing the bootstrap branch or merely opening the');
expect(guide).not.toContain('Once pushed, the `/squad` slash command is live');
expect(agentGuide).toContain(
'`/squad` slash commands become active only after that merge reaches',
);
});
});

describe('template mirror parity', () => {
for (const mirror of MIRRORS) {
it(`${mirror} is byte-for-byte identical to the canonical source`, () => {
Expand Down
46 changes: 46 additions & 0 deletions test/gh-aw-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,52 @@ describe('gh-aw: compiled workflow shell input security contract', () => {
expect(compiled).not.toMatch(/<!-- squad-[\w-]+(?:-v\d+)? -->/);
}, 20000);

it('keeps safe-output expressions valid after JSON serialization', () => {
const compiled = lockText();
// The JSON string is itself YAML-escaped, so the emitted file contains
// two backslashes before the unicode escape that GitHub misparses.
const escapedOperators = [
...compiled.matchAll(/\$\{\{[^}\n]*\\\\u(?:0026|003c|003e)[^}\n]*\}\}/gi),
].map(match => match[0]);

expect(
escapedOperators,
'gh-aw serialized a JSON-escaped operator inside a GitHub expression. ' +
'GitHub rejects the emitted lockfile before any job starts.',
).toEqual([]);
expect(compiled).toContain(
"activationComments\\\":\\\"${{ !(startsWith(github.event.comment.body, '/squad approve-improvement') || startsWith(github.event.comment.body, '/squad revoke-improvement')) }}",
);
}, 20000);

it('detects the previous activation-comments serialization mutation', () => {
const source = readText(SQUAD_WORKFLOW);
const safeExpression =
"${{ !(startsWith(github.event.comment.body, '/squad approve-improvement') || " +
"startsWith(github.event.comment.body, '/squad revoke-improvement')) }}";
const unsafeExpression =
"${{ !startsWith(github.event.comment.body, '/squad approve-improvement') && " +
"!startsWith(github.event.comment.body, '/squad revoke-improvement') }}";

expect(source).toContain(safeExpression);
const workspace = createTestWorkspace('gh-aw-expression-mutation-');
execFileSync('git', ['init', '--quiet'], { cwd: workspace });
cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true });
const mutantPath = join(workspace, '.github', 'workflows', 'squad.md');
writeFileSync(mutantPath, readText(mutantPath).replace(safeExpression, unsafeExpression));

execFileSync('gh', ['aw', 'compile', mutantPath, '--strict', '--approve'], {
cwd: workspace,
encoding: 'utf8',
stdio: 'pipe',
});

const mutantLock = readText(join(workspace, '.github', 'workflows', 'squad.lock.yml'));
expect(mutantLock).toMatch(
/\$\{\{[^}\n]*\\\\u0026\\\\u0026[^}\n]*\}\}/,
);
}, 20000);

it('compiles the lifecycle upsert as a post-safe-output job (#1916)', () => {
const compiled = lockText();
expect(compiled).toContain(' upsert_lifecycle_state:');
Expand Down
2 changes: 1 addition & 1 deletion workflows/squad.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ safe-outputs:
allowed-domains:
- learn.microsoft.com
- aspire.dev
activation-comments: ${{ !startsWith(github.event.comment.body, '/squad approve-improvement') && !startsWith(github.event.comment.body, '/squad revoke-improvement') }}
activation-comments: ${{ !(startsWith(github.event.comment.body, '/squad approve-improvement') || startsWith(github.event.comment.body, '/squad revoke-improvement')) }}
messages:
append-only-comments: true
run-success: "🤖 [{workflow_name}]({run_url}) finished processing. This completion message does not indicate Cast success. For Cast, only a linked Cast pull request indicates success."
Expand Down
Loading