diff --git a/.changeset/gh-aw-compiled-expression.md b/.changeset/gh-aw-compiled-expression.md new file mode 100644 index 000000000..709d203a9 --- /dev/null +++ b/.changeset/gh-aw-compiled-expression.md @@ -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. diff --git a/.github/agents.md b/.github/agents.md index 504bd6a2a..2b7f3a134 100644 --- a/.github/agents.md +++ b/.github/agents.md @@ -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 \ diff --git a/.squad-templates/skills/gh-aw-enlistment/SKILL.md b/.squad-templates/skills/gh-aw-enlistment/SKILL.md index 63477b185..977882357 100644 --- a/.squad-templates/skills/gh-aw-enlistment/SKILL.md +++ b/.squad-templates/skills/gh-aw-enlistment/SKILL.md @@ -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 diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index ea6b440a0..01ad5a9fe 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -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 @@ -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 @@ -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. --- @@ -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 @@ -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: @@ -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 diff --git a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md index 63477b185..977882357 100644 --- a/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-cli/templates/skills/gh-aw-enlistment/SKILL.md @@ -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 diff --git a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md index 63477b185..977882357 100644 --- a/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md +++ b/packages/squad-sdk/templates/skills/gh-aw-enlistment/SKILL.md @@ -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 diff --git a/templates/skills/gh-aw-enlistment/SKILL.md b/templates/skills/gh-aw-enlistment/SKILL.md index 63477b185..977882357 100644 --- a/templates/skills/gh-aw-enlistment/SKILL.md +++ b/templates/skills/gh-aw-enlistment/SKILL.md @@ -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 diff --git a/test/gh-aw-enlistment-skill.test.ts b/test/gh-aw-enlistment-skill.test.ts index d488ece35..639acc307 100644 --- a/test/gh-aw-enlistment-skill.test.ts +++ b/test/gh-aw-enlistment-skill.test.ts @@ -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`, @@ -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`, () => { diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 2ea3d8942..42ffecd35 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1451,6 +1451,52 @@ describe('gh-aw: compiled workflow shell input security contract', () => { expect(compiled).not.toMatch(//); }, 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:'); diff --git a/workflows/squad.md b/workflows/squad.md index 35fad55aa..288753101 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -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."