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/); + }); });