From c4438a20d0452571ecd4eb886f57b3db18eddfe1 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Wed, 26 Aug 2026 14:16:12 -0700 Subject: [PATCH 1/4] fix(gh-aw): use standalone activation Closes #1895 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-cli-pin-drift.yml | 84 ++++++++++------- .github/workflows/squad-release.yml | 24 +++++ .../docs/features/standalone-install.md | 11 ++- docs/src/content/docs/guide/gh-aw.md | 8 +- scripts/bump-activation-pin.mjs | 37 ++++---- test/gh-aw-quality.test.ts | 21 +++-- test/squad-cli-pin.test.ts | 89 ++++++++++--------- test/standalone-release-workflow.test.ts | 8 ++ workflows/shared/squad.md | 51 +++++++---- 9 files changed, 209 insertions(+), 124 deletions(-) diff --git a/.github/workflows/squad-cli-pin-drift.yml b/.github/workflows/squad-cli-pin-drift.yml index fc491a4dc..b0915ded8 100644 --- a/.github/workflows/squad-cli-pin-drift.yml +++ b/.github/workflows/squad-cli-pin-drift.yml @@ -1,27 +1,17 @@ -# Squad CLI pin drift guard (#1825) +# Squad standalone release pin drift guard (#1825) # -# `workflows/shared/squad.md` pins the Squad CLI version that new-repo activation -# installs. That pin decays silently with every npm release: it was introduced -# correct on 2026-08-07 referencing 0.11.0, 0.12.0 published on 2026-08-13, and the -# pin then sat stale for 8 days with nobody touching it. `git log -S` shows the line -# had been modified exactly once, ever — at introduction. It was caught only because -# an agent happened to run a cold start against a throwaway repo (fixed in PR #1818). +# `workflows/shared/squad.md` pins the GitHub Release bundle that new-repo +# activation installs. The pin is usable only when that release carries every +# standalone platform asset and SHA256SUMS.txt. # -# The pin itself is correct and stays: activation runs on an unrestricted-network job -# and hands state to the sandboxed agent job, so a bad point release mid-hop is -# expensive to debug. The absence of a drift mechanism was the defect, not the pin. -# -# This job compares the pin against the **published** npm dist-tag. It deliberately -# does NOT read `packages/squad-cli/package.json`: that is the next unreleased version -# (0.13.0 at time of writing) and resolves to E404 on npm, so automating from it would -# drive straight into the failure PR #1818 just fixed. A guard that reads the same -# source as the thing it guards is decoration (.squad/decisions.md:604) — so the two -# sources here are independent by construction. +# This job compares the pin against the latest public GitHub Release and verifies +# the exact assets consumed by scripts/install.sh. npm publication is deliberately +# irrelevant: activation downloads the self-contained bundle from GitHub Releases. # # On drift it opens an issue rather than only failing, so the alert arrives with a fix # path attached instead of an unexplained red run. -name: Squad CLI Pin Drift +name: Squad Standalone Release Pin Drift on: schedule: @@ -38,7 +28,7 @@ concurrency: jobs: check-pin: - name: Compare activation pin against published npm release + name: Compare activation pin against standalone GitHub release runs-on: ubuntu-latest permissions: contents: read @@ -47,11 +37,11 @@ jobs: - name: Checkout uses: actions/checkout@v6.0.0 - - name: Compare pin against npm dist-tag + - name: Compare pin against latest standalone release id: compare env: PIN_FILE: workflows/shared/squad.md - NPM_PACKAGE: '@bradygaster/squad-cli' + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -65,23 +55,46 @@ jobs: exit 1 fi - latest="$(npm view "$NPM_PACKAGE" dist-tags.latest 2>/dev/null || true)" + case "$pinned" in + v*) ;; + *) pinned="v$pinned" ;; + esac + + release_json="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest")" + latest="$(jq -r '.tag_name // empty' <<< "$release_json")" if [ -z "$latest" ]; then - echo "::error::Could not read dist-tags.latest for $NPM_PACKAGE from npm." + echo "::error::Could not resolve the latest GitHub Release tag." exit 1 fi + published_assets="$(jq -r '.assets[].name' <<< "$release_json")" + missing="" + for asset in \ + "squad-linux-x64.tar.gz" \ + "squad-linux-arm64.tar.gz" \ + "squad-darwin-x64.tar.gz" \ + "squad-darwin-arm64.tar.gz" \ + "squad-win32-x64.zip" \ + "squad-win32-arm64.zip" \ + "SHA256SUMS.txt"; do + if ! grep -Fxq "$asset" <<< "$published_assets"; then + missing="${missing:+${missing}, }${asset}" + fi + done + echo "pinned=$pinned" >> "$GITHUB_OUTPUT" echo "latest=$latest" >> "$GITHUB_OUTPUT" + echo "missing=$missing" >> "$GITHUB_OUTPUT" echo "Pinned in $PIN_FILE : $pinned" - echo "Published npm latest : $latest" + echo "Latest GitHub Release : $latest" + echo "Missing assets : ${missing:-}" - if [ "$pinned" = "$latest" ]; then + if [ "$pinned" = "$latest" ] && [ -z "$missing" ]; then echo "drift=false" >> "$GITHUB_OUTPUT" - echo "✅ Activation pin matches the published release." + echo "✅ Activation pin matches a complete standalone release." else echo "drift=true" >> "$GITHUB_OUTPUT" - echo "::warning::Activation pin ($pinned) is behind the published release ($latest)." + echo "::warning::Activation pin ($pinned) does not match a complete latest standalone release ($latest; missing: ${missing:-none})." fi - name: Open drift issue @@ -90,6 +103,7 @@ jobs: GH_TOKEN: ${{ github.token }} PINNED: ${{ steps.compare.outputs.pinned }} LATEST: ${{ steps.compare.outputs.latest }} + MISSING: ${{ steps.compare.outputs.missing }} TITLE: 'Squad CLI activation pin is behind the published release' run: | set -euo pipefail @@ -115,19 +129,20 @@ jobs: # `test/squad-cli-pin.test.ts` asserts no single-quoted literal here contains a # `$name` expansion -- so this suppression cannot mask a genuine SC2016 defect. { - printf '%s\n\n' 'The Squad CLI version pinned for new-repo activation is behind the published npm release.' + printf '%s\n\n' 'The standalone GitHub Release pinned for new-repo activation is stale or incomplete.' printf '%s\n' '| | Version |' printf '%s\n' '|---|---|' printf '| Pinned in `workflows/shared/squad.md` | `%s` |\n' "$PINNED" - printf '| Published npm `dist-tags.latest` | `%s` |\n\n' "$LATEST" - printf 'New-repo activation installs `%s`, so anything that shipped in `%s` is missing from a cold start.\n\n' "$PINNED" "$LATEST" + printf '| Latest GitHub Release | `%s` |\n\n' "$LATEST" + printf 'Missing standalone assets: `%s`.\n\n' "${MISSING:-none}" + printf 'New-repo activation downloads `%s` from GitHub Releases; npm publication is not part of this gate.\n\n' "$PINNED" printf '%s\n\n' '### Fix' - printf 'Update **both** literals in `workflows/shared/squad.md` to `%s`:\n\n' "$LATEST" + printf 'Publish the complete standalone asset set for `%s`, then update the activation pin if needed:\n\n' "$LATEST" printf '%s\n' '1. the `Default is .` line in the header comment' printf '%s\n\n' '2. the `SQUAD_CLI_VERSION` fallback literal in the activation `env:` block' printf '%s\n\n' '`test/squad-cli-pin.test.ts` fails if those two disagree, so they cannot be updated by halves.' - printf '%s\n' '> Do **not** copy the version from the CLI package manifest in this repo. That is the' - printf '%s\n\n' '> next *unreleased* version and resolves to E404 on npm — the exact failure PR #1818 fixed.' + printf '%s\n' '> Do **not** use npm package metadata as the activation gate. The installer consumes' + printf '%s\n\n' '> GitHub Release assets, and the release must contain every platform bundle plus checksums.' printf '%s\n' 'Filed automatically by `.github/workflows/squad-cli-pin-drift.yml` (#1825).' } > "$RUNNER_TEMP/drift-body.md" @@ -138,10 +153,11 @@ jobs: env: PINNED: ${{ steps.compare.outputs.pinned }} LATEST: ${{ steps.compare.outputs.latest }} + MISSING: ${{ steps.compare.outputs.missing }} run: | # Values pass through env rather than expression interpolation, per the # shell-input contract in workflows/squad.md: interpolation splices text into # the script before the shell parses it, so the quoting that would contain a # hostile value never gets a chance to apply. - echo "::error::Activation pin $PINNED != published $LATEST. See the issue filed by the previous step." + echo "::error::Activation pin $PINNED does not match complete release $LATEST (missing: ${MISSING:-none}). See the issue filed by the previous step." exit 1 diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml index 20b6dfab6..7a04b69d5 100644 --- a/.github/workflows/squad-release.yml +++ b/.github/workflows/squad-release.yml @@ -3,6 +3,12 @@ name: Squad Release on: push: branches: [main] + workflow_dispatch: + inputs: + confirm_tag: + description: 'Type the exact release tag from package.json (for example v0.13.1)' + required: true + type: string concurrency: group: ${{ github.workflow }} @@ -26,6 +32,24 @@ jobs: with: node-version: 22 + - name: Validate manual release request + if: github.event_name == 'workflow_dispatch' + env: + CONFIRM_TAG: ${{ inputs.confirm_tag }} + RELEASE_REF: ${{ github.ref }} + run: | + set -euo pipefail + expected_tag="v$(node -e "console.log(require('./package.json').version)")" + if [ "${RELEASE_REF}" != "refs/heads/dev" ]; then + echo "::error::Manual standalone releases must be dispatched from dev, not ${RELEASE_REF}." + exit 1 + fi + if [ "${CONFIRM_TAG}" != "${expected_tag}" ]; then + echo "::error::confirm_tag must be ${expected_tag}, got ${CONFIRM_TAG}." + exit 1 + fi + echo "Confirmed manual standalone release ${expected_tag} from dev." + - name: Run tests run: node --test test/*.test.cjs diff --git a/docs/src/content/docs/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md index a3344651f..61c53f437 100644 --- a/docs/src/content/docs/features/standalone-install.md +++ b/docs/src/content/docs/features/standalone-install.md @@ -35,7 +35,7 @@ release `SHA256SUMS.txt`, unpacks it into `$PREFIX/lib/squad`, and symlinks ```sh # pin a version and install somewhere specific curl -fsSL https://raw.githubusercontent.com/bradygaster/squad/dev/scripts/install.sh \ - | VERSION="v0.11.0" PREFIX="$HOME/tools" sh + | VERSION="v0.13.1" PREFIX="$HOME/tools" sh ``` ### Windows @@ -123,12 +123,17 @@ The bundles are what make an npm-free CI job possible — including the [gh-aw](/features/gh-aw/) activation job, which previously required `npx` and so could not run on a runner without npm registry access. +The release workflow still uses npm at bundle-build time because there is no +practical npm-free way to assemble the dependency tree. That workflow uses the +GitHub-hosted runner's normal registry configuration. The Microsoft npm proxy is +for local development only and must not be configured in GitHub Actions. + The `squad-init` action wraps the install and init steps: ```yaml - uses: bradygaster/squad/.github/actions/squad-init@ with: - version: v0.11.0 # default: latest release + version: v0.13.1 # default: latest release preset: default state-backend: local ``` @@ -139,7 +144,7 @@ Point `repository:` at an internal mirror if your runners cannot reach ```yaml - name: Install Squad env: - SQUAD_VERSION: v0.11.0 + SQUAD_VERSION: v0.13.1 run: | curl -fsSL https://raw.githubusercontent.com/bradygaster/squad/dev/scripts/install.sh \ | VERSION="${SQUAD_VERSION}" PREFIX="${HOME}/.local" sh diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index a6bd6a1f7..27043f0f1 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -147,13 +147,15 @@ Once pushed, the `/squad` slash command is live on your repo. ### Optional: pin a CLI version -Set a repository variable to control which Squad CLI version the workflow uses: +Activation downloads a self-contained GitHub Release bundle; it does not install +Squad from npm. Set a repository variable to select a specific standalone release: | Variable | Purpose | Default | |----------|---------|---------| -| `SQUAD_CLI_VERSION` | Squad CLI version to install during activation | `0.12.0` | +| `SQUAD_CLI_VERSION` | Standalone GitHub Release tag to install during activation | `v0.13.1` | -Set it in **Settings → Secrets and variables → Actions → Variables**. +Set it in **Settings → Secrets and variables → Actions → Variables**. A value +without the leading `v` is accepted for compatibility with older configurations. ### Optional: enhanced permissions with a GitHub App diff --git a/scripts/bump-activation-pin.mjs b/scripts/bump-activation-pin.mjs index 3707bb3f8..970c4363d 100644 --- a/scripts/bump-activation-pin.mjs +++ b/scripts/bump-activation-pin.mjs @@ -1,20 +1,18 @@ #!/usr/bin/env node /** - * Move the Squad CLI activation pin to a published version (#1825). + * Move the Squad CLI activation pin to a published standalone release (#1825). * - * The pin decays on a schedule nobody controls: every npm release makes it stale, - * and nothing about releasing touches it. `.github/workflows/squad-cli-pin-drift.yml` - * is the daily backstop that *notices*; this script is the half that prevents, by - * moving the pin in the same run that made the new version installable. + * `.github/workflows/squad-cli-pin-drift.yml` verifies that the pin resolves to a + * complete GitHub Release. This script keeps automated bumps compatible with the + * release-tag format consumed by scripts/install.sh. * * It is a script rather than an inline `run:` block for two reasons. The patterns * below contain backticks (the docs table) and pipes (the YAML `||` fallback), both * of which are hostile to quoting inside a workflow block scalar — and as a file it * can be exercised by `test/squad-cli-pin.test.ts` without a release. * - * Deliberately does NOT read `packages/squad-cli/package.json`. That holds the next - * *unreleased* version and resolves to E404 on npm — the exact breakage PR #1818 - * fixed. The caller supplies a version it has already proven is published. + * Deliberately does NOT read `packages/squad-cli/package.json`. The caller supplies + * a version it has already proven is published. * * Fails closed: if any pattern stops matching, the pin has moved and this script has * silently become a no-op. A bumper that cannot find its target must say so loudly, @@ -44,7 +42,7 @@ const SITES = [ { file: PIN_FILE, label: 'header comment default', - pattern: /^(#\s+Default is )[0-9][^\s.]*(?:\.[^\s.]+)*(\.\s*)$/gm, + pattern: /^(#\s+Default is )v?[0-9][^\s.]*(?:\.[^\s.]+)*(\.\s*)$/gm, }, { file: DOCS_FILE, @@ -58,17 +56,18 @@ function fail(message) { process.exit(1); } -const target = (process.env.TARGET_VERSION ?? '').trim(); +const requestedTarget = (process.env.TARGET_VERSION ?? '').trim(); -if (!target) { +if (!requestedTarget) { fail('TARGET_VERSION is not set — refusing to guess which version to pin.'); } -// Prereleases reach npm too, but activation is the cold-start path for brand-new -// repositories; pointing it at a prerelease would hand every new user an unproven -// build. `dist-tags.latest` is the stable channel, so the pin tracks stable only. -if (!/^\d+\.\d+\.\d+$/.test(target)) { - fail(`refusing to pin a non-stable version: "${target}" (expected MAJOR.MINOR.PATCH)`); +const target = requestedTarget.startsWith('v') ? requestedTarget : `v${requestedTarget}`; + +// Activation is the cold-start path for brand-new repositories. Keep it on a +// stable GitHub Release tag rather than a prerelease. +if (!/^v\d+\.\d+\.\d+$/.test(target)) { + fail(`refusing to pin a non-stable version: "${requestedTarget}" (expected vMAJOR.MINOR.PATCH)`); } const sources = new Map(); @@ -148,14 +147,14 @@ if (changed && process.env.PR_BODY_FILE) { writeFileSync( process.env.PR_BODY_FILE, [ - `Squad CLI \`${target}\` is published, so new-repo activation should install it.`, + `Squad standalone release \`${target}\` is published, so new-repo activation should install it.`, '', '| File | Site | Was |', '|---|---|---|', rows, '', - 'Opened automatically by `.github/workflows/squad-npm-publish.yml` (#1825) as part', - `of the run that published \`${target}\`.`, + 'Opened automatically after publication verification by the activation pin guard', + `for standalone release \`${target}\` (#1825).`, '', '> **This pull request has no CI.** GitHub does not fire `pull_request` workflows', '> for pull requests opened with `GITHUB_TOKEN`. The rewrite was verified in the', diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index fde5ca457..747b42d63 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1187,7 +1187,10 @@ describe('gh-aw: compiled workflow shell input security contract', () => { // real deployment layout so `squad-implement-worker` resolves as it will in // every real install. cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); - execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict'], { + // CI compiles with --approve after review. The newly approved action is + // SHA-pinned to this repository and only downloads checksum-verified release + // assets; it receives no secret input. + execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict', '--approve'], { cwd: workspace, encoding: 'utf8', stdio: 'pipe', @@ -1219,7 +1222,7 @@ 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)', () => { + it('preserves the standalone release 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*'([^']+)'/, @@ -1228,13 +1231,15 @@ describe('gh-aw: compiled workflow shell input security contract', () => { 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*\}\}`, + String.raw`name: Resolve Squad standalone release[\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}"', + 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', ); - expect(compiled).toContain('echo "$install_root/bin" >> "$GITHUB_PATH"'); + expect(compiled).toContain('version: ${{ steps.squad-release.outputs.tag }}'); + expect(compiled).toContain("skip-init: 'true'"); + expect(compiled).not.toContain('npm install --global'); expect(compiled).not.toContain('npx --yes "@bradygaster/squad-cli@'); }, 20000); @@ -2434,7 +2439,7 @@ describe('gh-aw: shared bootstrap health-before-dispatch contract (#1605)', () = expect(healthStepIdx, 'health check must precede upload').toBeLessThan(uploadStepIdx); }); - it('health and init use the globally installed CLI selected before both steps', () => { + it('health and init use the standalone CLI selected before both steps', () => { const lines = sharedContent.split('\n'); const healthLineIdx = lines.findIndex(l => l.includes('health --json')); expect(healthLineIdx).toBeGreaterThan(-1); @@ -2442,8 +2447,10 @@ describe('gh-aw: shared bootstrap health-before-dispatch contract (#1605)', () = const healthLine = lines[healthLineIdx]; 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}"', + 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', ); + expect(sharedContent).toContain('version: ${{ steps.squad-release.outputs.tag }}'); + expect(sharedContent).not.toContain('npm install --global'); expect(sharedContent).not.toContain('npx --yes "@bradygaster/squad-cli@'); }); diff --git a/test/squad-cli-pin.test.ts b/test/squad-cli-pin.test.ts index d5dad8794..55b171e8c 100644 --- a/test/squad-cli-pin.test.ts +++ b/test/squad-cli-pin.test.ts @@ -1,5 +1,5 @@ /** - * Squad CLI activation pin consistency (#1825) + * Squad standalone release activation pin consistency (#1825) * * `workflows/shared/squad.md` states the pinned CLI version twice: once as prose in * the header comment that documents `vars.SQUAD_CLI_VERSION`, and once as the literal @@ -9,17 +9,16 @@ * trying to verify the pin. * * This suite is the offline half of the guard. The online half - * (`.github/workflows/squad-cli-pin-drift.yml`) compares the pin against npm's - * published `dist-tags.latest` on a schedule; it cannot run here because it needs the - * network, and a network call in the unit suite fails closed on an offline machine. + * (`.github/workflows/squad-cli-pin-drift.yml`) compares the pin against the latest + * complete standalone GitHub Release; it cannot run here because it needs the network, + * and a network call in the unit suite fails closed on an offline machine. * * Split that way, each half checks something the other structurally cannot: * - here: the file agrees with itself, on every PR, deterministically * - there: the file agrees with reality, daily * - * Neither reads `packages/squad-cli/package.json`. That holds the next *unreleased* - * version — 0.13.0 while this was written, which resolves to E404 on npm — so deriving - * the pin from it reproduces the exact breakage PR #1818 fixed. + * Neither reads `packages/squad-cli/package.json`. That can hold the next unreleased + * version, while activation requires a public release carrying standalone assets. */ import { describe, it, expect } from 'vitest'; @@ -62,10 +61,10 @@ function effectivePin(source: string): string | undefined { /** The version quoted in the header comment that documents the default. */ function documentedPin(source: string): string | undefined { - return source.match(/^#\s*Default is ([0-9][^\s.]*(?:\.[^\s.]+)*)\.\s*$/m)?.[1]; + return source.match(/^#\s*Default is (v?[0-9][^\s.]*(?:\.[^\s.]+)*)\.\s*$/m)?.[1]; } -describe('Squad CLI activation pin (#1825)', () => { +describe('Squad standalone release activation pin (#1825)', () => { it('states a resolvable pin', () => { const pin = effectivePin(readPinFile()); @@ -73,7 +72,7 @@ describe('Squad CLI activation pin (#1825)', () => { // stopped matching too — and it would report "no drift" against a pin it never // found. Failing here makes that visible on the PR that reshapes the line. expect(pin, 'could not locate the SQUAD_CLI_VERSION fallback literal').toBeDefined(); - expect(pin).toMatch(/^\d+\.\d+\.\d+/); + expect(pin).toMatch(/^v\d+\.\d+\.\d+$/); }); it('documents the same version it resolves', () => { @@ -83,43 +82,41 @@ describe('Squad CLI activation pin (#1825)', () => { expect(documentedPin(source)).toBe(effectivePin(source)); }); - it('does not carry a second, unreachable default', () => { + it('normalizes legacy version overrides and never installs through npm', () => { const source = readPinFile(); - const pin = effectivePin(source); - // `SQUAD_CLI_VERSION` is set from `vars.X || ''`, which always yields a - // non-empty string, so a shell `${SQUAD_CLI_VERSION:-}` fallback can never - // fire. It is unreachable code whose only real effect is to be a third place the - // version can go stale — and being unreachable, it goes stale invisibly. - expect(source).not.toMatch(/\$\{SQUAD_CLI_VERSION:-/); - - expect(source).not.toContain('npx --yes "@bradygaster/squad-cli@'); expect(source).toContain( - 'npm install --global --prefix "$install_root" "@bradygaster/squad-cli@${SQUAD_CLI_VERSION}"', + 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', ); - expect(source).not.toContain(`squad-cli@${pin}`); + expect(source).toContain('*) release_tag="v${release_tag}" ;;'); + expect(source).not.toMatch(/\bnpm (?:install|ci|view)\b/); + expect(source).not.toContain('npx --yes'); }); - it('binds the selected version on the install step gh-aw preserves (#1884)', () => { + it('binds the selected release to the standalone action gh-aw preserves (#1884)', () => { const source = readPinFile(); + const resolveStep = source.match( + /- name: Resolve Squad standalone release[\s\S]*?(?=\n\s+- name:)/, + )?.[0]; const installStep = source.match( - /- name: Install Squad CLI[\s\S]*?(?=\n\s+- name:)/, + /- name: Install Squad CLI from standalone release[\s\S]*?(?=\n\s+- name:)/, )?.[0]; - expect(installStep, 'could not locate the Squad CLI install step').toBeDefined(); - expect(installStep).toMatch( + expect(resolveStep, 'could not locate the Squad release resolver').toBeDefined(); + expect(resolveStep).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, 'could not locate the standalone Squad install step').toContain( + 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', ); - expect(installStep).toContain('echo "$install_root/bin" >> "$GITHUB_PATH"'); + expect(installStep).toContain('version: ${{ steps.squad-release.outputs.tag }}'); + expect(installStep).toContain("skip-init: 'true'"); 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', () => { + it('keeps the drift guard pointed at complete GitHub Release assets', () => { const workflow = readFileSync(DRIFT_WORKFLOW, 'utf8'); // Comments are stripped first. The header comment names the trap deliberately, to @@ -130,11 +127,22 @@ describe('Squad CLI activation pin (#1825)', () => { .filter((l) => !l.trimStart().startsWith('#')) .join('\n'); - // The trap this issue was filed to avoid: the CLI package manifest holds the next - // unreleased version, so a guard reading it would compare the repo to itself and - // then "fix" the pin to something npm cannot install. + // Activation consumes release assets, not a package registry or the next in-repo + // manifest version. expect(executable).not.toMatch(/packages\/squad-cli\/package\.json/); - expect(executable).toMatch(/dist-tags\.latest/); + expect(executable).not.toMatch(/dist-tags\.latest|npm view/); + expect(executable).toContain('releases/latest'); + for (const asset of [ + 'squad-linux-x64.tar.gz', + 'squad-linux-arm64.tar.gz', + 'squad-darwin-x64.tar.gz', + 'squad-darwin-arm64.tar.gz', + 'squad-win32-x64.zip', + 'squad-win32-arm64.zip', + 'SHA256SUMS.txt', + ]) { + expect(executable).toContain(asset); + } }); it('passes values into shell through env, not expression interpolation', () => { @@ -195,7 +203,7 @@ describe('Squad CLI activation pin (#1825)', () => { const docs = readFileSync(DOCS_FILE, 'utf8'); // A third copy of the version, in the page that tells people what activation - // installs. It is outside the workflow file, so neither the drift guard's npm + // installs. It is outside the workflow file, so neither the drift guard's release // comparison nor the two checks above ever looked at it — it could sit stale // indefinitely while every other guard reported green. const documented = docs.match( @@ -227,13 +235,11 @@ describe('Squad CLI activation pin (#1825)', () => { expect(output).toContain('already'); }); - it('wires the bump into the run that publishes', () => { + it('keeps the existing release bump automation compatible with the standalone pin', () => { const workflow = readFileSync(PUBLISH_WORKFLOW, 'utf8'); - // Prevention has to be attached to publishing itself. If the bump job is dropped, - // or stops depending on the publish that makes the version installable, the pin - // goes back to decaying silently and only the daily backstop notices — a day late, - // as a red build. + // Stable npm publication may still open the pin PR, but the rewrite emits a + // GitHub Release tag. The online drift guard independently rejects missing assets. expect(workflow).toMatch(/bump-activation-pin:/); expect(workflow).toMatch(/needs:\s*publish-cli/); expect(workflow).toContain('scripts/bump-activation-pin.mjs'); @@ -254,9 +260,8 @@ describe('Squad CLI activation pin (#1825)', () => { }) .join('\n'); - // Same trap as the drift guard: `packages/squad-cli/package.json` holds the next - // unreleased version, so a bumper reading it would pin activation to something npm - // cannot install — reproducing PR #1818's breakage automatically, every release. + // The in-repo manifest may be ahead of the standalone GitHub Release. expect(executable).not.toMatch(/packages\/squad-cli\/package\.json/); + expect(executable).toContain("requestedTarget.startsWith('v')"); }); }); diff --git a/test/standalone-release-workflow.test.ts b/test/standalone-release-workflow.test.ts index 46ecb1f19..3ea1608df 100644 --- a/test/standalone-release-workflow.test.ts +++ b/test/standalone-release-workflow.test.ts @@ -16,6 +16,14 @@ const release = readFileSync(join(WORKFLOWS, 'squad-release.yml'), 'utf8'); const standalone = readFileSync(join(WORKFLOWS, 'squad-standalone-release.yml'), 'utf8'); describe('standalone release handoff', () => { + it('supports a confirmation-gated manual release from dev only', () => { + expect(release).toMatch(/workflow_dispatch:\r?\n\s+inputs:\r?\n\s+confirm_tag:/); + expect(release).toContain("if: github.event_name == 'workflow_dispatch'"); + expect(release).toContain('RELEASE_REF: ${{ github.ref }}'); + expect(release).toContain('if [ "${RELEASE_REF}" != "refs/heads/dev" ]; then'); + expect(release).toContain('if [ "${CONFIRM_TAG}" != "${expected_tag}" ]; then'); + }); + it('calls the reusable bundle workflow after creating a release', () => { expect(release).toMatch(/release:\r?\n\s+runs-on:[\s\S]*?\s+outputs:/); expect(release).toContain("created: ${{ steps.check_tag.outputs.exists == 'false' }}"); diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index 8756cb7f2..fd0928b1d 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -17,8 +17,8 @@ # # The Squad CLI is never installed or executed in the agent job — only the files it # produces (`.squad/` team state and `.github/agents/squad.agent.md`) are restored -# there. This is deliberate: gh-aw's network firewall only constrains the agent job, -# so the npm install and initialization happen in the unrestricted activation job. +# there. The activation job downloads a self-contained GitHub Release bundle, runs +# initialization, and hands the resulting state to the network-constrained agent job. # # Usage (as an import in your gh-aw workflow): # imports: @@ -49,9 +49,9 @@ # # Optional custom Squad CLI version: # vars.SQUAD_CLI_VERSION -# 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. +# Default is v0.13.1. +# This is a GitHub Release tag whose standalone assets are installed without npm. +# Values without a leading `v` are normalized for compatibility with older configs. # # Optional model override: # vars.SQUAD_MODEL @@ -82,18 +82,29 @@ jobs: private-key: ${{ secrets.SQUAD_GITHUB_APP_PRIVATE_KEY }} owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - - name: Install Squad CLI - id: squad-cli + - name: Resolve Squad standalone release + id: squad-release env: - SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || '0.12.0' }} + SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || 'v0.13.1' }} 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" + release_tag="${SQUAD_CLI_VERSION}" + case "${release_tag}" in + v*) ;; + *) release_tag="v${release_tag}" ;; + esac + if ! echo "${release_tag}" | LC_ALL=C grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.-]*$'; then + echo "::error::SQUAD_CLI_VERSION must be a semver release tag (for example v0.13.1)." + exit 1 + fi + echo "tag=${release_tag}" >> "$GITHUB_OUTPUT" + + - name: Install Squad CLI from standalone release + id: squad-cli + uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 + with: + version: ${{ steps.squad-release.outputs.tag }} + skip-init: 'true' - name: Initialize Squad team env: @@ -116,6 +127,14 @@ jobs: squad init --preset default --state-backend local fi + - name: Verify npm-free Squad state wiring + run: | + set -euo pipefail + if [ -f .mcp.json ] && grep -q '"npx"' .mcp.json; then + echo "::error::.mcp.json references npx; expected the standalone Squad launcher." + exit 1 + fi + - name: Run Squad health check env: GH_TOKEN: ${{ steps.squad-app-token.outputs.token || secrets.SQUAD_GITHUB_TOKEN || github.token }} @@ -158,13 +177,13 @@ 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), installs the selected published Squad CLI globally, + uses a supplied PAT), downloads the selected standalone GitHub Release bundle, 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. + the activation job without contacting an npm registry. 2. **`steps:`** (agent job) — downloads the `squad-state` artifact and restores it into the workspace. The Squad CLI is never installed here; only the files it From 21406df25bdf562213af524b01f856a8ee542fd2 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Wed, 26 Aug 2026 14:23:01 -0700 Subject: [PATCH 2/4] test(gh-aw): match compiled boolean quoting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-quality.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 747b42d63..2d7bf87a8 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1238,7 +1238,7 @@ describe('gh-aw: compiled workflow shell input security contract', () => { 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', ); expect(compiled).toContain('version: ${{ steps.squad-release.outputs.tag }}'); - expect(compiled).toContain("skip-init: 'true'"); + expect(compiled).toContain('skip-init: "true"'); expect(compiled).not.toContain('npm install --global'); expect(compiled).not.toContain('npx --yes "@bradygaster/squad-cli@'); }, 20000); From 7c54bdf294e7891490e553c6062c4da03003f43a Mon Sep 17 00:00:00 2001 From: brady gaster Date: Wed, 26 Aug 2026 14:24:06 -0700 Subject: [PATCH 3/4] fix(gh-aw): address graduation review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-cli-pin-drift.yml | 4 ++-- .github/workflows/squad-release.yml | 2 +- test/squad-cli-pin.test.ts | 2 ++ workflows/shared/squad.md | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/squad-cli-pin-drift.yml b/.github/workflows/squad-cli-pin-drift.yml index b0915ded8..e264d154c 100644 --- a/.github/workflows/squad-cli-pin-drift.yml +++ b/.github/workflows/squad-cli-pin-drift.yml @@ -35,7 +35,7 @@ jobs: issues: write steps: - name: Checkout - uses: actions/checkout@v6.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 - name: Compare pin against latest standalone release id: compare @@ -104,7 +104,7 @@ jobs: PINNED: ${{ steps.compare.outputs.pinned }} LATEST: ${{ steps.compare.outputs.latest }} MISSING: ${{ steps.compare.outputs.missing }} - TITLE: 'Squad CLI activation pin is behind the published release' + TITLE: 'Squad standalone activation pin is behind the published release' run: | set -euo pipefail diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml index 7a04b69d5..2c129ef30 100644 --- a/.github/workflows/squad-release.yml +++ b/.github/workflows/squad-release.yml @@ -39,7 +39,7 @@ jobs: RELEASE_REF: ${{ github.ref }} run: | set -euo pipefail - expected_tag="v$(node -e "console.log(require('./package.json').version)")" + expected_tag="v$(node -p 'require("./package.json").version')" if [ "${RELEASE_REF}" != "refs/heads/dev" ]; then echo "::error::Manual standalone releases must be dispatched from dev, not ${RELEASE_REF}." exit 1 diff --git a/test/squad-cli-pin.test.ts b/test/squad-cli-pin.test.ts index 55b171e8c..8ed8e1d39 100644 --- a/test/squad-cli-pin.test.ts +++ b/test/squad-cli-pin.test.ts @@ -89,6 +89,7 @@ describe('Squad standalone release activation pin (#1825)', () => { 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', ); expect(source).toContain('*) release_tag="v${release_tag}" ;;'); + expect(source).toContain("grep -qE '^v[0-9]+\\.[0-9]+\\.[0-9]+$'"); expect(source).not.toMatch(/\bnpm (?:install|ci|view)\b/); expect(source).not.toContain('npx --yes'); }); @@ -132,6 +133,7 @@ describe('Squad standalone release activation pin (#1825)', () => { expect(executable).not.toMatch(/packages\/squad-cli\/package\.json/); expect(executable).not.toMatch(/dist-tags\.latest|npm view/); expect(executable).toContain('releases/latest'); + expect(executable).toContain('Squad standalone activation pin is behind the published release'); for (const asset of [ 'squad-linux-x64.tar.gz', 'squad-linux-arm64.tar.gz', diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index fd0928b1d..30749abb2 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -93,7 +93,7 @@ jobs: v*) ;; *) release_tag="v${release_tag}" ;; esac - if ! echo "${release_tag}" | LC_ALL=C grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.-]*$'; then + if ! echo "${release_tag}" | LC_ALL=C grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::error::SQUAD_CLI_VERSION must be a semver release tag (for example v0.13.1)." exit 1 fi From d35ed3b37d0b1c271f86462489ac2a2460842cfd Mon Sep 17 00:00:00 2001 From: brady gaster Date: Wed, 26 Aug 2026 14:30:19 -0700 Subject: [PATCH 4/4] docs(gh-aw): clarify release guard wording Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-cli-pin-drift.yml | 2 +- .github/workflows/squad-release.yml | 2 +- test/squad-cli-pin.test.ts | 2 +- test/standalone-release-workflow.test.ts | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/squad-cli-pin-drift.yml b/.github/workflows/squad-cli-pin-drift.yml index e264d154c..147a1abcc 100644 --- a/.github/workflows/squad-cli-pin-drift.yml +++ b/.github/workflows/squad-cli-pin-drift.yml @@ -104,7 +104,7 @@ jobs: PINNED: ${{ steps.compare.outputs.pinned }} LATEST: ${{ steps.compare.outputs.latest }} MISSING: ${{ steps.compare.outputs.missing }} - TITLE: 'Squad standalone activation pin is behind the published release' + TITLE: 'Squad standalone activation release is stale or incomplete' run: | set -euo pipefail diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml index 2c129ef30..42cc4c7ab 100644 --- a/.github/workflows/squad-release.yml +++ b/.github/workflows/squad-release.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: confirm_tag: - description: 'Type the exact release tag from package.json (for example v0.13.1)' + description: 'Type v followed by the package.json version (for example v0.13.1)' required: true type: string diff --git a/test/squad-cli-pin.test.ts b/test/squad-cli-pin.test.ts index 8ed8e1d39..87f111092 100644 --- a/test/squad-cli-pin.test.ts +++ b/test/squad-cli-pin.test.ts @@ -133,7 +133,7 @@ describe('Squad standalone release activation pin (#1825)', () => { expect(executable).not.toMatch(/packages\/squad-cli\/package\.json/); expect(executable).not.toMatch(/dist-tags\.latest|npm view/); expect(executable).toContain('releases/latest'); - expect(executable).toContain('Squad standalone activation pin is behind the published release'); + expect(executable).toContain('Squad standalone activation release is stale or incomplete'); for (const asset of [ 'squad-linux-x64.tar.gz', 'squad-linux-arm64.tar.gz', diff --git a/test/standalone-release-workflow.test.ts b/test/standalone-release-workflow.test.ts index 3ea1608df..d0dcd4ed5 100644 --- a/test/standalone-release-workflow.test.ts +++ b/test/standalone-release-workflow.test.ts @@ -18,6 +18,9 @@ const standalone = readFileSync(join(WORKFLOWS, 'squad-standalone-release.yml'), describe('standalone release handoff', () => { it('supports a confirmation-gated manual release from dev only', () => { expect(release).toMatch(/workflow_dispatch:\r?\n\s+inputs:\r?\n\s+confirm_tag:/); + expect(release).toContain( + "description: 'Type v followed by the package.json version (for example v0.13.1)'", + ); expect(release).toContain("if: github.event_name == 'workflow_dispatch'"); expect(release).toContain('RELEASE_REF: ${{ github.ref }}'); expect(release).toContain('if [ "${RELEASE_REF}" != "refs/heads/dev" ]; then');