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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ jobs:
- name: The hosted-Supabase audit still detects, and still stays quiet
run: bash scripts/ci/test-hosted-supabase-audit.sh

# Same doctrine: the currency ratchet reports on repos nobody touches,
# so its own test pins both sides — the real 2026-08-31 staleness
# (aoz-housing's manifest, dead-owner ai-kit pin) is caught, a current
# manifest stays quiet, and an unreadable repo is UNCHECKED, never clean.
- name: The version-currency audit still detects, and still stays quiet
run: node scripts/ci/test-version-currency.mjs

# Drift guard. The fleet is on v7; templates handing out v4 is exactly
# how this repo fell behind the repos it governs.
- name: No stale action versions
Expand Down
62 changes: 62 additions & 0 deletions .github/workflows/version-currency.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Staleness is measured, and the measurement is a ratchet.
#
# The 2026-08-31 audit found the box on an EOL Node, one app a framework
# generation behind on every axis, and an internal package pinned at a dead
# owner — none of it hidden, all of it unmeasured. Same lesson as the shared
# inventory: writing "stay current" down is what already failed. The report
# makes the trend visible; `--check` makes it binding.
name: Version currency

on:
schedule:
# Weekly. Majors ship on the timescale of quarters; daily would be noise,
# and noise gets muted.
- cron: '17 6 * * 1'
workflow_dispatch:
inputs:
strict:
description: 'Fail when the gap count has risen'
type: boolean
default: true

permissions:
contents: read

jobs:
currency:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 24

- name: Self-test the detector first
run: node scripts/ci/test-version-currency.mjs

- name: Measure the fleet
env:
# Default token reads public repos only; private ones would land in
# UNCHECKED (visibly — never counted as clean). FLEET_READ_TOKEN
# covers them all.
GH_TOKEN: ${{ secrets.FLEET_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -uo pipefail
{
echo '## Version currency'
echo
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

set +e
node scripts/ci/version-currency.mjs 2>&1 | tee -a "$GITHUB_STEP_SUMMARY"
set -e
echo '```' >> "$GITHUB_STEP_SUMMARY"

- name: The ratchet — gaps may fall, never rise
if: ${{ inputs.strict != false }}
env:
GH_TOKEN: ${{ secrets.FLEET_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: node scripts/ci/version-currency.mjs --check
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ versa. `dotfiles` is the environment again; this repo is the automation.
| `scripts/ci/model-pin-audit.mjs` | runs daily: is any model id the fleet pins still served by its vendor? |
| `scripts/ci/verify-floor-audit.sh` | does every repo's `verify` actually run lint + typecheck + test? |
| `scripts/ci/shared-inventory.sh` | counts duplication across the fleet and holds it as a ratchet |
| `scripts/ci/version-currency.mjs` | measures every repo against `blessed-versions.json` (SSOT of blessed majors + internal-package tags) and holds the gap count as a ratchet |
| `scripts/ci/ui-defect-audit.mjs` | do any live sites ship WCAG AA contrast failures or misaligned stacks? |
| `scripts/fleet/` | local upkeep: worktree GC, stranded-work guard |
| `templates/ci/` | golden CI workflows + pre-commit — deliberately ONE central copy |
Expand Down
36 changes: 36 additions & 0 deletions scripts/ci/blessed-versions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"_comment": [
"The fleet's blessed MAJOR version per key package — the SSOT the",
"version-currency audit measures every repo against. Raise a number here",
"(in a PR) when the fleet decides a new major is the standard; the audit",
"then reports every repo below it as a gap. Majors only: 0.x packages",
"(drizzle-orm, @anthropic-ai/sdk) have no meaningful major and are not",
"listed. next-auth is deliberately absent while v5 is beta.",
"internal_tags: the git tag every consumer of an internal package must",
"pin until the package is on npm."
],
"majors": {
"next": 16,
"react": 19,
"react-dom": 19,
"typescript": 6,
"tailwindcss": 4,
"zod": 4,
"eslint": 10,
"vitest": 4,
"jest": 30,
"@types/node": 26,
"prettier": 3,
"tsx": 4,
"express": 5,
"dotenv": 17,
"framer-motion": 13,
"lucide-react": 1,
"openai": 7,
"@supabase/supabase-js": 2,
"pg": 8
},
"internal_tags": {
"ai-kit": "v0.6.2"
}
}
75 changes: 75 additions & 0 deletions scripts/ci/test-version-currency.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Self-test for version-currency.mjs.
* Run: node scripts/ci/test-version-currency.mjs
*
* Both sides pinned, per fleet doctrine: the real 2026-08-31 staleness is
* still caught (positive), a fully-current manifest stays silent (negative),
* and an unreadable repo is UNCHECKED — never counted as gap-free. The
* fixtures are the audit that motivated the ratchet: aoz-housing's actual
* manifest shape, including the dead-owner ai-kit pin.
*/
import { parseMajor, parseGitPin, gapsFor, collate } from "./version-currency.mjs";

let failures = 0;
function check(name, cond, detail = "") {
if (cond) console.log(` ok ${name}`);
else { console.log(` FAIL ${name} ${detail}`); failures++; }
}

const blessed = {
majors: { next: 16, react: 19, tailwindcss: 4, zod: 4, eslint: 10, "@types/node": 26 },
internal_tags: { "ai-kit": "v0.6.2" },
};

// ── parseMajor / parseGitPin ────────────────────────────────────────────────
check("parseMajor ^16.2.3 -> 16", parseMajor("^16.2.3") === 16);
check("parseMajor '*' -> null", parseMajor("*") === null);
check("parseGitPin github:catomean/ai-kit#v0.4.0",
JSON.stringify(parseGitPin("github:catomean/ai-kit#v0.4.0")) ===
JSON.stringify({ owner: "catomean", repo: "ai-kit", tag: "v0.4.0" }));
check("parseGitPin registry range -> null", parseGitPin("^4.5.4") === null);

// ── Positive: the aoz-housing regression is caught ──────────────────────────
const stale = {
dependencies: { next: "^14.2.5", react: "^18.3.1", "ai-kit": "github:catomean/ai-kit#v0.4.0" },
devDependencies: { tailwindcss: "^3.4.9", zod: "^3.23.8", eslint: "^8.57.0", "@types/node": "^20.14.10" },
};
const staleGaps = gapsFor(stale, blessed);
check("stale manifest: 6 major gaps + 1 pin gap", staleGaps.length === 7, `got ${JSON.stringify(staleGaps)}`);
check("dead-owner pin is named as such", staleGaps.some((g) => g.includes("dead after org move")));
check("next gap present", staleGaps.some((g) => g.startsWith("next ")));

// ── Negative: a current manifest stays silent ───────────────────────────────
const current = {
dependencies: { next: "16.3.3", react: "^19.2.8", "ai-kit": "github:bitbaum/ai-kit#v0.6.2" },
devDependencies: { tailwindcss: "^4.3.3", zod: "^4.5.4", eslint: "^10.9.1", "@types/node": "^26.4.0" },
};
check("current manifest: zero gaps", gapsFor(current, blessed).length === 0,
`got ${JSON.stringify(gapsFor(current, blessed))}`);

// ── Packages the repo does not use are not gaps ─────────────────────────────
check("absent package is not a gap", gapsFor({ dependencies: {} }, blessed).length === 0);

// ── Right-tag wrong-owner and right-owner wrong-tag both flagged ────────────
check("stale bitbaum tag flagged",
gapsFor({ dependencies: { "ai-kit": "github:bitbaum/ai-kit#v0.5.0" } }, blessed).length === 1);

// ── UNCHECKED is not clean ──────────────────────────────────────────────────
const { rows, total, uncheckedRepos } = collate([
{ repo: "good", pkg: current, gaps: gapsFor(current, blessed) },
{ repo: "bad", pkg: stale, gaps: staleGaps },
{ repo: "unreadable" }, // no pkg — could not fetch
]);
check("unreadable repo lands in UNCHECKED", uncheckedRepos.length === 1 && uncheckedRepos[0] === "unreadable");
check("unreadable repo not in measured rows", rows.every((r) => r.repo !== "unreadable"));
check("total counts only measured gaps", total === 7);
check("worst repo sorts first", rows[0].repo === "bad");

// ── Mutation-proof: a lowered baseline must trip the ratchet ────────────────
// (The ratchet math is `total > baseline` in main(); assert the collate side
// that feeds it — a stale fleet yields a total a clean baseline cannot cover.)
check("mutation: stale total exceeds a clean baseline", total > 0);

if (failures) { console.error(`\n${failures} failing`); process.exit(1); }
console.log("\nall green");
1 change: 1 addition & 0 deletions scripts/ci/version-currency.baseline
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
80
Loading