-
Notifications
You must be signed in to change notification settings - Fork 0
feat(dx): env-name parity check and branch-ledger sweeper (report-only) #596
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * check-env-parity — reconcile environment-variable NAMES across the places this | ||
| * repo declares them, without ever reading or printing a single value. | ||
| * | ||
| * Config truth is scattered: the canonical Zod schema in src/lib/env.ts, the CI | ||
| * browser-env checker (scripts/check-ci-env.mjs), GitHub repo secrets, and Railway | ||
| * runtime vars. A name present in one place but missing in another has broken main | ||
| * CI before (e.g. RAG_QUERY_HASH_SECRET). This diffs the name sets and reports gaps. | ||
| * | ||
| * Offline by default (parses env.ts + check-ci-env.mjs only). Live sources are | ||
| * opt-in and names-only: | ||
| * --gh run `gh secret list` (names only; values are write-only anyway) | ||
| * --railway run `railway variables` (names only) if the CLI is available | ||
| * | ||
| * Never prints a value. Exit 1 only when a hard parity problem is found (an | ||
| * expected secret is absent from a queried live source), else 0. | ||
| */ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { readFileSync } from "node:fs"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); | ||
|
|
||
| // Vars that MUST be supplied as deployment/CI secrets (never committed). Each is | ||
| // asserted to exist in the canonical name set below, so this list cannot silently | ||
| // drift from the schema. | ||
| const EXPECTED_SECRETS = [ | ||
| "SUPABASE_SERVICE_ROLE_KEY", | ||
| "OPENAI_API_KEY", | ||
| "RAG_QUERY_HASH_SECRET", | ||
| "HEALTH_DEEP_PROBE_SECRET", | ||
| "E2E_USER_EMAIL", | ||
| "E2E_USER_PASSWORD", | ||
| ]; | ||
|
|
||
| /** Zod schema keys from env.ts: lines shaped like ` NAME: z.…`. */ | ||
| export function parseEnvSchemaNames(envTsText) { | ||
| return [...envTsText.matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*:\s*z\./gm)].map((m) => m[1]); | ||
| } | ||
|
|
||
| /** UPPER_SNAKE names referenced in check-ci-env.mjs (quoted literals + process.env.X). */ | ||
| export function parseCiEnvNames(ciEnvText) { | ||
| const names = new Set(); | ||
| for (const m of ciEnvText.matchAll(/"([A-Z][A-Z0-9_]*)"/g)) names.add(m[1]); | ||
| for (const m of ciEnvText.matchAll(/process\.env\.([A-Z][A-Z0-9_]*)/g)) names.add(m[1]); | ||
| return [...names]; | ||
| } | ||
|
|
||
| /** Pure diff of live secret names against expectations + the known-name universe. */ | ||
| export function computeParity({ canonical, liveNames, expectedSecrets }) { | ||
| const canon = new Set(canonical); | ||
| const live = new Set(liveNames); | ||
| return { | ||
| missingSecrets: expectedSecrets.filter((name) => !live.has(name)), | ||
| unknownLive: [...live].filter((name) => !canon.has(name)), | ||
| }; | ||
| } | ||
|
|
||
| function ghSecretNames() { | ||
| const raw = execFileSync("gh", ["secret", "list", "--json", "name"], { encoding: "utf8" }); | ||
| return JSON.parse(raw).map((s) => s.name); | ||
| } | ||
|
|
||
| function railwayVarNames() { | ||
| // `railway variables` prints a table; --json/-k vary by CLI version, so parse | ||
| // UPPER_SNAKE tokens defensively. Names only. | ||
| const raw = execFileSync("railway", ["variables"], { encoding: "utf8" }); | ||
| return [...new Set([...raw.matchAll(/\b([A-Z][A-Z0-9_]{2,})\b/g)].map((m) => m[1]))]; | ||
| } | ||
|
|
||
| function main() { | ||
| const useGh = process.argv.includes("--gh"); | ||
| const useRailway = process.argv.includes("--railway"); | ||
|
|
||
| const envTs = readFileSync(path.join(root, "src/lib/env.ts"), "utf8"); | ||
| const ciEnv = readFileSync(path.join(root, "scripts/check-ci-env.mjs"), "utf8"); | ||
| const canonical = new Set([...parseEnvSchemaNames(envTs), ...parseCiEnvNames(ciEnv)]); | ||
|
|
||
| const problems = []; | ||
|
|
||
| // Self-consistency: every expected secret must be a name the app/CI actually knows. | ||
| const unknownExpected = EXPECTED_SECRETS.filter((name) => !canonical.has(name)); | ||
| if (unknownExpected.length > 0) { | ||
| problems.push( | ||
| `Expected-secret names not found in env.ts/check-ci-env (typo or drift): ${unknownExpected.join(", ")}`, | ||
| ); | ||
| } | ||
|
|
||
| console.log(`Known env names: ${canonical.size} (env.ts schema + check-ci-env).`); | ||
| console.log(`Expected secrets: ${EXPECTED_SECRETS.join(", ")}`); | ||
|
|
||
| for (const [flag, enabled, label, getter] of [ | ||
| ["--gh", useGh, "GitHub secrets", ghSecretNames], | ||
| ["--railway", useRailway, "Railway variables", railwayVarNames], | ||
| ]) { | ||
| if (!enabled) { | ||
| console.log(`(${label}: skipped — pass ${flag} to check; names only, no values)`); | ||
| continue; | ||
| } | ||
| let liveNames; | ||
| try { | ||
| liveNames = getter(); | ||
| } catch (error) { | ||
| problems.push(`${label}: could not query (${error.message.split("\n")[0]})`); | ||
| continue; | ||
| } | ||
| const { missingSecrets, unknownLive } = computeParity({ | ||
| canonical: [...canonical], | ||
| liveNames, | ||
| expectedSecrets: EXPECTED_SECRETS, | ||
| }); | ||
| console.log(`\n${label}: ${liveNames.length} names.`); | ||
| if (missingSecrets.length > 0) problems.push(`${label}: missing expected secret(s): ${missingSecrets.join(", ")}`); | ||
| if (unknownLive.length > 0) { | ||
| console.log(` ⚠ present but not in env.ts (possible stale/typo): ${unknownLive.join(", ")}`); | ||
| } | ||
| } | ||
|
|
||
| if (problems.length > 0) { | ||
| console.error("\nEnv parity problems:"); | ||
| for (const p of problems) console.error(`- ${p}`); | ||
| process.exit(1); | ||
| } | ||
| console.log("\nEnv parity OK (names only; no values were read)."); | ||
| } | ||
|
|
||
| const invokedDirectly = process.argv[1]?.endsWith("check-env-parity.mjs"); | ||
| if (invokedDirectly) main(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * sweep-branch-ledger — build the branch inventory that docs/branch-cleanup-guide.md | ||
| * mandates, and flag deletion candidates. REPORT ONLY: it never deletes, renames, or | ||
| * pushes anything, and it edits no files. | ||
| * | ||
| * With ~40 worktrees and a squash-merge flow, ancestry-based `--merged` misses | ||
| * squash-absorbed branches, so this uses the cherry-pick-aware check from the guide | ||
| * (`git log --right-only --cherry-pick`) to decide whether a branch still has unique | ||
| * patch content. Branches already recorded in docs/branch-review-ledger.md are noted | ||
| * so a follow-up review can skip them. | ||
| * | ||
| * Flags: --no-fetch (skip the network fetch), --json (machine-readable output). | ||
| */ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { readFileSync } from "node:fs"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); | ||
| const LEDGER = path.join(root, "docs/branch-review-ledger.md"); | ||
|
|
||
| function git(args) { | ||
| return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); | ||
| } | ||
|
|
||
| function tryGit(args) { | ||
| try { | ||
| return git(args); | ||
| } catch { | ||
| return ""; | ||
| } | ||
| } | ||
|
|
||
| /** Branch names referenced in the review ledger (2nd table column may prefix "PR #N / "). */ | ||
| export function parseLedgerBranches(markdown) { | ||
| const names = new Set(); | ||
| for (const m of markdown.matchAll(/\b((?:claude|codex)\/[A-Za-z0-9._-]+)/g)) names.add(m[1]); | ||
| return names; | ||
| } | ||
|
|
||
| function main() { | ||
| const asJson = process.argv.includes("--json"); | ||
| if (!process.argv.includes("--no-fetch")) { | ||
| try { | ||
| execFileSync("git", ["fetch", "--prune", "--quiet", "origin"], { cwd: root, stdio: "ignore" }); | ||
| } catch { | ||
| /* offline — use last-known refs */ | ||
| } | ||
| } | ||
|
|
||
| const ledgerBranches = (() => { | ||
| try { | ||
| return parseLedgerBranches(readFileSync(LEDGER, "utf8")); | ||
| } catch { | ||
| return new Set(); | ||
| } | ||
| })(); | ||
|
|
||
| const remoteBranches = tryGit(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"]) | ||
| .split("\n") | ||
| .map((b) => b.trim()) | ||
| // `refname:short` renders refs/remotes/origin/HEAD as bare "origin"; exclude it. | ||
| .filter((b) => b && b !== "origin" && b !== "origin/HEAD" && b !== "origin/main"); | ||
|
|
||
| const rows = []; | ||
| for (const ref of remoteBranches) { | ||
| const short = ref.replace(/^origin\//, ""); | ||
| const counts = tryGit(["rev-list", "--left-right", "--count", `origin/main...${ref}`]); | ||
| const [behind, ahead] = counts ? counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0) : [0, 0]; | ||
| // Cherry-pick-aware: commits on the branch NOT already in main (by patch id). | ||
| const uniqueLog = tryGit(["log", "--oneline", "--right-only", "--cherry-pick", `origin/main...${ref}`]); | ||
| const uniqueCommits = uniqueLog ? uniqueLog.split("\n").filter(Boolean).length : 0; | ||
| rows.push({ | ||
| branch: short, | ||
| ahead, | ||
| behind, | ||
| uniqueCommits, | ||
| inLedger: ledgerBranches.has(short), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This marks a branch as present in the review ledger based only on the branch name, but the cleanup guide requires matching branch/ref, reviewed HEAD, and Useful? React with 👍 / 👎. |
||
| deletionCandidate: uniqueCommits === 0, | ||
| }); | ||
| } | ||
|
|
||
| rows.sort((a, b) => a.uniqueCommits - b.uniqueCommits || a.branch.localeCompare(b.branch)); | ||
|
|
||
| if (asJson) { | ||
| console.log(JSON.stringify({ generatedAt: new Date().toISOString(), branches: rows }, null, 2)); | ||
| return; | ||
| } | ||
|
|
||
| const candidates = rows.filter((r) => r.deletionCandidate); | ||
| console.log(`Branch inventory vs origin/main (${rows.length} remote branches):\n`); | ||
| console.log("unique ahead behind ledger branch"); | ||
| for (const r of rows) { | ||
| console.log( | ||
| `${String(r.uniqueCommits).padStart(6)} ${String(r.ahead).padStart(5)} ${String(r.behind).padStart(6)} ` + | ||
| `${r.inLedger ? " yes " : " no "} ${r.branch}`, | ||
| ); | ||
| } | ||
| console.log(`\n${candidates.length} deletion candidate(s) (no unique patch content — squash-merged or empty):`); | ||
| for (const r of candidates) console.log(` - ${r.branch}`); | ||
| console.log( | ||
| "\nREPORT ONLY — nothing deleted. Verify each candidate per docs/branch-cleanup-guide.md before removing.", | ||
| ); | ||
| } | ||
|
|
||
| const invokedDirectly = process.argv[1]?.endsWith("sweep-branch-ledger.mjs"); | ||
| if (invokedDirectly) main(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { computeParity, parseCiEnvNames, parseEnvSchemaNames } from "../scripts/check-env-parity.mjs"; | ||
| import { parseLedgerBranches } from "../scripts/sweep-branch-ledger.mjs"; | ||
|
|
||
| describe("check-env-parity name parsing", () => { | ||
| it("extracts UPPER_SNAKE schema keys from env.ts-style text", () => { | ||
| const text = [ | ||
| "const envSchema = z.object({", | ||
| " NEXT_PUBLIC_SUPABASE_URL: z.string().url().optional(),", | ||
| " SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),", | ||
| " OPENAI_MAX_OUTPUT_TOKENS: z.coerce.number().default(16000),", | ||
| " notAKey: 3,", | ||
| "});", | ||
| ].join("\n"); | ||
| const names = parseEnvSchemaNames(text); | ||
| expect(names).toContain("NEXT_PUBLIC_SUPABASE_URL"); | ||
| expect(names).toContain("SUPABASE_SERVICE_ROLE_KEY"); | ||
| expect(names).toContain("OPENAI_MAX_OUTPUT_TOKENS"); | ||
| expect(names).not.toContain("notAKey"); | ||
| }); | ||
|
|
||
| it("extracts names from check-ci-env quoted literals and process.env access", () => { | ||
| const text = `const required = ["E2E_USER_EMAIL", "E2E_USER_PASSWORD"]; if (process.env.E2E_AUTH_ENABLED) {}`; | ||
| const names = parseCiEnvNames(text); | ||
| expect(names).toEqual(expect.arrayContaining(["E2E_USER_EMAIL", "E2E_USER_PASSWORD", "E2E_AUTH_ENABLED"])); | ||
| }); | ||
|
|
||
| it("reports missing expected secrets and unknown live names", () => { | ||
| const parity = computeParity({ | ||
| canonical: ["OPENAI_API_KEY", "SUPABASE_SERVICE_ROLE_KEY"], | ||
| liveNames: ["OPENAI_API_KEY", "LEFTOVER_OLD_KEY"], | ||
| expectedSecrets: ["OPENAI_API_KEY", "SUPABASE_SERVICE_ROLE_KEY"], | ||
| }); | ||
| expect(parity.missingSecrets).toEqual(["SUPABASE_SERVICE_ROLE_KEY"]); | ||
| expect(parity.unknownLive).toEqual(["LEFTOVER_OLD_KEY"]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("sweep-branch-ledger parsing", () => { | ||
| it("extracts claude/ and codex/ branch names from ledger markdown", () => { | ||
| const md = [ | ||
| "| 2026-07-10 | codex/design-ux-review-fixes | abc | scope | out | checks |", | ||
| "| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | def | s | o | c |", | ||
| ].join("\n"); | ||
| const names = parseLedgerBranches(md); | ||
| expect(names.has("codex/design-ux-review-fixes")).toBe(true); | ||
| expect(names.has("claude/answer-page-design-polish-ffd5a6")).toBe(true); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
--railwayis used, these CI-only E2E credentials are passed through the sameEXPECTED_SECRETSlist as runtime secrets, so a correctly configured Railway environment will be reported as missingE2E_USER_EMAIL/E2E_USER_PASSWORDand the command exits 1. The repo's deployment matrix documents those two as “CI only” and “GitHub repo secrets” (docs/deployment-architecture.md:294-299), so this makes the new Railway parity check fail in the intended production-runtime context or encourages putting test-account credentials into Railway unnecessarily.Useful? React with 👍 / 👎.