diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 7230929f5..f33793cb9 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -148,7 +148,7 @@ Set a repository variable to control which Squad CLI version the workflow uses: | Variable | Purpose | Default | |----------|---------|---------| -| `SQUAD_CLI_VERSION` | Squad CLI version to install during activation | `0.14.0` | +| `SQUAD_CLI_VERSION` | Squad CLI version to install during activation | `0.12.0` | Set it in **Settings → Secrets and variables → Actions → Variables**. diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 985eede7f..fde5ca457 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1219,6 +1219,25 @@ describe('gh-aw: compiled workflow shell input security contract', () => { expect(compiled).not.toMatch(//); }, 20000); + it('preserves the published CLI selection in the compiled install step (#1884)', () => { + const compiled = lockText(); + const pin = readText(join(SHARED_DIR, 'squad.md')).match( + /SQUAD_CLI_VERSION:\s*\$\{\{\s*vars\.SQUAD_CLI_VERSION\s*\|\|\s*'([^']+)'/, + )?.[1]; + + expect(pin, 'could not locate the source Squad CLI fallback').toBeDefined(); + expect(compiled).toMatch( + new RegExp( + String.raw`name: Install Squad CLI[\s\S]*SQUAD_CLI_VERSION:\s*\$\{\{\s*vars\.SQUAD_CLI_VERSION\s*\|\|\s*'${pin}'\s*\}\}`, + ), + ); + expect(compiled).toContain( + 'npm install --global --prefix "$install_root" "@bradygaster/squad-cli@${SQUAD_CLI_VERSION}"', + ); + expect(compiled).toContain('echo "$install_root/bin" >> "$GITHUB_PATH"'); + expect(compiled).not.toContain('npx --yes "@bradygaster/squad-cli@'); + }, 20000); + it('emits no attacker-controlled event text in any compiled run: block', () => { const compiled = lockText(); const blocks = extractRunBlocks(compiled); @@ -2342,13 +2361,22 @@ describe('gh-aw: shared bootstrap health-before-dispatch contract (#1605)', () = const sharedContent = readText(join(SHARED_DIR, 'squad.md')); it('squad health --json appears in the shared bootstrap', () => { - expect(sharedContent).toMatch(/npx\s+--yes\s+"@bradygaster\/squad-cli@\$\{SQUAD_CLI_VERSION\}"\s+health\s+--json/); + expect(sharedContent).toMatch(/\bsquad\s+health\s+--json/); }); it('health uses --json flag for structured CI output', () => { expect(sharedContent).toMatch(/health --json/); }); + it('gates health on command capability until the published pin includes it (#1884)', () => { + expect(sharedContent).toContain( + "squad help | grep -Fq 'Validate team state for CI'", + ); + expect(sharedContent).toContain( + 'predates the health command; the readiness gate will activate after the next published CLI pin', + ); + }); + it('squad health --json appears after squad init (command ordering)', () => { const initIdx = sharedContent.indexOf('init --preset default'); const healthIdx = sharedContent.indexOf('health --json'); @@ -2406,15 +2434,17 @@ describe('gh-aw: shared bootstrap health-before-dispatch contract (#1605)', () = expect(healthStepIdx, 'health check must precede upload').toBeLessThan(uploadStepIdx); }); - it('health step uses the same CLI version mechanism as init', () => { + it('health and init use the globally installed CLI selected before both steps', () => { const lines = sharedContent.split('\n'); const healthLineIdx = lines.findIndex(l => l.includes('health --json')); expect(healthLineIdx).toBeGreaterThan(-1); const healthLine = lines[healthLineIdx]; - expect(healthLine, 'health must use npx').toContain('npx'); - expect(healthLine, 'health must reference squad-cli package').toContain('@bradygaster/squad-cli@'); - expect(healthLine, 'health must use the SQUAD_CLI_VERSION variable').toContain('SQUAD_CLI_VERSION'); + expect(healthLine, 'health must invoke the installed squad binary').toMatch(/\bsquad health --json/); + expect(sharedContent).toContain( + 'npm install --global --prefix "$install_root" "@bradygaster/squad-cli@${SQUAD_CLI_VERSION}"', + ); + expect(sharedContent).not.toContain('npx --yes "@bradygaster/squad-cli@'); }); it('continue-on-error on the restore step does not shield health failure from stopping dispatch', () => { diff --git a/test/squad-cli-pin.test.ts b/test/squad-cli-pin.test.ts index e61840695..d5dad8794 100644 --- a/test/squad-cli-pin.test.ts +++ b/test/squad-cli-pin.test.ts @@ -93,12 +93,30 @@ describe('Squad CLI activation pin (#1825)', () => { // version can go stale — and being unreachable, it goes stale invisibly. expect(source).not.toMatch(/\$\{SQUAD_CLI_VERSION:-/); - // Belt and braces: no *other* copy of the version string in an npx invocation. - const npxLines = source.split(/\r?\n/).filter((l) => l.includes('squad-cli@')); - for (const line of npxLines) { - expect(line, `npx line hardcodes a version instead of using the env var: ${line.trim()}`) - .not.toContain(`squad-cli@${pin}`); - } + expect(source).not.toContain('npx --yes "@bradygaster/squad-cli@'); + expect(source).toContain( + 'npm install --global --prefix "$install_root" "@bradygaster/squad-cli@${SQUAD_CLI_VERSION}"', + ); + expect(source).not.toContain(`squad-cli@${pin}`); + }); + + it('binds the selected version on the install step gh-aw preserves (#1884)', () => { + const source = readPinFile(); + const installStep = source.match( + /- name: Install Squad CLI[\s\S]*?(?=\n\s+- name:)/, + )?.[0]; + + expect(installStep, 'could not locate the Squad CLI install step').toBeDefined(); + expect(installStep).toMatch( + /env:\s*\n\s+SQUAD_CLI_VERSION:\s*\$\{\{\s*vars\.SQUAD_CLI_VERSION\s*\|\|\s*'[^']+'\s*\}\}/, + ); + expect(installStep).toContain( + 'npm install --global --prefix "$install_root" "@bradygaster/squad-cli@${SQUAD_CLI_VERSION}"', + ); + expect(installStep).toContain('echo "$install_root/bin" >> "$GITHUB_PATH"'); + expect(source).not.toMatch( + /activation:\s*\n\s+env:\s*\n\s+SQUAD_CLI_VERSION:/, + ); }); it('keeps the drift guard pointed at npm rather than the repo', () => { diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index 5aa539000..6d8e44fb6 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -48,10 +48,9 @@ # # Optional custom Squad CLI version: # vars.SQUAD_CLI_VERSION -# Default is 0.14.0. -# This is the first release containing `squad health`. The shared component -# becomes fully operational only after that release is published. -# Import this workflow only from refs that post-date that release. +# Default is 0.12.0. +# This is the latest published stable release when this workflow was authored. +# The release pipeline updates this pin only after npm publication. # # Optional model override: # vars.SQUAD_MODEL @@ -72,8 +71,6 @@ engine: jobs: activation: - env: - SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || '0.14.0' }} pre-steps: - name: Mint Squad GitHub App token id: squad-app-token @@ -84,6 +81,19 @@ jobs: private-key: ${{ secrets.SQUAD_GITHUB_APP_PRIVATE_KEY }} owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} + - name: Install Squad CLI + id: squad-cli + env: + SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || '0.12.0' }} + run: | + set -euo pipefail + install_root="${RUNNER_TEMP}/squad-cli" + npm install --global --prefix "$install_root" "@bradygaster/squad-cli@${SQUAD_CLI_VERSION}" + installed_version="$("$install_root/bin/squad" --version)" + echo "Installed Squad CLI ${installed_version} (requested ${SQUAD_CLI_VERSION})." + echo "version=${installed_version}" >> "$GITHUB_OUTPUT" + echo "$install_root/bin" >> "$GITHUB_PATH" + - name: Initialize Squad team env: GH_TOKEN: ${{ steps.squad-app-token.outputs.token || secrets.SQUAD_GITHUB_TOKEN || github.token }} @@ -102,14 +112,20 @@ jobs: echo "✓ Existing squad team detected with roster entries — skipping init." else echo "No existing squad team found — running squad init." - npx --yes "@bradygaster/squad-cli@${SQUAD_CLI_VERSION}" init --preset default --state-backend local + squad init --preset default --state-backend local fi - name: Run Squad health check env: GH_TOKEN: ${{ steps.squad-app-token.outputs.token || secrets.SQUAD_GITHUB_TOKEN || github.token }} + SQUAD_CLI_VERSION: ${{ steps.squad-cli.outputs.version }} run: | - npx --yes "@bradygaster/squad-cli@${SQUAD_CLI_VERSION}" health --json + set -euo pipefail + if squad help | grep -Fq 'Validate team state for CI'; then + squad health --json + else + echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI pin." + fi - name: Upload Squad state artifact if: success() @@ -141,10 +157,11 @@ agent sandbox: 1. **`jobs.activation.pre-steps`** — the repository is already checked out by the activation job. This step optionally mints a GitHub App installation token (or - uses a supplied PAT), checks whether `.squad/team.md` already exists with - roster entries (preserving any previously committed cast), and only runs - `squad init` if no usable team is found. It then runs `squad health --json` - and uploads the resulting `.squad/` team state plus + uses a supplied PAT), installs the selected published Squad CLI globally, + checks whether `.squad/team.md` already exists with roster entries (preserving + any previously committed cast), and only runs `squad init` if no usable team + is found. It then runs `squad health --json` when the installed release + supports it and uploads the resulting `.squad/` team state plus `.github/agents/squad.agent.md` only when readiness checks pass — all inside the activation job with unrestricted egress.