Skip to content

ci: fail when unreleased changesets accumulate - #1481

Merged
bradygaster merged 1 commit into
bradygaster:devfrom
omercangumus:omercangumus/1273-changeset-drift-gate
Jul 19, 2026
Merged

ci: fail when unreleased changesets accumulate#1481
bradygaster merged 1 commit into
bradygaster:devfrom
omercangumus:omercangumus/1273-changeset-drift-gate

Conversation

@omercangumus

Copy link
Copy Markdown
Contributor

What

Adds a changeset-drift gate to Squad CI: scripts/check-changeset-drift.mjs counts 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 to dev.

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 version PR 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 other scripts/*.mjs checks. Fragments = .changeset/*.md minus README.md (config.json is JSON, filtered by extension). Age comes from git log --diff-filter=A on 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.
  • New changeset-drift job in squad-ci.yml: --mode=fail on push (dev), --mode=warn on pull_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: 0 because fragment age needs history.
  • One-paragraph note in CONTRIBUTING.md's existing Release Workflow section.
  • Deliberately NOT added to the shipped workflow templates (.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 dev push after merge will show a red Changeset Drift check 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 as test/scripts/parse-diff.test.ts): threshold boundaries (at-threshold passes, +1 trips), both-reasons case, custom thresholds, zero fragments, and listFragments filtering (README.md and config.json excluded, missing dir → empty).
  • Dry-run against the real tree: warn mode prints the 64/36-day drift and exits 0; --mode=fail exits 1.
  • squad-ci.yml still parses (js-yaml), npm run lint (tsc) passes after build, npx eslint on the new files — 0 errors.
  • CONTRIBUTING.md is CRLF-committed; the added lines keep CRLF per the line-ending rule, so git diff --check flags CR on them — expected for that file, git diff -w vs git diff shows no whitespace-only hunks.

⚠️ Quick Check

  • No changeset — .github/workflows/, scripts/, test/, and CONTRIBUTING.md are not governed source/template paths per the changelog gate's own path regex

PR Readiness Checklist

Branch & Commit

  • Branch created from dev
  • Branch is up to date with dev
  • Verified diff contains only intended changes (4 files, +214)
  • PR is not in draft mode
  • Commit history is clean (single commit)

Build & Test

  • npm run build passes
  • npx vitest run test/scripts/check-changeset-drift.test.ts test/ci/changelog-gate.test.ts — 26 passed
  • npm run lint passes (type check clean)
  • npm run lint:eslint — 0 errors on changed files

Changeset

  • N/A — infrastructure only

Docs

  • CONTRIBUTING.md Release Workflow section updated

Exports

  • N/A

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.

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>
@github-actions

Copy link
Copy Markdown
Contributor

🟡 Impact Analysis — PR #1481

Risk tier: 🟡 MEDIUM

📊 Summary

Metric Count
Files changed 4
Files added 2
Files modified 2
Files deleted 0
Modules touched 4

🎯 Risk Factors

  • 4 files changed (≤5 → LOW)
  • 4 modules touched (2-4 → MEDIUM)

📦 Modules Affected

ci-workflows (1 file)
  • .github/workflows/squad-ci.yml
root (1 file)
  • CONTRIBUTING.md
scripts (1 file)
  • scripts/check-changeset-drift.mjs
tests (1 file)
  • test/scripts/check-changeset-drift.test.ts

This report is generated automatically for every PR. See #733 for details.

@github-actions

Copy link
Copy Markdown
Contributor

🛫 PR Readiness Check

ℹ️ This comment updates on each push. Last checked: commit f0b0a25

PR Scope: 🔧 Infrastructure

⚠️ 2 item(s) to address before review

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.

@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Architectural Review

⚠️ Architectural review: 1 info.

Severity Category Finding Files
ℹ️ info template-sync Template files changed in .github/workflows/ but not in other template locations. If these templates should stay in sync, consider updating the others too. Changed: .github/workflows/, Unchanged: templates/, .squad-templates/, packages/squad-cli/templates/

Automated architectural review — informational only.

@bradygaster
bradygaster merged commit 84a5553 into bradygaster:dev Jul 19, 2026
15 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants