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
114 changes: 114 additions & 0 deletions .github/workflows/squad-npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Comment on lines +414 to +423
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:-<unset>}', waiting for '$TARGET' (attempt $attempt/5)..."
sleep 15
done

if [ "$latest" != "$TARGET" ]; then
echo "::error::npm dist-tags.latest is '${latest:-<unset>}', 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
Expand Down
176 changes: 176 additions & 0 deletions scripts/bump-activation-pin.mjs
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +100 to +107
}

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