From 3e0a6118a0a3835ded151a3fd4b9e9cbd6c37ff3 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Mon, 24 Aug 2026 12:52:41 -0700 Subject: [PATCH] ci: move the Squad CLI activation pin when the release publishes The pin in `workflows/shared/squad.md` decides which Squad CLI a brand-new repository installs during activation. Nothing about publishing touched it, so every release silently made it stale -- it once ran 8 days behind and was caught only because someone happened to cold-start a throwaway repo (PR #1818). PR #1855 added the daily backstop that *detects* that state. This adds the half that prevents it: after `publish-cli` succeeds, the pin moves to the version that same run made installable, and the change arrives as a pull request against `dev`. Both halves are load-bearing. Detection alone leaves a red build with no fix path; prevention alone re-breaks the moment someone publishes out-of-band. Three things worth knowing: - The version is confirmed against two independent sources before anything is rewritten -- the version this run published, and npm's `dist-tags.latest`. The bumper never reads `packages/squad-cli/package.json`, which holds the next *unreleased* version and is exactly the E404 that PR #1818 fixed by hand. - There was a third copy of the pin nobody was guarding, in `docs/src/content/docs/guide/gh-aw.md`. Neither the drift guard nor the existing tests looked at it, so it could sit stale while everything reported green. It is now bumped with the other two and asserted equal on every pull request. - The rewrite is a script rather than an inline `run:` block. Its patterns contain backticks and pipes that are hostile to shell quoting -- the SC2016 problem PR #1855 had to suppress -- and as a file it can be executed by the test suite. That last point is what makes this guard non-decorative: `squad-cli-pin.test.ts` runs the real bumper against the version already pinned. That is an identity rewrite, so it touches nothing, but it proves all three patterns still match. A pattern that quietly stops matching would turn the bump into a no-op, which is the same silent decay this issue was filed about. Verified by mutation: reshaping the pin, drifting the docs copy, and retargeting the pull request at `main` each turn the suite red, and the bumper fails closed with a named error when a site goes missing or is duplicated. Closes #1825 --- .github/workflows/squad-npm-publish.yml | 114 +++++++++++++++ scripts/bump-activation-pin.mjs | 176 ++++++++++++++++++++++++ test/squad-cli-pin.test.ts | 87 ++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 scripts/bump-activation-pin.mjs diff --git a/.github/workflows/squad-npm-publish.yml b/.github/workflows/squad-npm-publish.yml index e830f4935..553a5d2dd 100644 --- a/.github/workflows/squad-npm-publish.yml +++ b/.github/workflows/squad-npm-publish.yml @@ -264,6 +264,10 @@ jobs: needs: [preflight, smoke-test, registry-check, publish-sdk] runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + # Consumed by `bump-activation-pin` so the pin moves to the version this run + # actually published, rather than to whatever npm happens to report later. + version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 with: @@ -385,6 +389,116 @@ jobs: sleep 15 done echo "Registry propagation slow but package was published successfully" + + # --- Issue #1825 ------------------------------------------------------ + # Release-time half of the activation-pin guard. + # + # The literal in `workflows/shared/squad.md` decides which Squad CLI a brand-new + # repository installs during activation. Nothing about publishing used to touch it, + # so every release silently made it stale — it once ran 8 days behind and was caught + # only because someone happened to cold-start a throwaway repo (PR #1818). + # + # `.github/workflows/squad-cli-pin-drift.yml` is the daily backstop that *detects* + # that state. This job is the half that prevents it, by moving the pin in the same + # run that made the new version installable. Both halves are load-bearing: detection + # alone leaves a red build with no fix path, and prevention alone re-breaks the + # moment someone publishes out-of-band. + bump-activation-pin: + name: Bump activation pin to the published release + needs: publish-cli + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 + with: + # The pin lives on the default branch, but releases are cut from `main`. + # Bumping a `main` checkout would edit a file `dev` never sees, and the next + # merge would quietly restore the stale value. + ref: dev + fetch-depth: 0 + + - name: Confirm the version is published and current + id: target + env: + TARGET: ${{ needs.publish-cli.outputs.version }} + NPM_PACKAGE: '@bradygaster/squad-cli' + run: | + set -euo pipefail + + if [ -z "$TARGET" ]; then + echo "::error::publish-cli reported no version — refusing to guess which version to pin." + exit 1 + fi + + # Two independent sources, which is the whole point of #1825: the version this + # run published, and what npm now serves as `latest`. Checking only the repo + # would compare the pin against itself, and the repo's own manifest holds the + # next *unreleased* version — the E404 that PR #1818 had to fix by hand. + latest="" + for attempt in 1 2 3 4 5; do + latest="$(npm view "$NPM_PACKAGE" dist-tags.latest 2>/dev/null || true)" + if [ "$latest" = "$TARGET" ]; then + break + fi + echo "dist-tags.latest is '${latest:-}', waiting for '$TARGET' (attempt $attempt/5)..." + sleep 15 + done + + if [ "$latest" != "$TARGET" ]; then + echo "::error::npm dist-tags.latest is '${latest:-}', not the published '$TARGET'. Not moving the pin to a version new repositories would not receive." + exit 1 + fi + + echo "version=$TARGET" >> "$GITHUB_OUTPUT" + echo "Confirmed $NPM_PACKAGE@$TARGET is the published latest." + + - name: Rewrite the pin + id: bump + env: + TARGET_VERSION: ${{ steps.target.outputs.version }} + PR_BODY_FILE: ${{ runner.temp }}/activation-pin-pr.md + run: node scripts/bump-activation-pin.mjs + + - name: Open pull request + if: steps.bump.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.target.outputs.version }} + PR_BODY_FILE: ${{ runner.temp }}/activation-pin-pr.md + run: | + set -euo pipefail + + # A pull request rather than a push: `dev` does not take direct pushes, and a + # version bump that lands unreviewed is exactly the kind of change that should + # be visible in history. + branch="chore/activation-pin-$TARGET" + + if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + echo "Branch $branch already exists — an earlier run already opened this bump." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" + + # Named explicitly: the checkout is a full release tree, and `git add -A` here + # would sweep in anything the publish jobs left behind. + git add workflows/shared/squad.md docs/src/content/docs/guide/gh-aw.md + git commit -m "chore: pin Squad CLI activation to $TARGET + + Refs #1825" + git push origin "$branch" + + gh pr create \ + --base dev \ + --head "$branch" \ + --title "chore: pin Squad CLI activation to $TARGET" \ + --body-file "$PR_BODY_FILE" + # --- Issues #1491 / #1497 --------------------------------------------- # After a stable release publishes to `latest`, ensure each package's # `insider` dist-tag is not left semver-lower than `latest`. Promotions run diff --git a/scripts/bump-activation-pin.mjs b/scripts/bump-activation-pin.mjs new file mode 100644 index 000000000..3707bb3f8 --- /dev/null +++ b/scripts/bump-activation-pin.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Move the Squad CLI activation pin to a published version (#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. + * + * 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. + * + * 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, + * or it reintroduces exactly the silent decay it exists to prevent. + */ + +import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; + +const PIN_FILE = 'workflows/shared/squad.md'; +const DOCS_FILE = 'docs/src/content/docs/guide/gh-aw.md'; + +/** + * Every place the pinned version is written out. All three must move together: the + * effective pin is what activation installs, and the other two are what a human reads + * when deciding whether the pin is current — a stale copy misinforms precisely the + * person trying to verify it. + * + * None of these patterns contain a literal `${`+`{` sequence, so the file stays safe + * to reference from a workflow without Actions evaluating it as an expression. + */ +const SITES = [ + { + file: PIN_FILE, + label: 'activation env fallback', + pattern: /(SQUAD_CLI_VERSION:[^\n']*\|\|\s*')[^']*(')/g, + }, + { + file: PIN_FILE, + label: 'header comment default', + pattern: /^(#\s+Default is )[0-9][^\s.]*(?:\.[^\s.]+)*(\.\s*)$/gm, + }, + { + file: DOCS_FILE, + label: 'docs default column', + pattern: /(\|\s*`SQUAD_CLI_VERSION`[^|\n]*\|[^|\n]*\|\s*`)[^`\n]*(`\s*\|)/g, + }, +]; + +function fail(message) { + console.error(`::error::${message}`); + process.exit(1); +} + +const target = (process.env.TARGET_VERSION ?? '').trim(); + +if (!target) { + 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 sources = new Map(); +for (const { file } of SITES) { + if (!sources.has(file)) sources.set(file, readFileSync(file, 'utf8')); +} + +const applied = []; +const dirty = new Set(); + +for (const { file, label, pattern } of SITES) { + const before = sources.get(file); + const matches = [...before.matchAll(pattern)]; + + if (matches.length === 0) { + fail( + `${file}: could not locate the ${label}. The pin moved or changed shape — ` + + 'update this script. A bumper that stops matching becomes a silent no-op, ' + + 'which is worse than no bumper at all.', + ); + } + if (matches.length > 1) { + fail( + `${file}: found ${matches.length} copies of the ${label}, expected exactly 1. ` + + 'Ambiguous targets mean one of them will be left stale.', + ); + } + + const previous = matches[0][0]; + const after = before.replace(pattern, (_full, head, tail) => `${head}${target}${tail}`); + + if (after !== before) { + applied.push({ file, label, previous: previous.trim() }); + dirty.add(file); + } + sources.set(file, after); +} + +// Only rewrite files that actually changed. Running this against the version already +// pinned must be a true no-op, because that is how `test/squad-cli-pin.test.ts` +// exercises the patterns on every pull request — executing the real script is the only +// check that proves all three still match, and it must not touch the working tree. +for (const file of dirty) { + writeFileSync(file, sources.get(file)); +} + +// Re-read from disk and re-extract, rather than trusting the in-memory replace. The +// failure this guards against is a pattern that matched but captured the wrong span, +// which would write a well-formed file pinned to the wrong thing. +const verified = new Map(); +for (const { file, label, pattern } of SITES) { + if (!verified.has(file)) verified.set(file, readFileSync(file, 'utf8')); + const found = [...verified.get(file).matchAll(pattern)][0]?.[0] ?? ''; + if (!found.includes(target)) { + fail(`${file}: the ${label} does not read "${target}" after rewriting: ${found.trim()}`); + } +} + +const changed = applied.length > 0; + +if (changed) { + console.log(`Pinned Squad CLI activation to ${target}:`); + for (const { file, label } of applied) console.log(` - ${file} (${label})`); +} else { + console.log(`Activation pin is already ${target} — nothing to do.`); +} + +// The pull-request body is composed here, not in the workflow. It is markdown, so it +// is full of backticks and pipes; building it with shell printf means every one of +// them is a quoting hazard, and suppressing the resulting shellcheck noise risks +// masking a genuine one. Node writes the file; the workflow only passes its path. +if (changed && process.env.PR_BODY_FILE) { + const rows = applied + .map(({ file, label, previous }) => `| \`${file}\` | ${label} | \`${previous}\` |`) + .join('\n'); + + writeFileSync( + process.env.PR_BODY_FILE, + [ + `Squad CLI \`${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}\`.`, + '', + '> **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', + '> job that produced it — `scripts/bump-activation-pin.mjs` re-reads every file it', + '> wrote and fails closed — but the usual checks will not appear below. Close and', + '> reopen this pull request to run them.', + '', + 'If this sits unmerged, `.github/workflows/squad-cli-pin-drift.yml` will file a', + 'drift issue for the same version within a day. That is the intended backstop, not', + 'a duplicate report.', + '', + ].join('\n'), + ); +} + +if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed}\n`); +} diff --git a/test/squad-cli-pin.test.ts b/test/squad-cli-pin.test.ts index 1a7e9667f..e61840695 100644 --- a/test/squad-cli-pin.test.ts +++ b/test/squad-cli-pin.test.ts @@ -24,9 +24,26 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; const PIN_FILE = join(process.cwd(), 'workflows', 'shared', 'squad.md'); +const DOCS_FILE = join( + process.cwd(), + 'docs', + 'src', + 'content', + 'docs', + 'guide', + 'gh-aw.md', +); +const BUMP_SCRIPT = join(process.cwd(), 'scripts', 'bump-activation-pin.mjs'); +const PUBLISH_WORKFLOW = join( + process.cwd(), + '.github', + 'workflows', + 'squad-npm-publish.yml', +); const DRIFT_WORKFLOW = join( process.cwd(), '.github', @@ -154,4 +171,74 @@ describe('Squad CLI activation pin (#1825)', () => { `not the value, and SC2016 is suppressed here so shellcheck will not say so:\n${offenders.join('\n')}`, ).toEqual([]); }); + + it('documents the same version in the published guide', () => { + const pin = effectivePin(readPinFile()); + 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 + // 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( + /\|\s*`SQUAD_CLI_VERSION`[^|\n]*\|[^|\n]*\|\s*`([^`\n]+)`\s*\|/, + )?.[1]; + + expect(documented, 'gh-aw guide no longer states a SQUAD_CLI_VERSION default').toBeDefined(); + expect(documented).toBe(pin); + }); + + it('can still find every place it has to rewrite', () => { + const pin = effectivePin(readPinFile()); + + // Runs the real bumper against the version already pinned. That is an identity + // rewrite, so it touches nothing — but it exercises all three patterns for real, + // which is the only way to prove they still match. A pattern that quietly stops + // matching turns the release-time bump into a no-op, and the whole failure mode + // #1825 describes is a guard that decays without saying anything. + const env = { ...process.env, TARGET_VERSION: pin as string }; + delete env.GITHUB_OUTPUT; + delete env.PR_BODY_FILE; + + const output = execFileSync(process.execPath, [BUMP_SCRIPT], { + cwd: process.cwd(), + encoding: 'utf8', + env, + }); + + expect(output).toContain('already'); + }); + + it('wires the bump into the run that publishes', () => { + 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. + expect(workflow).toMatch(/bump-activation-pin:/); + expect(workflow).toMatch(/needs:\s*publish-cli/); + expect(workflow).toContain('scripts/bump-activation-pin.mjs'); + + // The bump must land where the pin lives. Releases are cut from `main`; a bump + // committed there would edit a file the default branch never sees. + expect(workflow).toMatch(/--base dev/); + }); + + it('never derives the pin from the unreleased in-repo manifest', () => { + const script = readFileSync(BUMP_SCRIPT, 'utf8'); + + const executable = script + .split(/\r?\n/) + .filter((l) => { + const t = l.trimStart(); + return !t.startsWith('*') && !t.startsWith('//') && !t.startsWith('/*'); + }) + .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. + expect(executable).not.toMatch(/packages\/squad-cli\/package\.json/); + }); });