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
19 changes: 19 additions & 0 deletions .github/workflows/squad-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
113 changes: 113 additions & 0 deletions scripts/check-changeset-drift.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
80 changes: 80 additions & 0 deletions test/scripts/check-changeset-drift.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading