diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index d97a6de86..73ea55d90 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -174,6 +174,25 @@ jobs: # Skip labels: skip-changelog, skip-exports-check, skip-samples-ci, # skip-workspace-check, skip-version-check, skip-export-smoke, large-deletion-approved + # ── Changeset drift gate (#1273) ───────────────────────────────────────── + # Alarms when unreleased changeset fragments accumulate (count or age), + # i.e. the release flow stopped consuming them. Warn-only on PRs (the PR + # author isn't the one who can fix release cadence), hard fail on dev pushes. + changeset-drift: + name: Changeset Drift + if: vars.SQUAD_CHANGESET_DRIFT_CHECK != 'false' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e #v6 + with: + node-version: 22 + - name: Check changeset drift + run: node scripts/check-changeset-drift.mjs --mode=${{ github.event_name == 'push' && 'fail' || 'warn' }} + # ── Consolidated policy gates ─────────────────────────────────────────── # Runs changelog, changelog-protection, workspace-integrity, # prerelease-version-guard, publish-policy, and scope-check on one runner. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c0eae1c2..ffdac417f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -343,6 +343,8 @@ This: You don't need to manually version — changesets handle it. +CI watches for drift: `scripts/check-changeset-drift.mjs` warns on PRs and fails on `dev` pushes when more than 25 fragments are pending or the oldest is over 30 days old — a signal that `changeset version` needs to run. + ## Branch Strategy - **main** — Stable, published releases. All merges include changesets. diff --git a/scripts/check-changeset-drift.mjs b/scripts/check-changeset-drift.mjs new file mode 100644 index 000000000..33a4d92fe --- /dev/null +++ b/scripts/check-changeset-drift.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +/** + * Changeset Drift Check — fails CI when unreleased changesets accumulate. + * + * The .changeset/ workflow only works if fragments are consumed by a release + * (`changeset version`) at a reasonable cadence. Issue #1273 documented the + * failure mode: 104 fragments piled up while the root CHANGELOG sat a full + * minor version behind, and nothing in CI noticed. + * + * Thresholds are deliberately loose — this is a smoke alarm for "the release + * flow stopped consuming fragments", not a nag on active development. + * + * Modes: + * --mode=warn (default) emit a workflow warning, exit 0 + * --mode=fail emit an error annotation, exit 1 + * + * Issue: bradygaster/squad#1273 + */ + +import { readdirSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const MAX_FRAGMENTS = 25; +export const MAX_AGE_DAYS = 30; + +/** + * List pending changeset fragments (markdown files that `changeset version` + * would consume). README.md and non-.md files (config.json) are not fragments. + */ +export function listFragments(changesetDir) { + if (!existsSync(changesetDir)) return []; + return readdirSync(changesetDir) + .filter((f) => f.endsWith('.md') && f !== 'README.md') + .sort(); +} + +/** + * Age in days of a fragment, measured from the commit that added it. + * Untracked or unresolvable files count as age 0 (they can't be stale). + */ +export function fragmentAgeDays(repoRoot, fragment, nowMs = Date.now()) { + try { + const out = execFileSync( + 'git', + ['log', '--diff-filter=A', '--format=%ct', '-1', '--', join('.changeset', fragment)], + { cwd: repoRoot, encoding: 'utf8' }, + ).trim(); + if (!out) return 0; + const addedMs = Number(out) * 1000; + if (!Number.isFinite(addedMs) || addedMs <= 0) return 0; + return Math.max(0, (nowMs - addedMs) / 86_400_000); + } catch { + return 0; + } +} + +/** + * Pure threshold logic — drifted when the pending count exceeds maxFragments + * OR the oldest pending fragment exceeds maxAgeDays. + */ +export function evaluateDrift({ count, oldestAgeDays, maxFragments = MAX_FRAGMENTS, maxAgeDays = MAX_AGE_DAYS }) { + const reasons = []; + if (count > maxFragments) { + reasons.push(`${count} unreleased changeset fragments (threshold: ${maxFragments})`); + } + if (oldestAgeDays > maxAgeDays) { + reasons.push(`oldest fragment is ${Math.floor(oldestAgeDays)} days old (threshold: ${maxAgeDays})`); + } + return { drifted: reasons.length > 0, reasons }; +} + +function main() { + const mode = process.argv.includes('--mode=fail') ? 'fail' : 'warn'; + const repoRoot = process.cwd(); + const fragments = listFragments(join(repoRoot, '.changeset')); + + let oldestAgeDays = 0; + let oldestName = ''; + for (const fragment of fragments) { + const age = fragmentAgeDays(repoRoot, fragment); + if (age > oldestAgeDays) { + oldestAgeDays = age; + oldestName = fragment; + } + } + + const { drifted, reasons } = evaluateDrift({ count: fragments.length, oldestAgeDays }); + + console.log(`Pending changeset fragments: ${fragments.length} (thresholds: >${MAX_FRAGMENTS} count, >${MAX_AGE_DAYS} days age)`); + if (oldestName) { + console.log(`Oldest: ${oldestName} (${Math.floor(oldestAgeDays)} days)`); + } + + if (!drifted) { + console.log('✅ No changeset drift'); + return; + } + + const detail = `${reasons.join('; ')}. Run the release flow (changeset version) or consolidate — see #1273.`; + if (mode === 'fail') { + console.log(`::error::Changeset drift: ${detail}`); + process.exitCode = 1; + } else { + console.log(`::warning::Changeset drift: ${detail}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/test/scripts/check-changeset-drift.test.ts b/test/scripts/check-changeset-drift.test.ts new file mode 100644 index 000000000..96d87e5bd --- /dev/null +++ b/test/scripts/check-changeset-drift.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomBytes } from 'node:crypto'; +import { + listFragments, + evaluateDrift, + MAX_FRAGMENTS, + MAX_AGE_DAYS, +} from '../../scripts/check-changeset-drift.mjs'; + +// ── evaluateDrift ─────────────────────────────────────────────────────── + +describe('evaluateDrift', () => { + it('passes when both count and age are under the thresholds', () => { + const result = evaluateDrift({ count: MAX_FRAGMENTS, oldestAgeDays: MAX_AGE_DAYS }); + expect(result.drifted).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it('drifts when the fragment count exceeds the threshold', () => { + const result = evaluateDrift({ count: MAX_FRAGMENTS + 1, oldestAgeDays: 0 }); + expect(result.drifted).toBe(true); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain(`${MAX_FRAGMENTS + 1} unreleased`); + }); + + it('drifts when the oldest fragment exceeds the age threshold', () => { + const result = evaluateDrift({ count: 1, oldestAgeDays: MAX_AGE_DAYS + 0.5 }); + expect(result.drifted).toBe(true); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain('days old'); + }); + + it('reports both reasons when both thresholds are exceeded', () => { + const result = evaluateDrift({ count: 104, oldestAgeDays: 90 }); + expect(result.drifted).toBe(true); + expect(result.reasons).toHaveLength(2); + }); + + it('respects custom thresholds', () => { + const result = evaluateDrift({ count: 3, oldestAgeDays: 2, maxFragments: 2, maxAgeDays: 1 }); + expect(result.drifted).toBe(true); + expect(result.reasons).toHaveLength(2); + }); + + it('passes with zero fragments', () => { + const result = evaluateDrift({ count: 0, oldestAgeDays: 0 }); + expect(result.drifted).toBe(false); + }); +}); + +// ── listFragments ─────────────────────────────────────────────────────── + +describe('listFragments', () => { + const TEST_DIR = join(tmpdir(), `.test-changeset-drift-${randomBytes(4).toString('hex')}`); + + beforeEach(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('lists .md fragments, excluding README.md and non-markdown files', () => { + writeFileSync(join(TEST_DIR, 'brave-lions-jump.md'), '---\n---\n'); + writeFileSync(join(TEST_DIR, 'happy-owls-sing.md'), '---\n---\n'); + writeFileSync(join(TEST_DIR, 'README.md'), '# changesets\n'); + writeFileSync(join(TEST_DIR, 'config.json'), '{}\n'); + + expect(listFragments(TEST_DIR)).toEqual(['brave-lions-jump.md', 'happy-owls-sing.md']); + }); + + it('returns empty for a missing directory', () => { + expect(listFragments(join(TEST_DIR, 'does-not-exist'))).toEqual([]); + }); +});