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
88 changes: 52 additions & 36 deletions .github/workflows/squad-cli-pin-drift.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -38,20 +28,20 @@ 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
issues: write
steps:
- name: Checkout
uses: actions/checkout@v6.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7

- 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

Expand All @@ -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:-<none>}"

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
Expand All @@ -90,7 +103,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
PINNED: ${{ steps.compare.outputs.pinned }}
LATEST: ${{ steps.compare.outputs.latest }}
TITLE: 'Squad CLI activation pin is behind the published release'
MISSING: ${{ steps.compare.outputs.missing }}
TITLE: 'Squad standalone activation release is stale or incomplete'
run: |
Comment thread
bradygaster marked this conversation as resolved.
set -euo pipefail

Expand All @@ -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 <version>.` 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"

Expand All @@ -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
24 changes: 24 additions & 0 deletions .github/workflows/squad-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ name: Squad Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
confirm_tag:
description: 'Type v followed by the package.json version (for example v0.13.1)'
required: true
type: string

concurrency:
group: ${{ github.workflow }}
Expand All @@ -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 -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
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

Expand Down
11 changes: 8 additions & 3 deletions docs/src/content/docs/features/standalone-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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@<sha>
with:
version: v0.11.0 # default: latest release
version: v0.13.1 # default: latest release
preset: default
state-backend: local
```
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/src/content/docs/guide/gh-aw.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 18 additions & 19 deletions scripts/bump-activation-pin.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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',
Expand Down
21 changes: 14 additions & 7 deletions test/gh-aw-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -1219,7 +1222,7 @@ 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)', () => {
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*'([^']+)'/,
Expand All @@ -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);

Expand Down Expand Up @@ -2434,16 +2439,18 @@ 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);

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@');
});

Expand Down
Loading