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
2 changes: 1 addition & 1 deletion docs/src/content/docs/guide/gh-aw.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down
40 changes: 35 additions & 5 deletions test/gh-aw-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,25 @@ describe('gh-aw: compiled workflow shell input security contract', () => {
expect(compiled).not.toMatch(/<!-- squad-[\w-]+(?:-v\d+)? -->/);
}, 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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
30 changes: 24 additions & 6 deletions test/squad-cli-pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
41 changes: 29 additions & 12 deletions workflows/shared/squad.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }}
Expand All @@ -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."
Comment on lines +123 to +127
fi

- name: Upload Squad state artifact
if: success()
Expand Down Expand Up @@ -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.

Expand Down