ci: fail when unreleased changesets accumulate - #1481
Merged
bradygaster merged 1 commit intoJul 19, 2026
Merged
Conversation
The .changeset flow only works if a release consumes the fragments at some cadence — bradygaster#1273 documented 104 pending fragments with the root CHANGELOG a full minor behind, and nothing in CI noticing. This adds a smoke alarm: scripts/check-changeset-drift.mjs counts pending fragments and the age of the oldest one (via the commit that added it), and trips when count > 25 or age > 30 days. Warn-only on PRs since the PR author isn't the one who can fix release cadence; hard fail on dev pushes. SQUAD_CHANGESET_DRIFT_CHECK repo var is the off switch, matching the other gates in squad-ci.yml. Thresholds are deliberately loose — the current tree (64 fragments, oldest 36 days) already trips both, which is the alarm working, not a false positive. Consolidating the backlog itself is a maintainer call tracked on the issue. Part of bradygaster#1273 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
🟡 Impact Analysis — PR #1481Risk tier: 🟡 MEDIUM 📊 Summary
🎯 Risk Factors
📦 Modules Affectedci-workflows (1 file)
root (1 file)
scripts (1 file)
tests (1 file)
This report is generated automatically for every PR. See #733 for details. |
Contributor
🛫 PR Readiness Check
PR Scope: 🔧 Infrastructure
|
| Status | Check | Details |
|---|---|---|
| ✅ | Single commit | 1 commit — clean history |
| ✅ | Not in draft | Ready for review |
| ✅ | Branch up to date | Up to date with dev |
| ❌ | Copilot review | No Copilot review yet — it may still be processing |
| ✅ | Changeset present | No source files changed — changeset not required |
| ✅ | Scope clean | No .squad/ or docs/proposals/ files |
| ✅ | No merge conflicts | No merge conflicts |
| ✅ | Copilot threads resolved | No Copilot review threads |
| ❌ | CI passing | 8 check(s) still running |
Files Changed (4 files, +214 −0)
| File | +/− |
|---|---|
.github/workflows/squad-ci.yml |
+19 −0 |
CONTRIBUTING.md |
+2 −0 |
scripts/check-changeset-drift.mjs |
+113 −0 |
test/scripts/check-changeset-drift.test.ts |
+80 −0 |
Total: +214 −0
This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.
Contributor
🏗️ Architectural Review
Automated architectural review — informational only. |
This was referenced Aug 21, 2026
bradygaster
added a commit
that referenced
this pull request
Aug 21, 2026
…st (#1790) * fix(ci): force LF on *.mjs so shebanged scripts stay loadable by vitest scripts/check-changeset-drift.mjs starts with `#!/usr/bin/env node`. No .gitattributes rule covered *.mjs, so with core.autocrlf=true the file checks out CRLF on Windows. Vite's shebang stripping does not survive the \r, leaving a bare `#` as the module's first token: SyntaxError: Invalid or unexpected token. test/scripts/check-changeset-drift.test.ts then loads ZERO of its 8 tests -- silently. Linux CI checks out LF, so dev stayed green and this has been dead since #1481. Node strips CRLF shebangs itself, which is why `node scripts/...` and a plain dynamic import both succeed and mask the defect. Only Vite's transform trips. .gitattributes already encodes this exact lesson for shell scripts (`*.sh text eol=lf` -- "CRLF breaks the shebang"); it was never extended to .mjs. This adds that rule and lands `git add --renormalize -- "*.mjs"` in the same commit so existing checkouts converge. 9 of 40 tracked .mjs blobs stored CRLF in the repo, so the renormalize was not a no-op. The shebang is kept -- squad-ci.yml invokes `node scripts/check-changeset-drift.mjs` directly. Adds test/scripts/mjs-shebang-loadable.test.ts, which imports the real shebanged module through the bundler pipeline and replays every tracked shebang line (15 files) as a fixture. Proven red against the pre-fix tree: 16/16 failed with the production error SyntaxError: Invalid or unexpected token. test/scripts/ goes from 3 passed | 1 failed (33 tests) to 5 passed (58 tests). Closes #1788 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6 * ci: add shebang EOL lint and generalize the eol=lf rule (#1788 follow-up) .gitattributes encoded "CRLF breaks the shebang" for *.sh in one PR and for *.mjs in another, and never generalized. We paid for that lesson twice. This adds scripts/check-shebang-eol.mjs so we do not pay for it a third time. The lint enumerates every tracked file whose blob starts with `#!` (45 today) and checks two things independently: UNPINNED git check-attr says eol != lf -- no rule covers the path CRLF-BLOB the staged blob's first line ends in \r -- a rule was added but `git add --renormalize` never ran, so the repo is still broken The second check matters because a rule alone is a no-op on existing blobs. That is not hypothetical: the scan found 7 violations on a tree that already had the rules, including samples/storage-provider-azure/scripts/*.sh, which `*.sh text eol=lf` has covered for ages. Those two files showed as modified in every worktree and `git restore` never made it stick -- git was normalizing the working tree to LF while the blob stayed CRLF, so the diff could never close. Renormalizing them fixes that permanently. docs/pagefind.yml had the identical condition under `*.yml text eol=lf` and is renormalized for the same reason. Also renormalized packages/squad-cli/src/cli-entry.ts, which stored a CRLF shebang. tsc happens to emit LF today, so the published bin is fine by accident rather than by construction. Rule coverage is extended by measured blast radius, not reflex: *.js (6 blobs), *.cjs (0) and *.ps1 (0) are cheap, but a blanket *.ts rule would renormalize 95 CRLF-storing blobs, so the 3 shebanged .ts entrypoints are pinned by path. The lint reads the index rather than HEAD, so it is correct both as a pre-commit check and as a CI gate (a fresh checkout has index == HEAD), and batches every blob through a single `git cat-file --batch` -- per-file spawns cost 70s on Windows and blew vitest's hook timeout. Proven red before green, in the real repo: staging a shebanged scripts/ _probe-deploy.zsh (matched by no rule) produced Shebang EOL check FAILED: 1 problem(s) across 1 of 46 shebanged file(s). UNPINNED scripts/_probe-deploy.zsh -- git check-attr eol = unspecified and exit 1; removing it returned exit 0. The suite also drives both violation kinds through real `git check-attr` / `git cat-file` in throwaway repos rather than hand-built fixture maps. test/scripts/: 6 files, 67 tests. Refs #1788 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6 * test: reword fixture comment that tripped the unsafe-git detector The comment quoted a repo-wide staging flag verbatim, which security-review.mjs correctly flags as an unsafe-git finding. The code was never doing it -- the fixture stages explicit paths -- so this is a false positive created by prose, and prose is the cheaper thing to change. Worth recording why it surfaced: the finding message quotes the offending command in backticks, so on dev (which does not yet carry #1786) the reporter died with `SyntaxError: Unexpected identifier 'git'` instead of posting the finding. That is #1770 reproducing in the field, on this PR, from an ordinary code comment -- independent confirmation that the #1786 boundary fix is addressing a live defect rather than a theoretical one. Refs #1788 Refs #1770 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6 * build: pin *.snap to LF and document the lint's scope boundary Vitest writes snapshot files with LF unconditionally. `*.snap` had no .gitattributes rule, so it checks out CRLF on Windows and `npm test` dirties a tracked file on every run -- `test/__snapshots__/parser-contracts.test.ts.snap`, found by EECOM. Same CRLF class as the *.mjs rule, different symptom. The .mjs case was a strict parse failure: the suite loaded zero tests. This one loads and passes (16/16) and leaves the tree dirty, which is the more dangerous shape -- a broad `git add` commits pure line-ending noise, or sweeps in an unrelated real change sitting in the same working tree. Reproduced before fixing: clean tree, run the suite, ` M ...parser-contracts. test.ts.snap`. With the rule, the same run leaves the tree clean. Note this does NOT ride the renormalize, contrary to first assumption. The blob is already LF (0 CRLF, 2991 bytes); only the checkout is CRLF (151 CRLF, 3142 bytes). `git add --renormalize -- "*.snap"` stages nothing. What an existing Windows checkout needs is for the file to be rewritten as LF -- which the first `npm test` after this merges does by itself. No manual remediation. Also states the shebang lint's scope boundary in its own header, because a green run should not be read as "no EOL bugs". It covers the strict-parse class, where `#!` is an exact static signature. It does not cover the tool-rewrite class, which has no cheap static signature; guessing at it would trade a sound check for an unsound one. New tool-written file types get pinned by hand. Closes #1788 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6 --------- Co-authored-by: brady gaster <bradygaster@github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70370e36-33b0-4786-bd72-4cf15518daa6
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a changeset-drift gate to Squad CI:
scripts/check-changeset-drift.mjscounts pending.changeset/fragments and the age of the oldest one, and trips when count > 25 or age > 30 days. Warn-only on PRs, hard fail on pushes todev.Why
Part of #1273 — the CI-gate half only. The issue documented 104 pending fragments with the root CHANGELOG a full minor version behind, and nothing in CI noticing. Consolidating the backlog itself is a maintainer call (asked on the issue: one-off
changeset versionPR vs inside the release workflow) — this gate doesn't wait on that answer.Threshold rationale in one sentence: loose enough that normal development between releases never trips it (25 fragments / 30 days is several release cycles of headroom), tight enough that a stalled release flow gets caught within a month instead of a version later.
How
scripts/check-changeset-drift.mjs— zero-dep ESM, same shape as the otherscripts/*.mjschecks. Fragments =.changeset/*.mdminusREADME.md(config.jsonis JSON, filtered by extension). Age comes fromgit log --diff-filter=Aon each fragment; untracked/unresolvable files count as age 0 so a PR adding its own changeset can't trip the age check. Threshold logic is a pure exported function.changeset-driftjob insquad-ci.yml:--mode=failonpush(dev),--mode=warnonpull_request— the PR author isn't the one who can fix release cadence, so red PRs would just be noise.vars.SQUAD_CHANGESET_DRIFT_CHECK != 'false'is the off switch, matching the escape-hatch convention of the other gates in this file. Actions pinned to full-length SHAs with trailing version comments (same refs already used elsewhere in the file).fetch-depth: 0because fragment age needs history..squad-templates/workflows/) — this is release hygiene for this repo's changeset flow, not something consumer repos need.Heads up: the gate trips today by design — current tree has 64 pending fragments, oldest 36 days. PRs (including this one) only get a warning annotation, but the first
devpush after merge will show a redChangeset Driftcheck until the #1273 consolidation lands or the repo var turns it off. That's the alarm working; flagging it so nobody is surprised.Testing
test/scripts/check-changeset-drift.test.ts(8 tests, same import style astest/scripts/parse-diff.test.ts): threshold boundaries (at-threshold passes, +1 trips), both-reasons case, custom thresholds, zero fragments, andlistFragmentsfiltering (README.md and config.json excluded, missing dir → empty).--mode=failexits 1.squad-ci.ymlstill parses (js-yaml),npm run lint(tsc) passes after build,npx eslinton the new files — 0 errors.git diff --checkflags CR on them — expected for that file,git diff -wvsgit diffshows no whitespace-only hunks..github/workflows/,scripts/,test/, and CONTRIBUTING.md are not governed source/template paths per the changelog gate's own path regexPR Readiness Checklist
Branch & Commit
devdevBuild & Test
npm run buildpassesnpx vitest run test/scripts/check-changeset-drift.test.ts test/ci/changelog-gate.test.ts— 26 passednpm run lintpasses (type check clean)npm run lint:eslint— 0 errors on changed filesChangeset
Docs
Exports
Breaking Changes
None at merge time. The first dev push after merge will fail the new check until the fragment backlog is consolidated — see the heads-up above.
Waivers
None.