@homeflare/config 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/bin/hooks.ts +19 -0
- package/dist/hooks/gates.d.ts +19 -0
- package/dist/hooks/gates.d.ts.map +1 -0
- package/dist/hooks/install.d.ts +20 -0
- package/dist/hooks/install.d.ts.map +1 -0
- package/dist/hooks/report.d.ts +30 -0
- package/dist/hooks/report.d.ts.map +1 -0
- package/dist/hooks/staged.d.ts +19 -0
- package/dist/hooks/staged.d.ts.map +1 -0
- package/dist/hooks.d.ts +15 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +196 -0
- package/dist/hooks.js.map +14 -0
- package/dist/repo-shape/ci.d.ts +20 -0
- package/dist/repo-shape/ci.d.ts.map +1 -0
- package/dist/repo-shape/companions.d.ts +39 -0
- package/dist/repo-shape/companions.d.ts.map +1 -0
- package/dist/repo-shape/drift.d.ts +29 -0
- package/dist/repo-shape/drift.d.ts.map +1 -0
- package/dist/repo-shape/refresh.d.ts +18 -0
- package/dist/repo-shape/refresh.d.ts.map +1 -0
- package/dist/repo-shape/render.d.ts +39 -0
- package/dist/repo-shape/render.d.ts.map +1 -0
- package/dist/repo-shape/security.d.ts +19 -0
- package/dist/repo-shape/security.d.ts.map +1 -0
- package/dist/repo-shape/shape.d.ts +138 -0
- package/dist/repo-shape/shape.d.ts.map +1 -0
- package/dist/repo-shape/yaml.d.ts +18 -0
- package/dist/repo-shape/yaml.d.ts.map +1 -0
- package/dist/repo-shape.d.ts +43 -0
- package/dist/repo-shape.d.ts.map +1 -0
- package/dist/repo-shape.js +611 -0
- package/dist/repo-shape.js.map +17 -0
- package/docs/repo-shape.md +199 -0
- package/package.json +11 -1
- package/src/hooks/gates.ts +102 -0
- package/src/hooks/install.ts +95 -0
- package/src/hooks/report.ts +72 -0
- package/src/hooks/staged.ts +61 -0
- package/src/hooks.ts +48 -0
- package/src/repo-shape/ci.ts +215 -0
- package/src/repo-shape/companions.ts +151 -0
- package/src/repo-shape/drift.ts +130 -0
- package/src/repo-shape/refresh.ts +113 -0
- package/src/repo-shape/render.ts +88 -0
- package/src/repo-shape/security.ts +109 -0
- package/src/repo-shape/shape.ts +214 -0
- package/src/repo-shape/yaml.ts +96 -0
- package/src/repo-shape.ts +69 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/repo-shape/shape.ts", "../src/repo-shape/yaml.ts", "../src/repo-shape/ci.ts", "../src/repo-shape/companions.ts", "../src/repo-shape/security.ts", "../src/repo-shape/render.ts", "../src/repo-shape/drift.ts", "../src/repo-shape/refresh.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * `RepoShape` — one HomeFlare repository's tooling, as a value.\n *\n * ★ WHY THIS TYPE EXISTS. Measured across the estate on 2026-09-22: 13 repositories each\n * carry a hand-written `.github/workflows/ci.yml`, `security.yml`, `.github/actionlint.yaml`\n * and `.changeset/config.json`. The changeset configs were byte-identical apart from the\n * repository name in 11 of 13. The security workflows were not: five still carried\n * `push: branches: [main]` that the other eight had removed, five were missing the\n * `concurrency:` block entirely, two were missing `pull-requests: read` (whose absence\n * makes every pull-request scan fail 403), the gitleaks action was pinned at `@v2` in\n * five and `@v3` in eight, and the weekly cron minute took three different values. Not\n * one of those differences was a decision. They are what happens when the same edit is\n * applied by hand thirteen times.\n *\n * ⛔ SO: A DIFFERENCE IS EITHER AN INPUT OR AN EXCEPTION. Nothing else. A repository that\n * needs something the standard does not give it either widens this type — and every\n * repository gets the widening — or declares an exception with a stated reason. There\n * is no third option where a file is quietly edited, because `drift.ts` fails on it.\n *\n * ⚠️ THE REASON IS ENFORCED BY THE COMPILER, NOT BY REVIEW. `except()` and `extraJob()`\n * below refuse an empty reason and refuse a reason read out of a variable, so the text\n * has to be written at the exception. See `Stated`.\n */\n\n/** Where a repository's jobs run. */\nexport type RepoRunner =\n /**\n * `[self-hosted, homeflare-mini]` — the interim Linux arm64 runner on the Mac mini.\n * ⚠️ GitHub-hosted runners are refused for this account (billing lock, 2026-09-22).\n */\n | 'mini'\n /** `ubuntu-latest`. Public repositories, where GitHub-hosted minutes are free. */\n | 'github';\n\n/** One step in a rendered job. Either a `uses:` or a `run:`, never both. */\nexport type JobStep = {\n readonly name?: string;\n readonly env?: Readonly<Record<string, string>>;\n} & (\n | {\n readonly uses: string;\n readonly with?: Readonly<Record<string, string | number>>;\n readonly run?: never;\n }\n | { readonly run: string; readonly uses?: never; readonly with?: never }\n);\n\n/**\n * ⛔ A REASON MUST BE WRITTEN, NOT COMPUTED. `'' extends R` is true for the empty literal\n * AND for the wide `string` type, so both collapse to `never` and fail to typecheck:\n * `reason: ''` is refused, and so is `reason: someVariable`. Only a string literal\n * written at the call site survives — which is the whole point, because a reason\n * assembled at runtime is a reason nobody reads in the diff.\n * Verified against TypeScript 7.0.2 on 2026-09-22; `tests/repo-shape-reason.test.ts`\n * compiles the refusals and asserts they still fail.\n */\nexport type Stated<R extends string> = '' extends R ? never : R;\n\n/** Paths this package renders. A deviation names one of these, not an arbitrary file. */\nexport type RenderedPath =\n | '.changeset/config.json'\n | '.github/actionlint.yaml'\n | '.github/dependabot.yml'\n | '.github/workflows/ci.yml'\n | '.github/workflows/security.yml';\n\nexport interface RepoShapeException {\n /** The rendered file this repository does not take from the renderer. */\n readonly file: RenderedPath;\n /** Why — written at the exception, in a sentence a stranger can act on. */\n readonly reason: string;\n /** `YYYY-MM-DD` the exception was taken, so a stale one is visible. */\n readonly since: string;\n}\n\ninterface ExceptionInput {\n readonly file: RenderedPath;\n readonly reason: string;\n readonly since: string;\n}\n\n/**\n * Declare that this repository keeps its own copy of a rendered file.\n *\n * except({\n * file: '.github/workflows/ci.yml',\n * reason: 'Payload needs Node >=24.15, which the rendered job does not install',\n * since: '2026-09-22',\n * })\n *\n * ⛔ THE DRIFT CHECK STOPS CHECKING THAT FILE. That is the trade: an exception buys the\n * freedom to hand-edit one file and pays for it by losing the guarantee on that file.\n * Prefer widening `RepoShape` — then every repository benefits and nothing is lost.\n */\nexport function except<const E extends ExceptionInput>(\n exception: E & { readonly reason: Stated<E['reason']> },\n): RepoShapeException {\n return {\n file: exception.file,\n reason: requireSentence('reason', exception.reason),\n since: requireIsoDate(exception.since),\n };\n}\n\nexport interface ExtraJob {\n /** The job key in `jobs:`, e.g. `package`. Lower-case, dashes allowed. */\n readonly id: string;\n /**\n * The job's `name:`, which is also the status-check context. It joins the `ci`\n * aggregate's `needs`, so the branch ruleset still requires exactly one check.\n */\n readonly name: string;\n /** Why this repository has a job the other twelve do not. */\n readonly reason: string;\n /** Steps after checkout. The prologue (checkout, bun, install) is rendered for you. */\n readonly steps: readonly JobStep[];\n /** Job ids this one waits for. Default: none, so it runs beside `check`. */\n readonly needs?: readonly string[];\n /** `false` skips the rendered bun prologue — for a job that needs another toolchain. */\n readonly bun?: boolean;\n}\n\ninterface ExtraJobInput extends Omit<ExtraJob, 'needs' | 'bun'> {\n readonly needs?: readonly string[];\n readonly bun?: boolean;\n}\n\n/**\n * Declare a job this repository needs and the standard does not have.\n * ⛔ Same rule as `except`: the reason is a written literal or it does not compile.\n */\nexport function extraJob<const J extends ExtraJobInput>(\n job: J & { readonly reason: Stated<J['reason']> },\n): ExtraJob {\n if (job.steps.length === 0) {\n throw new Error(`repo-shape: extra job \"${job.id}\" has no steps`);\n }\n return {\n bun: job.bun ?? true,\n id: requireJobId(job.id),\n // ⚠️ A DISPLAY NAME IS NOT A SENTENCE. `build` and `consumer smoke test` are both\n // correct job names; only the REASON has to be prose, because only the reason is\n // there for a reader rather than for the checks list.\n name: requireNonEmpty('name', job.name),\n needs: [...(job.needs ?? [])],\n reason: requireSentence('reason', job.reason),\n steps: [...job.steps],\n };\n}\n\nexport interface RepoShape {\n /** Repository owner — a user or organization login. */\n readonly owner: string;\n /** Repository name, which is also the checkout directory name. */\n readonly repository: string;\n /** Where its jobs run. */\n readonly runner: RepoRunner;\n /**\n * `true` when the repository publishes an npm tarball. It decides `access` in the\n * changeset config and whether `privatePackages` is written at all — the two keys\n * that differ between `homeflare-kit` and every other repository.\n */\n readonly publishes: boolean;\n /** Jobs beyond `check` and `workflows`. Each carries its own stated reason. */\n readonly extraJobs?: readonly ExtraJob[];\n /** Rendered files this repository keeps its own copy of, each with a reason. */\n readonly exceptions?: readonly RepoShapeException[];\n}\n\nconst SENTENCE = 12;\n\nfunction requireNonEmpty(field: string, value: string): string {\n const trimmed = value.trim();\n if (trimmed.length === 0) throw new Error(`repo-shape: ${field} must not be blank`);\n return trimmed;\n}\n\nfunction requireSentence(field: string, value: string): string {\n const trimmed = value.trim();\n // ⚠️ THE TYPE CANNOT CATCH `reason: ' '`. `' '` is a non-empty literal, so `Stated`\n // lets it through and only this does not. Type and guard cover different halves.\n if (trimmed.length < SENTENCE) {\n throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);\n }\n return trimmed;\n}\n\nfunction requireIsoDate(value: string): string {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {\n throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);\n }\n return value;\n}\n\nfunction requireJobId(value: string): string {\n // ⛔ The id becomes a YAML key and a `needs:` entry. Anything else renders a workflow\n // GitHub rejects at parse time, which reports as \"workflow file issue\" with no line.\n if (!/^[a-z][a-z0-9_-]*$/.test(value)) {\n throw new Error(\n `repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`,\n );\n }\n return value;\n}\n\n/** `runs-on:` for a runner. */\nexport function runsOn(runner: RepoRunner): string {\n return runner === 'mini' ? '[self-hosted, homeflare-mini]' : 'ubuntu-latest';\n}\n\n/** Whether a rendered path is excepted by this shape. */\nexport function isExcepted(shape: RepoShape, file: RenderedPath): boolean {\n return (shape.exceptions ?? []).some((exception) => exception.file === file);\n}\n",
|
|
6
|
+
"/**\n * The smallest YAML writer that renders a job's steps, and nothing else.\n *\n * ★ NOT A YAML LIBRARY, DELIBERATELY. A general serializer would take a dependency every\n * consumer of `@homeflare/config` inherits, and it would strip the comments — which in\n * this house are the product. The rendered workflows are written as text with holes;\n * only the step lists, whose shape varies per repository, go through here.\n *\n * ⚠️ Bun can PARSE YAML natively (`Bun.YAML.parse`) but does not stringify it, measured\n * against Bun 1.4.0 on 2026-09-22. The tests parse what this writes with `Bun.YAML.parse`\n * and compare structures, so a malformed emission fails rather than shipping.\n */\nimport type { JobStep } from './shape.ts';\n\n/** Two spaces per level, the house indent and GitHub's own. */\nconst INDENT = ' ';\n\nfunction indent(depth: number): string {\n return INDENT.repeat(depth);\n}\n\n/**\n * ⛔ QUOTE ANYTHING YAML WOULD RE-READ AS SOMETHING ELSE. `09:00` is a sexagesimal number\n * in YAML 1.1 and `on`/`no` are booleans; an unquoted value that looks like either\n * reaches GitHub as the wrong type, and the symptom is a schedule that never fires\n * rather than an error.\n */\nconst PLAIN = /^[A-Za-z0-9_./][A-Za-z0-9_ ./@:+-]*$/;\nconst YAML_KEYWORD =\n /^(y|Y|n|N|on|On|ON|no|No|NO|yes|Yes|YES|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/;\n\nfunction scalar(value: string | number): string {\n if (typeof value === 'number') return String(value);\n if (value === '') return \"''\";\n const plain =\n PLAIN.test(value) &&\n !YAML_KEYWORD.test(value) &&\n !value.includes(': ') &&\n !value.includes(' #') &&\n // ⚠️ A LEADING DIGIT PLUS A COLON IS A YAML 1.1 SEXAGESIMAL NUMBER, not a string:\n // `09:00` would reach GitHub as an integer, and a schedule set to an integer\n // simply never fires. `bun run build:web` is safe because it does not start with\n // a digit, which is why this is narrower than \"contains a colon\".\n !/^\\d.*:/.test(value) &&\n value.trimEnd() === value;\n return plain ? value : `'${value.replaceAll(\"'\", \"''\")}'`;\n}\n\nfunction renderMapping(\n entries: Readonly<Record<string, string | number>>,\n depth: number,\n): string[] {\n return Object.entries(entries).map(([key, value]) => `${indent(depth)}${key}: ${scalar(value)}`);\n}\n\n/**\n * ★ BLOCK SCALAR FOR EVERY MULTI-LINE `run:`. A folded or quoted form would join the\n * lines, and a shell script whose `if` and `then` end up on one line is a syntax error\n * at job time rather than at lint time. `|` keeps them exactly as written.\n */\nfunction renderRun(command: string, depth: number): string[] {\n const lines = command.replace(/\\n+$/, '').split('\\n');\n if (lines.length === 1) return [`${indent(depth)}run: ${scalar(lines[0] ?? '')}`];\n return [`${indent(depth)}run: |`, ...lines.map((line) => `${indent(depth + 1)}${line}`)];\n}\n\n/** One step, as the lines of a `steps:` list item at `depth`. */\nexport function renderStep(step: JobStep, depth: number): string[] {\n const lines: string[] = [];\n const body =\n step.uses === undefined\n ? renderRun(step.run ?? '', depth + 1)\n : [`${indent(depth + 1)}uses: ${step.uses}`];\n\n if (step.name === undefined) {\n // ⚠️ The first line of the item carries the dash, so an unnamed step's `run:` or\n // `uses:` is inlined after it and any block body follows at its own indent.\n const [first = '', ...rest] = body;\n lines.push(`${indent(depth)}- ${first.trimStart()}`, ...rest);\n } else {\n lines.push(`${indent(depth)}- name: ${scalar(step.name)}`, ...body);\n }\n\n if (step.with !== undefined && Object.keys(step.with).length > 0) {\n lines.push(`${indent(depth + 1)}with:`, ...renderMapping(step.with, depth + 2));\n }\n if (step.env !== undefined && Object.keys(step.env).length > 0) {\n lines.push(`${indent(depth + 1)}env:`, ...renderMapping(step.env, depth + 2));\n }\n return lines;\n}\n\n/** A whole `steps:` list, already indented for a job at `depth`. */\nexport function renderSteps(steps: readonly JobStep[], depth: number): string {\n return steps.flatMap((step) => renderStep(step, depth)).join('\\n');\n}\n",
|
|
7
|
+
"/**\n * `.github/workflows/ci.yml`, rendered.\n *\n * ★ THE COMMENTS ARE PART OF THE RENDER, NOT DECORATION. Thirteen repositories carried\n * thirteen hand-edited copies of the same reasoning; measured 2026-09-22, the header\n * comment alone had four different wordings and the actionlint block had three. Written\n * here once, every repository gets the same explanation, and correcting it is one edit.\n *\n * ⛔ THE AGGREGATE `ci` JOB IS THE ONLY REQUIRED CHECK. Every other job feeds it through\n * `needs`, so adding a job never means editing a branch ruleset. `repoShapeChecks()`\n * returns exactly `['ci', 'secret scan']`, which is what `declareRepoPolicy` requires.\n */\nimport type { ExtraJob, RepoShape } from './shape.ts';\nimport { runsOn } from './shape.ts';\nimport { renderSteps } from './yaml.ts';\n\n/** Bun the whole estate is pinned to. One line, one place. */\nexport const BUN_VERSION = '1.4.0';\n/** actionlint the `workflow lint` job runs, and the mini's job image preloads. */\nexport const ACTIONLINT_VERSION = '1.7.12';\n\nconst CHECKOUT = 'actions/checkout@v7';\nconst SETUP_BUN = 'oven-sh/setup-bun@v2';\n\nfunction runnerNote(shape: RepoShape): string {\n if (shape.runner !== 'mini') {\n return \"# ⚠️ RUNS ON GitHub-HOSTED `ubuntu-latest`. Only a public repository should: hosted minutes\\n# are refused for this account's private repos (billing lock, 2026-09-22).\\n\";\n }\n return [\n '# ⚠️ RUNS ON THE MINI (`[self-hosted, homeflare-mini]`): GitHub-hosted runners are refused for',\n '# this account (billing lock, 2026-09-22), so every job here runs in a one-job Linux arm64',\n \"# container started by homeflare-mini's src/ci-runner (docs/ci-runner.md there). Each action\",\n `# below was checked for linux/arm64. The job image preloads bun ${\n BUN_VERSION\n } where setup-bun`,\n '# looks, gh, actionlint, gitleaks, typos and shfmt; no node on PATH.',\n '',\n ].join('\\n');\n}\n\nconst HEADER = `# Pull requests: is this code correct?\n#\n# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.\n# Its input is this repository's \\`repo-shape.ts\\`. Change that, then:\n# bun run repo-shape:refresh\n# A hand edit is reverted by the next refresh and fails \\`bun run check\\` before that.\n# A file this repository must own outright is declared as an \\`except({...})\\` with a\n# reason, which stops the check from comparing it — see @homeflare/config/repo-shape.\n#\n# ★ SPLIT BY CONCERN, NOT ONE BIG JOB — AND NOT ONE JOB PER COMMAND EITHER: lanes that\n# share a prologue are steps inside \\`check\\`, so the install is paid once.\n# ⛔ EVERY ACTION IS FIRST-PARTY OR THE VENDOR'S OWN, PINNED TO A MAJOR TAG.\n`;\n\nconst TRIGGER = `name: ci\n\non:\n # ⛔ NO \\`push: branches: [main]\\`. Every commit reaches main through a pull request whose\n # \\`ci\\` had to be green — the branch ruleset requires it and carries no bypass actors —\n # so a second run on the squash commit recomputed an answer it already had. Measured\n # 2026-09-15..22 across the estate: 613 of 619 main-push runs had a head_sha identical\n # to the merge_commit_sha of an already-green PR. That duplication was ~41% of the Mac\n # mini's entire CI load.\n # ⚠️ WHAT THIS GIVES UP, SAID OUT LOUD: the ~4% of merges whose base DID move between the\n # PR run and the squash no longer get a post-merge re-test. That run gated nothing — it\n # reported after main already had the commit — and the next PR, which branches from the\n # merged main, is what actually catches a semantic conflict.\n pull_request:\n\npermissions:\n contents: read\n\nconcurrency:\n group: ci-\\${{ github.ref }}\n cancel-in-progress: true\n`;\n\nconst CHECK_NOTE = ` # ── One job, one install ────────────────────────────────────────────────────\n # ★ WAS 3 SEPARATE JOBS: lint and format, types, tests. They shared an identical\n # prologue — checkout, setup-bun, \\`bun install --frozen-lockfile\\` — and the install,\n # not the check, was the cost: measured on the mini 2026-09-22, install p50 51s /\n # p90 81s against 1-3s for the command the install existed to enable. 3 jobs meant 3\n # installs and 3 containers to compute one answer, and on a 3-slot pool that was one\n # pull request asking for every slot to answer a single question.\n # ⛔ ONE STEP: \\`bun run check\\`, THE REPOSITORY'S OWN GATE, NOT A COPY OF ITS LANES.\n # The house rule is that CI runs the same command a person runs, and a workflow that\n # re-lists \\`lint\\`, \\`types\\`, \\`test\\` is a second copy of that command which can quietly\n # check LESS than the local gate. Measured 2026-09-22: \\`bun run check\\` in homeflare-kit\n # is \\`lint && types && build && test\\`, and its tests/dist.test.ts SKIPS ITSELF when\n # dist/ is absent — so a workflow running lint/types/test without the build would drop\n # that test silently and still report green. homeflare-alerts' check runs\n # \\`check:types\\` and \\`build:web\\`; homeflare-subnet-calc's delegates to \\`verify\\`. All\n # fourteen repositories have a \\`check\\` script and every one is local-only — no network,\n # no deploy. Checked, not assumed.\n # ★ THE SPLIT OF RESPONSIBILITY: this renderer owns the PLUMBING — triggers, permissions,\n # concurrency, runner, action versions, the aggregate gate — and the repository owns\n # WHAT ITS GATE RUNS, in package.json, where a change to it shows up in that\n # repository's own diff rather than in a workflow nobody reads.\n # ⚠️ THE TRADE, STATED: a red X says \\`check\\` rather than naming the lane. \\`bun run check\\`\n # short-circuits on the first failure and its output names the lane, which is the same\n # signal a person gets locally.`;\n\nconst WORKFLOWS_NOTE = ` # ★ \\`workflow lint\\` STAYS ITS OWN JOB on purpose: it needs no \\`bun install\\` at all\n # (actionlint is preloaded on the mini's job image — measured 5s end to end), so\n # folding it into \\`check\\` would make a YAML-only change pay the install for nothing.`;\n\nconst ACTIONLINT_NOTE = ` # ⛔ THERE IS NO FIRST-PARTY actionlint ACTION, and the npm package by that name is\n # an unrelated wasm port with no \\`bin\\`. The vendor's own documented CI path is\n # \\`download-actionlint.bash\\` — but that script does NOT verify a checksum (read at\n # the v${ACTIONLINT_VERSION} tag, 2026-09-16: \\`curl -L \"$url\" | tar xvz\\`, no sha256sum anywhere).\n # rhysd's releases DO publish a \\`_checksums.txt\\` per version; this verifies against\n # that instead of trusting the tarball on receipt.\n # ⚠️ ARCH FROM THE RUNNER, NOT HARD-CODED: the mini's runners are arm64, and an amd64\n # binary fails with \"exec format error\" (the checksum file lists both).`;\n\nconst INSTALL_ACTIONLINT = `set -euo pipefail\nversion=${ACTIONLINT_VERSION}\nif command -v actionlint >/dev/null 2>&1 &&\n [ \"$(actionlint -version 2>/dev/null | sed -n 1p)\" = \"\\${version}\" ]; then\n cp \"$(command -v actionlint)\" ./actionlint\n exit 0\nfi\ncase \"$(uname -m)\" in\n x86_64) arch=amd64 ;;\n aarch64 | arm64) arch=arm64 ;;\n *) echo \"unsupported architecture $(uname -m)\" >&2; exit 1 ;;\nesac\nfile=\"actionlint_\\${version}_linux_\\${arch}.tar.gz\"\nbase=\"https://github.com/rhysd/actionlint/releases/download/v\\${version}\"\ncurl -sSfLO \"\\${base}/\\${file}\"\ncurl -sSfLO \"\\${base}/actionlint_\\${version}_checksums.txt\"\ngrep \" \\${file}\\\\$\" \"actionlint_\\${version}_checksums.txt\" | sha256sum -c -\ntar xzf \"\\${file}\" actionlint`;\n\nconst AGGREGATE_NOTE = ` # ── The one check a branch rule can require ─────────────────────────────────\n # ★ A single required check that depends on all of them. Without this, adding a job\n # means editing the branch ruleset too, and forgetting to means the new job is\n # advisory without anyone noticing.\n # ⛔ FAILS ON ANYTHING OTHER THAN success, INCLUDING skipped. A job result has exactly\n # four values: success, failure, cancelled, skipped. Checking only the first two lets\n # a skipped job — a bad \\`if:\\` condition, a misconfigured dependency, a runner picking\n # up nothing — report this aggregate green with a required job never having run.`;\n\nconst VERIFY = `if [ \"\\${{ contains(needs.*.result, 'failure') }}\" = \"true\" ] || \\\\\n [ \"\\${{ contains(needs.*.result, 'cancelled') }}\" = \"true\" ] || \\\\\n [ \"\\${{ contains(needs.*.result, 'skipped') }}\" = \"true\" ]; then\n echo \"one or more required jobs did not succeed: \\${{ toJSON(needs.*.result) }}\" >&2\n exit 1\nfi`;\n\nfunction prologue(): string {\n return renderSteps(\n [\n { uses: CHECKOUT },\n { uses: SETUP_BUN, with: { 'bun-version': BUN_VERSION } },\n { run: 'bun install --frozen-lockfile' },\n ],\n 3,\n );\n}\n\nfunction renderExtraJob(job: ExtraJob, on: string): string {\n const needs =\n (job.needs ?? []).length === 0 ? '' : ` needs: [${(job.needs ?? []).join(', ')}]\\n`;\n const steps =\n job.bun === false\n ? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3)\n : [prologue(), renderSteps(job.steps, 3)].join('\\n');\n // ★ The stated reason is rendered into the file. A job nobody can explain is a job\n // nobody dares delete, so the explanation travels with it.\n return ` # ★ NOT PART OF THE STANDARD SHAPE — ${job.reason}\n ${job.id}:\n name: ${job.name}\n${needs} runs-on: ${on}\n steps:\n${steps}\n`;\n}\n\n/** The whole `ci.yml` for a shape. */\nexport function renderCi(shape: RepoShape): string {\n const on = runsOn(shape.runner);\n const extras = shape.extraJobs ?? [];\n const needs = ['check', 'workflows', ...extras.map((job) => job.id)];\n\n return `${HEADER}${runnerNote(shape)}${TRIGGER}\njobs:\n${CHECK_NOTE}\n check:\n name: check\n runs-on: ${on}\n steps:\n${prologue()}\n${renderSteps([{ run: 'bun run check' }], 3)}\n\n${WORKFLOWS_NOTE}\n workflows:\n name: workflow lint\n runs-on: ${on}\n steps:\n${renderSteps([{ uses: CHECKOUT }], 3)}\n${ACTIONLINT_NOTE}\n${renderSteps([{ name: 'Install actionlint (checksum-verified)', run: INSTALL_ACTIONLINT }], 3)}\n${renderSteps([{ name: 'Lint workflows', run: './actionlint -color' }], 3)}\n\n${extras.map((job) => `${renderExtraJob(job, on)}\\n`).join('')}${AGGREGATE_NOTE}\n ci:\n name: ci\n if: always()\n needs: [${needs.join(', ')}]\n runs-on: ${on}\n steps:\n${renderSteps([{ name: 'Verify every job succeeded', run: VERIFY }], 3)}\n`;\n}\n",
|
|
8
|
+
"/**\n * The three smaller rendered files: actionlint's config, the changeset config, and\n * Dependabot.\n *\n * ★ EACH ONE WAS MEASURED BEFORE IT WAS RENDERED (2026-09-22, across 13 repositories):\n * · `.changeset/config.json` — byte-identical apart from the repository name in 11 of\n * 13. The three that differed did so by accident: two pinned an older `$schema`\n * (3.1.4 against 4.0.1) and one used `@changesets/cli/changelog` instead of the\n * GitHub changelog everyone else had, which silently drops PR links from releases.\n * · `.github/actionlint.yaml` — 12 of 13 had it, with three wordings of one comment.\n * The one without it is the one repository still on `ubuntu-latest`, which is the\n * only case where it is genuinely not needed. That is an input, not an exception.\n * · `.github/dependabot.yml` — 1 of 14. Twelve repositories take no dependency or\n * Action updates at all, and nothing said so. Rendering it is the fix.\n */\nimport type { RepoShape } from './shape.ts';\n\n/**\n * ⚠️ PINNED, NOT `latest`. `unpkg.com/@changesets/config@latest/schema.json` would change\n * what editors validate against without a commit, and the two repositories that drifted\n * here drifted by being pinned to different versions — not by being pinned.\n */\nconst CHANGESET_SCHEMA = '4.0.1';\n\n/**\n * `.changeset/config.json`.\n *\n * ⛔ `privatePackages` IS WRITTEN ONLY FOR A REPOSITORY THAT DOES NOT PUBLISH, AND BOTH\n * OF ITS FIELDS ARE REQUIRED. `@changesets/cli` silently versions nothing when\n * `version` is absent, so a private repository without this key opens a Version\n * Packages PR that changes no version and tags no release — a release pipeline that\n * reports success and ships nothing.\n */\nexport function renderChangesetConfig(shape: RepoShape): string {\n const config = {\n $schema: `https://unpkg.com/@changesets/config@${CHANGESET_SCHEMA}/schema.json`,\n // ★ `@changesets/changelog-github` over the built-in: it writes the pull request and\n // author into CHANGELOG.md, which is the difference between a changelog you can\n // audit and a list of sentences.\n changelog: ['@changesets/changelog-github', { repo: `${shape.owner}/${shape.repository}` }],\n commit: false,\n fixed: [] as string[],\n linked: [] as string[],\n access: shape.publishes ? 'public' : 'restricted',\n baseBranch: 'main',\n updateInternalDependencies: 'patch',\n ignore: [] as string[],\n ...(shape.publishes ? {} : { privatePackages: { version: true, tag: false } }),\n };\n return `${JSON.stringify(config, undefined, 2)}\\n`;\n}\n\n/**\n * `.github/actionlint.yaml` — rendered only for a self-hosted runner.\n *\n * ★ DECLARED, NOT SUPPRESSED. actionlint's `runner-label` rule stays on, so a typo'd\n * label (`homeflare-mnii`) still fails the `workflow lint` job rather than silently\n * queueing a job no runner ever claims. Measured 2026-09-22 with actionlint 1.7.12: an\n * undeclared `homeflare-mini` is an error, not a warning, so this file is the reason\n * twelve repositories are green rather than a nicety.\n */\nexport function renderActionlintConfig(shape: RepoShape): string | undefined {\n if (shape.runner !== 'mini') return undefined;\n return `# actionlint's list of self-hosted runner labels (its \\`runner-label\\` check).\n#\n# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.\n# Refresh with \\`bun run repo-shape:refresh\\`.\n#\n# ★ DECLARED, NOT SUPPRESSED: the rule stays on, so a typo'd label (\\`homeflare-mnii\\`)\n# still fails the \\`workflow lint\\` job rather than queueing a job no runner claims.\n# \\`homeflare-mini\\` is the interim runner on the Mac mini (homeflare-mini's\n# docs/ci-runner.md); setting this repository's shape to \\`runner: 'github'\\` removes\n# this file with the \\`runs-on\\` that needed it.\nself-hosted-runner:\n labels:\n - homeflare-mini\n`;\n}\n\n/** `.github/dependabot.yml`. */\nexport function renderDependabot(shape: RepoShape): string {\n // ⚠️ `directories`, not `directory`, for bun: a workspace keeps a dependency in the\n // package that declares it, so pointing only at `/` leaves every `packages/*`\n // manifest unwatched — and the symptom is silence, not an error.\n const bunDirs = shape.publishes ? ['/', '/packages/*'] : ['/'];\n return `# Dependabot for ${shape.repository}.\n#\n# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.\n# Refresh with \\`bun run repo-shape:refresh\\`.\n#\n# ⛔ THIS FILE LIVES AT .github/dependabot.yml, NOT IN .github/workflows/. Dependabot is a\n# platform feature, not an Action — a config placed among the workflows is silently\n# ignored, and the symptom is simply that no pull requests ever arrive. Measured\n# 2026-09-22: 13 of 14 HomeFlare repositories had no dependabot config at all, so their\n# Actions and their toolchain went stale invisibly, which is exactly how nothing fails.\n#\n# ★ WHY GROUPED RATHER THAN ONE PR PER DEPENDENCY. The default opens a pull request per\n# outdated package; that is a trickle nobody reviews properly. Each group below is a set\n# that is either safe to take together or needs deciding together.\nversion: 2\n\nupdates:\n # ── The toolchain (bun.lock) ────────────────────────────────────────────────\n - package-ecosystem: bun\n directories:\n${bunDirs.map((dir) => ` - ${dir}`).join('\\n')}\n schedule:\n interval: weekly\n day: monday\n time: '09:00'\n timezone: America/New_York\n open-pull-requests-limit: 5\n commit-message:\n prefix: 'chore'\n include: scope\n labels: [dependencies]\n groups:\n # oxfmt and oxlint move together and only affect style. Minor and patch bumps are\n # noise unless they fail CI, which is what CI is for.\n lint-and-format:\n patterns: ['oxfmt', 'oxlint']\n update-types: [minor, patch]\n\n # ⚠️ MAJORS EXCLUDED DELIBERATELY. TypeScript majors change what typechecks;\n # changesets majors have renamed inputs and dropped compatibility (the action's v2\n # did both). These want reading, not merging on green.\n build-tooling:\n patterns: ['typescript', '@changesets/*', '@types/bun']\n update-types: [minor, patch]\n\n # ── The workflows themselves ────────────────────────────────────────────────\n # ★ Actions go stale invisibly: nothing fails, they just keep running old code. The\n # estate's first workflows pinned checkout@v5 (current: v7) and changesets/action@v1\n # (current: v2, with every input renamed) — measured 2026-09-15.\n - package-ecosystem: github-actions\n directory: /\n schedule:\n interval: weekly\n day: monday\n time: '09:00'\n timezone: America/New_York\n open-pull-requests-limit: 5\n commit-message:\n prefix: 'ci'\n labels: [dependencies, github-actions]\n groups:\n actions:\n patterns: ['*']\n update-types: [minor, patch]\n`;\n}\n",
|
|
9
|
+
"/**\n * `.github/workflows/security.yml`, rendered.\n *\n * ⛔ THIS FILE IS WHY THE RENDERER EXISTS. Measured across 13 repositories on 2026-09-22,\n * every one of these was a per-repo accident rather than a decision:\n * · `push: branches: [main]` still present in 5, removed in 8;\n * · the `concurrency:` block missing entirely in 5 — so a superseded scan ran to\n * completion holding one of the mini's 3 slots for a result nobody would read;\n * · `pull-requests: read` missing in 2, which makes every pull-request scan fail 403\n * before it inspects a single line;\n * · `gitleaks/gitleaks-action` pinned at `@v2` in 5 and `@v3` in 8, after GitHub\n * removed the Node 20 runtime v2 needs (2026-09-16);\n * · the weekly cron at three different minutes.\n * One renderer makes all thirteen the same file, and the next such fix is one commit.\n */\nimport type { RepoShape } from './shape.ts';\nimport { runsOn } from './shape.ts';\nimport { renderSteps } from './yaml.ts';\n\n/**\n * ⚠️ Off the hour deliberately — `:00` is when every scheduled workflow on GitHub fires\n * at once, and queue time is the result. One minute for the whole estate: a repository\n * does NOT get its own, because \"which minute is this repo on\" is a question with no\n * useful answer and 13 repos produced 3 different ones by accident.\n */\nconst CRON = '41 6 * * 1';\n\nconst HEADER = `# Secret scanning, on its own schedule and its own check.\n#\n# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.\n# Its input is this repository's \\`repo-shape.ts\\`; refresh with \\`bun run repo-shape:refresh\\`.\n#\n# ★ SEPARATE FROM ci.yml BY CONCERN. A secret scan answers a different question from \"is\n# this code correct\", it runs on a schedule as well as on pull requests (a rule added\n# tomorrow can find a secret committed today), and it needs full history, which the CI\n# jobs deliberately do not fetch.\n`;\n\nconst TRIGGER = `name: security\n\non:\n # ⛔ NO \\`push: branches: [main]\\`, for the reason ci.yml gives: main only moves by\n # squashing a pull request this same workflow already scanned, so the post-merge scan\n # re-read commits it had just cleared. The weekly cron is what covers \"a rule added\n # tomorrow finds a secret committed today\" — and it is the one that fetches full history.\n pull_request:\n schedule:\n - cron: '${CRON}'\n\npermissions:\n contents: read\n # 🔴 gitleaks-action lists pull-request commits through GitHub's API before scanning,\n # and posts inline annotations. Without this, \\`pull_request\\` runs fail 403 before\n # inspecting any code. Two of thirteen repositories were missing it (2026-09-22).\n pull-requests: read\n\n# ⛔ cancel-in-progress IS CONDITIONAL, NOT \\`true\\`. The scheduled run's ref is\n# refs/heads/main; a plain \\`true\\` would let anything sharing that group kill the weekly\n# full-history scan. Only pull-request runs are ever cancelled.\nconcurrency:\n group: security-\\${{ github.ref }}\n cancel-in-progress: \\${{ github.event_name == 'pull_request' }}\n`;\n\nconst FETCH_NOTE = ` with:\n # ⛔ FULL HISTORY. A shallow clone scans only the tip, which misses the case that\n # matters most: a secret added and then removed in a later commit. It is still\n # in the history, still fetchable, and still compromised.\n fetch-depth: 0`;\n\nconst GITLEAKS_NOTE = ` # ⛔ v3, NOT v2. GitHub removed the Node 20 runtime from hosted runners on 2026-09-16\n # (gitleaks/gitleaks-action's own v3.0.0 migration notes, verified 2026-09-17), so\n # v2 (\\`runs: node20\\`) fails outright now, with no opt-out flag. v3 changes only the\n # runtime to node24 — inputs, outputs and behaviour are unchanged.\n # ★ arm64-SAFE ON THE MINI, verified rather than assumed: v3's src/gitleaks.js builds\n # its download URL from \\`process.arch\\`, so the runner asks for\n # gitleaks_<version>_linux_arm64.tar.gz, an asset its default release publishes\n # (both read 2026-09-22). Nothing here is hard-coded to x64.\n # ★ No \\`GITLEAKS_LICENSE\\`: that env var is required only for GitHub Organization\n # accounts, and \\`taslabs-net\\` is a personal User account (2026-09-17,\n # \\`gh api users/taslabs-net --jq .type\\` -> \\`User\\`).`;\n\n/** The whole `security.yml` for a shape. */\nexport function renderSecurity(shape: RepoShape): string {\n const on = runsOn(shape.runner);\n // ⚠️ A GitHub ACTIONS EXPRESSION, NOT A TEMPLATE LITERAL. `${{ … }}` is interpolated by\n // the Actions runner at job time and must reach the file verbatim, so it is a regular\n // string on purpose — `no-template-curly-in-string` is warning about exactly the\n // confusion this comment resolves, and it is right to ask.\n // oxlint-disable-next-line no-template-curly-in-string\n const token = '${{ secrets.GITHUB_TOKEN }}';\n const scheduled =\n shape.runner === 'mini'\n ? `\\n # ⛔ THE SCHEDULED RUN GOES TO THE MINI TOO: if the runner daemon is down this queues\\n # rather than failing, so a missed weekly scan is silent. homeflare-mini's\\n # \\`ci-runner\\` vmalert group is what notices that, not this workflow.`\n : '';\n\n return `${HEADER}${TRIGGER}\njobs:\n secrets:\n name: secret scan${scheduled}\n runs-on: ${on}\n steps:\n${renderSteps([{ uses: 'actions/checkout@v7' }], 3)}\n${FETCH_NOTE}\n\n${GITLEAKS_NOTE}\n${renderSteps([{ uses: 'gitleaks/gitleaks-action@v3', env: { GITHUB_TOKEN: token } }], 3)}\n`;\n}\n",
|
|
10
|
+
"/**\n * `renderRepoShape` — one declaration, both halves of a standard repository.\n *\n * ★ THIS IS THE POINT OF THE WHOLE MODULE. A repository declares its shape once and gets\n * its FILES (here) and its GitHub SETTINGS (through `policy`, which is the options\n * object `@homeflare/alchemy`'s `declareRepoPolicy` takes). Before this, the workflow\n * that produces a status check and the ruleset that requires that status check were\n * written in two files by hand, and nothing connected them: renaming the aggregate job\n * would leave a required check that never reports, and a pull request would wait on it\n * forever with auto-merge armed. `checks` below is derived from the rendered workflows,\n * so the two cannot disagree.\n *\n * ⛔ NO IMPORT CROSSES BETWEEN THE PACKAGES, IN EITHER DIRECTION. `policy` is a plain\n * object that is structurally assignable to `RepoPolicyOptions`; `@homeflare/alchemy`\n * does not depend on `@homeflare/config` and this does not depend on Alchemy or Effect.\n * That matters because `@homeflare/config` is installed in 13 of 14 repositories and\n * `@homeflare/alchemy` in 4 — the drift check has to run everywhere, and it must not\n * drag a provider library into a repository that has no stack.\n * ⚠️ A structural seam is checked only where both sides are present: the assignability\n * test lives in `packages/alchemy/tests/repo-shape-policy.test.ts`, which is the one\n * place that imports both.\n */\nimport { renderCi } from './ci.ts';\nimport { renderActionlintConfig, renderChangesetConfig, renderDependabot } from './companions.ts';\nimport { renderSecurity } from './security.ts';\nimport type { RenderedPath, RepoShape } from './shape.ts';\n\n/** The status-check contexts a rendered repository reports. */\nexport interface RepoShapePolicy {\n readonly owner: string;\n readonly repository: string;\n /**\n * ⛔ EXACTLY THE AGGREGATES, NEVER THE LEAF JOBS. `ci` is `if: always()` and fails when\n * any job it needs did not succeed, so requiring it requires all of them. Requiring a\n * leaf job instead would mean a ruleset edit every time a job is added, and a context\n * that stops reporting leaves every pull request pending rather than failing.\n */\n readonly checks: readonly string[];\n}\n\nexport interface RenderedRepo {\n /** Every rendered file, by its path relative to the repository root. */\n readonly files: Readonly<Record<string, string>>;\n /**\n * The options `declareRepoPolicy(id, { ...rendered.policy, settings })` takes.\n * Squash-only, auto-merge, delete-branch-on-merge and the `main` ruleset come from\n * there; the check names come from here.\n */\n readonly policy: RepoShapePolicy;\n}\n\n/**\n * Render a repository's tooling files and the policy that matches them.\n *\n * const rendered = renderRepoShape(shape);\n * rendered.files['.github/workflows/ci.yml'] // the file\n * rendered.policy.checks // ['ci', 'secret scan']\n *\n * ⚠️ EXCEPTED FILES ARE STILL RENDERED. `files` is what the standard says this repository\n * should have; `drift.ts` is what decides which of them are compared. Keeping them here\n * means `--show` can print the standard version of an excepted file, which is how\n * anyone judges whether the exception is still worth its reason.\n */\nexport function renderRepoShape(shape: RepoShape): RenderedRepo {\n const files: Record<string, string> = {\n '.changeset/config.json': renderChangesetConfig(shape),\n '.github/dependabot.yml': renderDependabot(shape),\n '.github/workflows/ci.yml': renderCi(shape),\n '.github/workflows/security.yml': renderSecurity(shape),\n };\n\n const actionlint = renderActionlintConfig(shape);\n if (actionlint !== undefined) files['.github/actionlint.yaml'] = actionlint;\n\n return {\n files,\n policy: { checks: ['ci', 'secret scan'], owner: shape.owner, repository: shape.repository },\n };\n}\n\n/** Every path this renderer can emit, whatever a given shape asks for. */\nexport const RENDERED_PATHS: readonly RenderedPath[] = [\n '.changeset/config.json',\n '.github/actionlint.yaml',\n '.github/dependabot.yml',\n '.github/workflows/ci.yml',\n '.github/workflows/security.yml',\n];\n",
|
|
11
|
+
"/**\n * The drift check: re-render, compare, and say what to run.\n *\n * ★ WHAT MAKES THIS DIFFERENT FROM A LINT RULE. It does not describe good workflows; it\n * asserts that this repository's committed files ARE the rendered ones. A hand edit\n * fails here before it can spread, which is the mechanism — not the convention — that\n * keeps fourteen repositories the same.\n *\n * ⛔ IT REPORTS, IT DOES NOT REPAIR, exactly like `checkProject` in this package. A check\n * that silently rewrote the working tree would erase the evidence of what someone\n * changed, and the one case worth catching — a deliberate edit that should have been an\n * exception — is the case it would erase. `refresh.ts` is the separate, explicit writer.\n *\n * ⚠️ EVERY COMPARISON IS EXACT TEXT, NOT STRUCTURE. `checkProject` compares parsed values\n * because a project may reformat its copy of a preset. Here the file IS generated, so\n * any difference at all — including a reflowed comment — means the file did not come\n * out of the renderer, and reformatting it would defeat the point of generating it.\n * The one exception is line endings, normalised so a CRLF checkout is not \"drift\".\n */\nimport { type RenderedRepo, renderRepoShape } from './render.ts';\nimport type { RenderedPath, RepoShape, RepoShapeException } from './shape.ts';\n\n/** One thing to fix, in the imperative — the same `Problem` shape `./check` reports. */\nexport type Problem = string;\n\n/** The command that makes a difference go away. Printed with every drift problem. */\nexport const REFRESH_COMMAND = 'bun run repo-shape:refresh';\n\nfunction normalize(text: string): string {\n return text.replaceAll('\\r\\n', '\\n');\n}\n\nasync function readIfPresent(path: string): Promise<string | undefined> {\n const file = Bun.file(path);\n return (await file.exists()) ? normalize(await file.text()) : undefined;\n}\n\n/**\n * ⛔ AN EXCEPTION FOR A FILE THE SHAPE DOES NOT RENDER IS ITSELF A PROBLEM. Otherwise a\n * repository that moved from the mini to GitHub-hosted runners would keep a stale\n * `.github/actionlint.yaml` exception forever, and the exception list — the one place\n * that is supposed to hold every deviation — would be lying about one of them.\n */\nfunction staleExceptions(\n rendered: RenderedRepo,\n exceptions: readonly RepoShapeException[],\n): Problem[] {\n return exceptions\n .filter((exception) => rendered.files[exception.file] === undefined)\n .map(\n (exception) =>\n `${exception.file}: excepted (since ${exception.since}) but this shape renders no such file — drop the exception`,\n );\n}\n\n/**\n * ⚠️ A DUPLICATE EXCEPTION HIDES A REASON. Two `except()` entries for one file means one\n * of the two reasons is never read, and the one that loses is arbitrary. Refuse both.\n */\nfunction duplicateExceptions(exceptions: readonly RepoShapeException[]): Problem[] {\n const seen = new Set<RenderedPath>();\n const duplicated = new Set<RenderedPath>();\n for (const exception of exceptions) {\n if (seen.has(exception.file)) duplicated.add(exception.file);\n seen.add(exception.file);\n }\n return [...duplicated].map(\n (file) => `${file}: declared as an exception more than once — keep one, with one reason`,\n );\n}\n\nexport interface DriftReport {\n /** Empty when the committed files are the rendered ones. */\n readonly problems: readonly Problem[];\n /** Paths whose committed text differs from the render, excluding excepted files. */\n readonly drifted: readonly string[];\n /** Paths the repository declared it owns, with the reason it gave. */\n readonly excepted: readonly RepoShapeException[];\n}\n\n/**\n * Compare a repository's committed tooling files against its declared shape.\n *\n * const report = await driftInRepoShape(process.cwd(), shape);\n * expect(report.problems).toEqual([]);\n *\n * A declared exception passes. An undeclared difference fails, and so does a missing file.\n */\nexport async function driftInRepoShape(projectDir: string, shape: RepoShape): Promise<DriftReport> {\n const rendered = renderRepoShape(shape);\n const exceptions = shape.exceptions ?? [];\n const excepted = new Map(exceptions.map((exception) => [exception.file as string, exception]));\n const problems: Problem[] = [\n ...duplicateExceptions(exceptions),\n ...staleExceptions(rendered, exceptions),\n ];\n const drifted: string[] = [];\n\n for (const [path, expected] of Object.entries(rendered.files)) {\n if (excepted.has(path)) continue;\n const actual = await readIfPresent(`${projectDir}/${path}`);\n if (actual === undefined) {\n problems.push(`${path}: missing — run \\`${REFRESH_COMMAND}\\``);\n drifted.push(path);\n continue;\n }\n if (actual !== normalize(expected)) {\n drifted.push(path);\n problems.push(\n `${path}: differs from what @homeflare/config renders for this shape. ` +\n `Run \\`${REFRESH_COMMAND}\\` to take the standard, ` +\n `or declare it with except({ file: '${path}', reason: '…', since: '…' }) in repo-shape.ts.`,\n );\n }\n }\n\n return { drifted, excepted: exceptions, problems };\n}\n\n/**\n * Every rendered path that is NOT compared, with why. For a report, not for a gate.\n * ★ Printed by `refresh --check` so a passing run still names what it did not check.\n * An exception that stops being visible is an exception that stops being reconsidered.\n */\nexport function exceptionSummary(shape: RepoShape): readonly string[] {\n return (shape.exceptions ?? []).map(\n (exception) =>\n `${exception.file}: not checked — ${exception.reason} (since ${exception.since})`,\n );\n}\n",
|
|
12
|
+
"/**\n * The writer, and the CLI a repository wires to `repo-shape:refresh`.\n *\n * ⛔ SEPARATE FROM THE CHECK, ON PURPOSE. `drift.ts` never writes and this never gates;\n * a tool that did both would repair the working tree during CI and report green on a\n * repository whose committed files are still wrong.\n *\n * ★ IT ONLY WRITES WHAT CHANGED. An unchanged file keeps its mtime, so a refresh in a\n * watch-mode session does not restart anything, and `git status` after a no-op refresh\n * is empty rather than \"14 files touched\".\n *\n * ⚠️ IT NEVER WRITES AN EXCEPTED FILE. Refreshing a file the repository declared it owns\n * would overwrite the deviation the reason was written for — the one destructive thing\n * this could do, and the one it must not.\n */\nimport { REFRESH_COMMAND, driftInRepoShape, exceptionSummary } from './drift.ts';\nimport { renderRepoShape } from './render.ts';\nimport { type RepoShape, isExcepted } from './shape.ts';\nimport type { RenderedPath } from './shape.ts';\n\nexport interface RefreshResult {\n /** Paths written because their content changed or they were absent. */\n readonly written: readonly string[];\n /** Paths already correct. */\n readonly unchanged: readonly string[];\n /** Paths skipped because the shape declares an exception for them. */\n readonly skipped: readonly string[];\n}\n\n/** Write a repository's rendered files into `projectDir`. */\nexport async function refreshRepoShape(\n projectDir: string,\n shape: RepoShape,\n): Promise<RefreshResult> {\n const rendered = renderRepoShape(shape);\n const written: string[] = [];\n const unchanged: string[] = [];\n const skipped: string[] = [];\n\n for (const [path, contents] of Object.entries(rendered.files)) {\n if (isExcepted(shape, path as RenderedPath)) {\n skipped.push(path);\n continue;\n }\n const target = `${projectDir}/${path}`;\n const file = Bun.file(target);\n if ((await file.exists()) && (await file.text()) === contents) {\n unchanged.push(path);\n continue;\n }\n await Bun.write(target, contents);\n written.push(path);\n }\n\n return { skipped, unchanged, written };\n}\n\n/**\n * ★ `Bun.write(Bun.stdout, …)` RATHER THAN `console.log`. Two reasons, and the lint rule\n * is the lesser: a CLI's output is a stream it owns, and writing to it explicitly is\n * what lets `repoShapeCli` be called in a test and in a `repo-shape.ts` without either\n * one inheriting a global. `console` in `src/` is also off-limits in the house preset —\n * allowed under `tests/**` and `scripts/**`, and this is neither.\n */\nasync function say(\n stream: typeof Bun.stdout | typeof Bun.stderr,\n lines: readonly string[],\n): Promise<void> {\n if (lines.length === 0) return;\n await Bun.write(stream, `${lines.join('\\n')}\\n`);\n}\n\n/**\n * The `repo-shape:refresh` entry point. `--check` reports drift and exits non-zero\n * instead of writing, which is what a repository puts in CI when it does not want the\n * check inside `bun test`.\n */\nexport async function repoShapeCli(\n projectDir: string,\n shape: RepoShape,\n argv: readonly string[],\n): Promise<number> {\n if (argv.includes('--check')) {\n const report = await driftInRepoShape(projectDir, shape);\n // ★ The exceptions are printed on a PASSING run too. An exception that stops being\n // visible is an exception that stops being reconsidered.\n await say(\n Bun.stdout,\n exceptionSummary(shape).map((note) => `· ${note}`),\n );\n if (report.problems.length === 0) {\n const count = report.excepted.length;\n await say(Bun.stdout, [\n `repo shape: in step with @homeflare/config (${count} declared exception(s))`,\n ]);\n return 0;\n }\n await say(Bun.stderr, [\n ...report.problems.map((problem) => `✗ ${problem}`),\n '',\n `Run \\`${REFRESH_COMMAND}\\`, or declare the deviation with a reason.`,\n ]);\n return 1;\n }\n\n const result = await refreshRepoShape(projectDir, shape);\n await say(Bun.stdout, [\n ...result.written.map((path) => `wrote ${path}`),\n ...result.unchanged.map((path) => `ok ${path}`),\n ...result.skipped.map((path) => `excepted ${path}`),\n ]);\n return 0;\n}\n"
|
|
13
|
+
],
|
|
14
|
+
"mappings": "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",
|
|
15
|
+
"debugId": "4FA0365136CBD38764756E2164756E21",
|
|
16
|
+
"names": []
|
|
17
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# The standard repo shape, and how to except from it
|
|
2
|
+
|
|
3
|
+
A HomeFlare repository declares its tooling once. The declaration renders the files, and
|
|
4
|
+
the same declaration hands `@homeflare/alchemy`'s `declareRepoPolicy` the GitHub settings
|
|
5
|
+
and the ruleset. "Standard repo" is one object, not fourteen copies of five files.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Measured across the estate on 2026-09-22, before any of this existed:
|
|
10
|
+
|
|
11
|
+
| File | Copies | What differed, and whether it was a decision |
|
|
12
|
+
| -------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `.changeset/config.json` | 13 | Identical apart from the repo name in 11. Of the 3 that differed: 2 pinned an older `$schema`, 1 used the built-in changelog so its releases carry no PR links. **None deliberate.** |
|
|
14
|
+
| `.github/workflows/security.yml` | 13 | 5 still had `push: branches: [main]`; 5 had no `concurrency:` at all; 2 lacked `pull-requests: read`, whose absence makes every PR scan fail 403; the gitleaks action was `@v2` in 5 and `@v3` in 8, after GitHub dropped the Node 20 runtime v2 needs; the weekly cron took 3 different minutes. **None deliberate.** |
|
|
15
|
+
| `.github/workflows/ci.yml` | 13 | Two shapes (`lint`/`types`/`test` split in 6, one `check` job in 7), one repo still on `ubuntu-latest`, and four wordings of the same header comment. The genuinely different parts were four extra jobs across three repos. |
|
|
16
|
+
| `.github/actionlint.yaml` | 12 | Three wordings of one comment. The repo without it is the one on GitHub-hosted runners, which is the only case where it is not needed. |
|
|
17
|
+
| `.github/dependabot.yml` | 1 | Twelve repositories took no dependency or Action updates at all, and nothing said so. |
|
|
18
|
+
|
|
19
|
+
Every one of those rows is the same edit applied by hand a dozen times, landing in a
|
|
20
|
+
different subset each time. A renderer makes it one edit.
|
|
21
|
+
|
|
22
|
+
## Declaring a repository
|
|
23
|
+
|
|
24
|
+
`repo-shape.ts` at the repository root:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { repoShapeCli, type RepoShape } from '@homeflare/config/repo-shape';
|
|
28
|
+
|
|
29
|
+
export const shape: RepoShape = {
|
|
30
|
+
owner: 'taslabs-net',
|
|
31
|
+
repository: 'homeflare-proxmox',
|
|
32
|
+
runner: 'mini', // '[self-hosted, homeflare-mini]' — or 'github' for ubuntu-latest
|
|
33
|
+
publishes: false, // true only for a repository that ships an npm tarball
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (import.meta.main) {
|
|
37
|
+
process.exit(await repoShapeCli(import.meta.dir, shape, Bun.argv.slice(2)));
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`package.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"scripts": {
|
|
46
|
+
"repo-shape:refresh": "bun run repo-shape.ts"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`tests/repo-shape.test.ts` — this is the gate:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { expect, test } from 'bun:test';
|
|
55
|
+
import { driftInRepoShape } from '@homeflare/config/repo-shape';
|
|
56
|
+
import { shape } from '../repo-shape.ts';
|
|
57
|
+
|
|
58
|
+
test('the tooling files are the ones @homeflare/config renders', async () => {
|
|
59
|
+
const report = await driftInRepoShape(import.meta.dir + '/..', shape);
|
|
60
|
+
expect(report.problems).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then `bun run repo-shape:refresh` once, commit what it writes, and `bun run check` is the
|
|
65
|
+
drift check from then on.
|
|
66
|
+
|
|
67
|
+
### The GitHub half
|
|
68
|
+
|
|
69
|
+
In `alchemy.run.ts`, the same declaration:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { renderRepoShape } from '@homeflare/config/repo-shape';
|
|
73
|
+
import { declareRepoPolicy } from '@homeflare/alchemy/github';
|
|
74
|
+
import { shape } from './repo-shape.ts';
|
|
75
|
+
|
|
76
|
+
export default Effect.gen(function* () {
|
|
77
|
+
yield* declareRepoPolicy('repo', {
|
|
78
|
+
...renderRepoShape(shape).policy,
|
|
79
|
+
settings: { description: '…', visibility: 'private' },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`policy.checks` is derived from the rendered workflows, so the ruleset cannot require a
|
|
85
|
+
context no job reports. That failure mode has no error message: a required check that
|
|
86
|
+
never reports stays _pending_, and with auto-merge armed the pull request waits forever.
|
|
87
|
+
|
|
88
|
+
## Excepting
|
|
89
|
+
|
|
90
|
+
Two kinds of deviation, and they are not the same thing.
|
|
91
|
+
|
|
92
|
+
**An extra job** is work this repository genuinely has and others do not — a frontend
|
|
93
|
+
build, a Go toolchain, a consumer smoke test. It is not an exception to the standard; it
|
|
94
|
+
is an addition the standard supports:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
extraJobs: [
|
|
98
|
+
extraJob({
|
|
99
|
+
id: 'build',
|
|
100
|
+
name: 'build',
|
|
101
|
+
reason: 'ships a Vite frontend the Worker serves; no other estate repository does',
|
|
102
|
+
steps: [{ run: 'bun run build:web' }],
|
|
103
|
+
}),
|
|
104
|
+
],
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The job's name joins the `ci` aggregate's `needs`, so it is required without anyone
|
|
108
|
+
touching a ruleset, and the reason is rendered into the workflow above the job.
|
|
109
|
+
|
|
110
|
+
**An exception** is a rendered file this repository keeps its own copy of:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
exceptions: [
|
|
114
|
+
except({
|
|
115
|
+
file: '.github/workflows/ci.yml',
|
|
116
|
+
reason: 'Payload needs Node >=24.15, which the rendered job does not install',
|
|
117
|
+
since: '2026-09-22',
|
|
118
|
+
}),
|
|
119
|
+
],
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The drift check then stops comparing that file. That is the trade, and it is a real cost:
|
|
123
|
+
the repository buys the freedom to hand-edit one file and pays for it by losing the
|
|
124
|
+
guarantee on that file forever. **Prefer widening `RepoShape`** — then every repository
|
|
125
|
+
gets the fix and nothing is given up.
|
|
126
|
+
|
|
127
|
+
### A reason is enforced by the compiler
|
|
128
|
+
|
|
129
|
+
`except()` and `extraJob()` refuse an empty reason _and_ a reason read out of a variable.
|
|
130
|
+
Both collapse to `never`, so neither compiles:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
except({ file: '.github/workflows/ci.yml', reason: '', since: '2026-09-22' }); // ✗
|
|
134
|
+
except({ file: '.github/workflows/ci.yml', reason: whyVariable, since: '2026-09-22' }); // ✗
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The text has to be written at the call, in the diff, where someone reviewing will read it.
|
|
138
|
+
A reason assembled at runtime is a reason nobody reads.
|
|
139
|
+
`packages/config/tests/repo-shape-reason.test.ts` runs `tsc` over a fixture of exactly
|
|
140
|
+
these calls and asserts they still fail, so a TypeScript upgrade that made one legal turns
|
|
141
|
+
the suite red rather than quietly opening the door.
|
|
142
|
+
|
|
143
|
+
Three further things the check refuses, each because it hides a reason:
|
|
144
|
+
|
|
145
|
+
- an exception for a file this shape does not render — stale, and the exception list is
|
|
146
|
+
supposed to be the one honest inventory of deviations;
|
|
147
|
+
- the same file excepted twice — one of the two reasons would never be read;
|
|
148
|
+
- a blank or one-word reason — the type allows `' '` because it is a non-empty literal,
|
|
149
|
+
so a runtime guard catches that half.
|
|
150
|
+
|
|
151
|
+
## What the renderer owns, and what it does not
|
|
152
|
+
|
|
153
|
+
The rendered `check` job runs one step: `bun run check`, the repository's own gate.
|
|
154
|
+
|
|
155
|
+
That is deliberate. A workflow that re-lists `lint`, `types` and `test` is a second copy
|
|
156
|
+
of the gate, and a copy can check _less_ than the original. Measured 2026-09-22:
|
|
157
|
+
`bun run check` in `homeflare-kit` is `lint && types && build && test`, and its
|
|
158
|
+
`tests/dist.test.ts` skips itself when `dist/` is absent — so a workflow that ran the
|
|
159
|
+
three lanes without the build would drop that test silently and still report green.
|
|
160
|
+
`homeflare-alerts` runs `check:types` and `build:web` in its check; `homeflare-subnet-calc`
|
|
161
|
+
delegates to `verify`. All fourteen have a `check` script, and all fourteen are local-only.
|
|
162
|
+
|
|
163
|
+
So the split is:
|
|
164
|
+
|
|
165
|
+
| Owned by the renderer | Owned by the repository |
|
|
166
|
+
| ------------------------------------------------ | ------------------------------------------ |
|
|
167
|
+
| Triggers, permissions, concurrency, runner label | What `bun run check` runs |
|
|
168
|
+
| Action versions and their pins | Which extra jobs exist, each with a reason |
|
|
169
|
+
| The aggregate `ci` job and its `needs` | Its `package.json` scripts |
|
|
170
|
+
| The actionlint, Dependabot and changeset configs | — |
|
|
171
|
+
|
|
172
|
+
The cost, said plainly: a red X says `check` rather than naming the lane. `bun run check`
|
|
173
|
+
short-circuits on the first failure and names the lane in its output, which is the same
|
|
174
|
+
signal a person gets locally.
|
|
175
|
+
|
|
176
|
+
## Bumping `@homeflare/config` will go red before it goes green
|
|
177
|
+
|
|
178
|
+
⚠️ **This is the price of the check, and it is worth knowing before it happens.** When the
|
|
179
|
+
renderer changes — a new action version, a fixed permission — every repository that bumps
|
|
180
|
+
`@homeflare/config` fails `bun run check` on the next run, because its committed files are
|
|
181
|
+
now the _old_ render. That includes the Dependabot pull request that does the bumping,
|
|
182
|
+
which cannot fix itself.
|
|
183
|
+
|
|
184
|
+
The fix is one command, and the failure names it. The rule that follows:
|
|
185
|
+
|
|
186
|
+
> **A `@homeflare/config` bump and a `bun run repo-shape:refresh` land in the same pull
|
|
187
|
+
> request.** Never merge the bump alone, and never refresh without saying which version
|
|
188
|
+
> of the renderer produced the files.
|
|
189
|
+
|
|
190
|
+
The alternative — a check that tolerated an older render — is a check that tolerates
|
|
191
|
+
drift, which is the thing this exists to stop. A loud, one-command failure is the better
|
|
192
|
+
half of that trade, but it is a trade.
|
|
193
|
+
|
|
194
|
+
## What this does not render yet
|
|
195
|
+
|
|
196
|
+
`.github/workflows/release.yml`. Thirteen copies, 166–197 lines each, thirteen distinct
|
|
197
|
+
hashes — publishing to npm, tagging a GitHub Release, and running `changeset version`
|
|
198
|
+
differ enough between them that rendering it needs its own measurement pass rather than
|
|
199
|
+
being folded into this one. It is the next file, not a file that was decided against.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Shared tsconfig, oxlint, oxfmt, and non-npm release helpers for HomeFlare projects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Timothy Schneider",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"dist",
|
|
14
|
+
"docs",
|
|
14
15
|
"src",
|
|
16
|
+
"bin",
|
|
15
17
|
"tsconfig.base.json",
|
|
16
18
|
"tsconfig.lib.json",
|
|
17
19
|
"tsconfig.app.json",
|
|
@@ -35,6 +37,14 @@
|
|
|
35
37
|
"types": "./dist/check.d.ts",
|
|
36
38
|
"default": "./dist/check.js"
|
|
37
39
|
},
|
|
40
|
+
"./hooks": {
|
|
41
|
+
"types": "./dist/hooks.d.ts",
|
|
42
|
+
"default": "./dist/hooks.js"
|
|
43
|
+
},
|
|
44
|
+
"./repo-shape": {
|
|
45
|
+
"types": "./dist/repo-shape.d.ts",
|
|
46
|
+
"default": "./dist/repo-shape.js"
|
|
47
|
+
},
|
|
38
48
|
"./release": {
|
|
39
49
|
"types": "./dist/release.d.ts",
|
|
40
50
|
"default": "./dist/release.js"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two gates every HomeFlare repo gets, from one place.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE SPLIT IS BY COST. `pre-commit` touches only the staged files and is measured in
|
|
5
|
+
* hundreds of milliseconds, so it can run on every commit without anyone resenting
|
|
6
|
+
* it. `pre-push` runs the repo's own `bun run check` — seconds, once, before the
|
|
7
|
+
* change costs a slot on the shared self-hosted runner.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ NEITHER IS A GATE. Both are skippable with `--no-verify` and neither exists in a
|
|
10
|
+
* fresh clone until `bun install` runs `prepare`. The required checks on `main` stay
|
|
11
|
+
* the gate; these only make the cheap mistakes cheap to find.
|
|
12
|
+
*/
|
|
13
|
+
import { fail, note, ok, run, tool } from './report.ts';
|
|
14
|
+
import { fingerprints, staged } from './staged.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format and lint what is staged, restaging only what the formatter rewrote.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ THIS HOOK REWRITES FILES DURING A COMMIT, and it says so on the line where it
|
|
20
|
+
* happens. Without the restage, oxfmt would fix the worktree while the commit kept
|
|
21
|
+
* the unformatted bytes — CI then fails on a file that reads as correct locally.
|
|
22
|
+
*/
|
|
23
|
+
export async function preCommit(root: string): Promise<void> {
|
|
24
|
+
const oxfmt = tool(root, 'oxfmt');
|
|
25
|
+
const { formattable, code, partial } = await staged();
|
|
26
|
+
|
|
27
|
+
// ⛔ Checked, never rewritten — see staged.ts for why `git add` here would be theft.
|
|
28
|
+
if (partial.length > 0) {
|
|
29
|
+
note(`${partial.length} staged file(s) also have unstaged edits; checking without rewriting`);
|
|
30
|
+
if ((await run([...oxfmt, '--check', ...partial])) !== 0) {
|
|
31
|
+
fail(
|
|
32
|
+
'pre-commit',
|
|
33
|
+
'those partially staged files are unformatted, and rewriting them would stage your unstaged work',
|
|
34
|
+
'bun run format, then stage the files you meant to commit',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (formattable.length === 0) {
|
|
40
|
+
ok('pre-commit: nothing staged to format');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const before = await fingerprints(formattable);
|
|
45
|
+
if ((await run([...oxfmt, ...formattable])) !== 0) {
|
|
46
|
+
fail('pre-commit', 'oxfmt could not format the staged files', 'bun run format');
|
|
47
|
+
}
|
|
48
|
+
const after = await fingerprints(formattable);
|
|
49
|
+
const rewritten = formattable.filter((file) => before.get(file) !== after.get(file));
|
|
50
|
+
|
|
51
|
+
if (rewritten.length > 0) {
|
|
52
|
+
note(`oxfmt rewrote and restaged ${rewritten.length} file(s): ${rewritten.join(', ')}`);
|
|
53
|
+
if ((await run(['git', 'add', '--', ...rewritten])) !== 0) {
|
|
54
|
+
fail('pre-commit', 'could not restage the formatted files', 'git add the listed files');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ⚠️ `--deny-warnings` matches what CI runs. A hook that is laxer than CI is worse
|
|
59
|
+
// than no hook: it certifies a change CI will reject.
|
|
60
|
+
if (code.length > 0 && (await run([...tool(root, 'oxlint'), '--deny-warnings', ...code])) !== 0) {
|
|
61
|
+
fail(
|
|
62
|
+
'pre-commit',
|
|
63
|
+
`oxlint found problems in ${code.length} staged file(s)`,
|
|
64
|
+
'bun run lint:fix, then fix by hand what remains',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run the repo's own declared gate before the push reaches the runner.
|
|
73
|
+
*
|
|
74
|
+
* ★ IT CALLS `bun run check` RATHER THAN NAMING TOOLS. Every repo's `check` is the
|
|
75
|
+
* command CI runs; hard-coding `tsc` and `bun test` here would drift from whichever
|
|
76
|
+
* repo added a step, and the hook would certify a push CI rejects.
|
|
77
|
+
* ⛔ It does not widen a narrow `check`. If a repo's gate only looks at part of the
|
|
78
|
+
* tree, this hook inherits exactly that blind spot — fix the script, not the hook.
|
|
79
|
+
*/
|
|
80
|
+
export async function prePush(root: string): Promise<void> {
|
|
81
|
+
const manifest = Bun.file(`${root}/package.json`);
|
|
82
|
+
const pkg = (await manifest.exists())
|
|
83
|
+
? ((await manifest.json()) as { scripts?: Record<string, string> })
|
|
84
|
+
: {};
|
|
85
|
+
|
|
86
|
+
if (pkg.scripts?.['check'] === undefined) {
|
|
87
|
+
note('pre-push: no `check` script declared in package.json; nothing to run');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
note('pre-push: running `bun run check` — the same gate CI runs');
|
|
92
|
+
const started = Bun.nanoseconds();
|
|
93
|
+
if ((await run(['bun', 'run', 'check'])) !== 0) {
|
|
94
|
+
fail(
|
|
95
|
+
'pre-push',
|
|
96
|
+
'bun run check failed — CI would fail the same way, on a shared runner',
|
|
97
|
+
'bun run lint:fix, then bun run check until it is green',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ok(`pre-push: bun run check passed in ${((Bun.nanoseconds() - started) / 1e9).toFixed(1)}s`);
|
|
102
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adoption: the one wrapper every repo commits, and the check that it has not drifted.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY A WRAPPER AT ALL. husky can only run a file that is tracked in the repo, so
|
|
5
|
+
* something must be committed per repo. This keeps that something to a delegation
|
|
6
|
+
* whose text is owned HERE — the behaviour lives in one package, and a repo that
|
|
7
|
+
* edits its copy is reported as drift rather than quietly diverging.
|
|
8
|
+
* ★ ONE FILE, TWO NAMES. The wrapper reads the hook name from `$0`, so `pre-commit`
|
|
9
|
+
* and `pre-push` are byte-identical and there is a single text to keep in step.
|
|
10
|
+
*/
|
|
11
|
+
import { chmod, mkdir } from 'node:fs/promises';
|
|
12
|
+
|
|
13
|
+
/** The hook files this package installs, in the order a contributor meets them. */
|
|
14
|
+
export const HOOK_NAMES = ['pre-commit', 'pre-push'] as const;
|
|
15
|
+
|
|
16
|
+
/** Where the runner lives once `bun install` has run. Relative: git runs hooks at the root. */
|
|
17
|
+
const RUNNER = 'node_modules/@homeflare/config/bin/hooks.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ⚠️ IT EXITS 0 WHEN THE RUNNER IS ABSENT. A checkout with no `node_modules` would
|
|
21
|
+
* otherwise fail every commit with a module-resolution error, and the first thing
|
|
22
|
+
* anyone would do is delete the hook. Failing open is the right trade for a
|
|
23
|
+
* convenience; the required checks on `main` are what must fail closed.
|
|
24
|
+
*/
|
|
25
|
+
export const HUSKY_HOOK: string = `# HomeFlare shared git hook. The behaviour lives in @homeflare/config, not in this file,
|
|
26
|
+
# and the same bytes are installed as .husky/pre-commit and .husky/pre-push — the hook
|
|
27
|
+
# name comes from $0.
|
|
28
|
+
#
|
|
29
|
+
# ⚠️ A hook is a local convenience, not a gate: it is skippable with --no-verify and does
|
|
30
|
+
# not exist in a fresh clone until \`bun install\` runs the \`prepare\` script. The
|
|
31
|
+
# required checks on main stay the gate.
|
|
32
|
+
#
|
|
33
|
+
# Regenerate this file with: bun ${RUNNER} install
|
|
34
|
+
hook="${RUNNER}"
|
|
35
|
+
if [ ! -f "$hook" ]; then
|
|
36
|
+
echo "husky: $hook is missing — run 'bun install' to enable the HomeFlare hooks; skipping"
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
exec bun "$hook" "$(basename "$0")"
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
/** Write the wrapper into `.husky/`. Returns the paths written, relative to the project. */
|
|
43
|
+
export async function installHooks(projectDir: string): Promise<readonly string[]> {
|
|
44
|
+
await mkdir(`${projectDir}/.husky`, { recursive: true });
|
|
45
|
+
const written: string[] = [];
|
|
46
|
+
for (const name of HOOK_NAMES) {
|
|
47
|
+
const path = `${projectDir}/.husky/${name}`;
|
|
48
|
+
await Bun.write(path, HUSKY_HOOK);
|
|
49
|
+
// ⚠️ husky's own runner does `sh -e "$s"`, which does not need the execute bit, but
|
|
50
|
+
// `core.hooksPath=.husky` without husky does. Set it so both mechanisms work.
|
|
51
|
+
await chmod(path, 0o755);
|
|
52
|
+
written.push(`.husky/${name}`);
|
|
53
|
+
}
|
|
54
|
+
return written;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Report what stops this project's hooks from working. Empty means adopted.
|
|
59
|
+
*
|
|
60
|
+
* ⛔ DELIBERATELY NOT PART OF `checkProject`. Every repo in the estate runs that checker
|
|
61
|
+
* from a test; folding hook conformance into it would turn every repo that has not
|
|
62
|
+
* adopted yet red on `main` in the same commit. A repo opts in by calling this.
|
|
63
|
+
*/
|
|
64
|
+
export async function problemsInHooks(projectDir: string): Promise<readonly string[]> {
|
|
65
|
+
const problems: string[] = [];
|
|
66
|
+
const manifest = Bun.file(`${projectDir}/package.json`);
|
|
67
|
+
|
|
68
|
+
if (!(await manifest.exists())) return ['package.json: missing'];
|
|
69
|
+
const pkg = (await manifest.json()) as {
|
|
70
|
+
scripts?: Record<string, string>;
|
|
71
|
+
devDependencies?: Record<string, string>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (!(pkg.scripts?.['prepare'] ?? '').includes('husky')) {
|
|
75
|
+
problems.push('package.json: no "prepare": "husky" script — a fresh clone installs no hooks');
|
|
76
|
+
}
|
|
77
|
+
if (pkg.devDependencies?.['husky'] === undefined) {
|
|
78
|
+
problems.push('package.json: husky is not a devDependency');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const name of HOOK_NAMES) {
|
|
82
|
+
const file = Bun.file(`${projectDir}/.husky/${name}`);
|
|
83
|
+
if (!(await file.exists())) {
|
|
84
|
+
problems.push(`.husky/${name}: missing — run \`bun ${RUNNER} install\``);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if ((await file.text()) !== HUSKY_HOOK) {
|
|
88
|
+
problems.push(
|
|
89
|
+
`.husky/${name}: differs from the @homeflare/config wrapper — run \`bun ${RUNNER} install\`, or change it in the package`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return problems;
|
|
95
|
+
}
|