@homeflare/config 0.9.0 → 0.11.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 +5 -40
- package/bin/hooks.ts +8 -4
- package/dist/hooks/activate.d.ts +7 -0
- package/dist/hooks/activate.d.ts.map +1 -0
- package/dist/hooks/gates.d.ts +7 -14
- package/dist/hooks/gates.d.ts.map +1 -1
- package/dist/hooks/install.d.ts +7 -1
- package/dist/hooks/install.d.ts.map +1 -1
- package/dist/hooks/push-plan.d.ts +59 -0
- package/dist/hooks/push-plan.d.ts.map +1 -0
- package/dist/hooks/push-range.d.ts +51 -0
- package/dist/hooks/push-range.d.ts.map +1 -0
- package/dist/hooks/report.d.ts +29 -2
- package/dist/hooks/report.d.ts.map +1 -1
- package/dist/hooks/secrets.d.ts +2 -0
- package/dist/hooks/secrets.d.ts.map +1 -0
- package/dist/hooks.d.ts +30 -6
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +339 -31
- package/dist/hooks.js.map +11 -7
- package/dist/repo-shape/automerge.d.ts +17 -0
- package/dist/repo-shape/automerge.d.ts.map +1 -0
- package/dist/repo-shape/ci.d.ts.map +1 -1
- package/dist/repo-shape/companions.d.ts +2 -6
- package/dist/repo-shape/companions.d.ts.map +1 -1
- package/dist/repo-shape/dependabot.d.ts +35 -0
- package/dist/repo-shape/dependabot.d.ts.map +1 -0
- package/dist/repo-shape/guards.d.ts +25 -0
- package/dist/repo-shape/guards.d.ts.map +1 -0
- package/dist/repo-shape/render.d.ts.map +1 -1
- package/dist/repo-shape/shape.d.ts +40 -2
- package/dist/repo-shape/shape.d.ts.map +1 -1
- package/dist/repo-shape.d.ts +5 -2
- package/dist/repo-shape.d.ts.map +1 -1
- package/dist/repo-shape.js +269 -105
- package/dist/repo-shape.js.map +10 -7
- package/dist/versions.js +68 -31
- package/dist/versions.js.map +6 -5
- package/docs/hooks.md +99 -0
- package/docs/repo-shape-dependabot.md +126 -0
- package/docs/repo-shape-inputs.md +83 -0
- package/docs/repo-shape.md +5 -23
- package/package.json +1 -1
- package/src/hooks/activate.ts +71 -0
- package/src/hooks/gates.ts +87 -28
- package/src/hooks/install.ts +39 -20
- package/src/hooks/push-plan.ts +167 -0
- package/src/hooks/push-range.ts +177 -0
- package/src/hooks/report.ts +31 -6
- package/src/hooks/secrets.ts +42 -0
- package/src/hooks.ts +30 -14
- package/src/repo-shape/automerge.ts +144 -0
- package/src/repo-shape/ci.ts +35 -16
- package/src/repo-shape/companions.ts +2 -77
- package/src/repo-shape/dependabot.ts +136 -0
- package/src/repo-shape/guards.ts +68 -0
- package/src/repo-shape/render.ts +5 -1
- package/src/repo-shape/shape.ts +51 -35
- package/src/repo-shape.ts +8 -4
package/dist/versions.js.map
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/repo-shape/shape.ts", "../src/repo-shape/yaml.ts", "../src/repo-shape/ci.ts", "../src/versions.ts"],
|
|
3
|
+
"sources": ["../src/repo-shape/guards.ts", "../src/repo-shape/shape.ts", "../src/repo-shape/yaml.ts", "../src/repo-shape/ci.ts", "../src/versions.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * `
|
|
5
|
+
"/**\n * The runtime guards `repo-shape` needs and its types cannot express.\n *\n * ★ EXTRACTED FROM `shape.ts`, NOT INVENTED HERE. The types carry one half of the\n * contract — `Stated<R>` refuses an empty or computed reason at compile time — and\n * these carry the other half: the cases a literal type still lets through. Keeping\n * them in one file is what lets `shape.ts` stay the declaration and nothing else.\n *\n * ⚠️ EVERY MESSAGE STARTS `repo-shape:` AND NAMES THE FIELD. These throw at render or at\n * declaration, far from the workflow file they would otherwise break at job time, so\n * the message is the only thing pointing back at the call that caused it.\n */\n\nconst SENTENCE = 12;\n\nexport function 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\nexport function 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\nexport function 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\n/**\n * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`\n * and resolves the newest 24.x; a fractional or negative value renders YAML the action\n * accepts and then fails to resolve, mid-job, on the runner.\n */\nexport function requireMajor(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: node must be a positive integer major, got ${value}`);\n }\n return value;\n}\n\n/** `timeout-minutes` must be a positive whole number of minutes. */\nexport function requireMinutes(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: timeout must be a positive whole minute count, got ${value}`);\n }\n return value;\n}\n\nexport function 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",
|
|
6
|
+
"/**\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\nimport {\n requireIsoDate,\n requireJobId,\n requireMajor,\n requireMinutes,\n requireNonEmpty,\n requireSentence,\n} from './guards.ts';\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 /**\n * The step's `if:` condition, verbatim.\n *\n * ★ WIDENED RATHER THAN EXCEPTED, 2026-09-22. homeflare-kit's `consumer smoke test`\n * ends with a step that renders the packed tarball sizes onto the run summary, and\n * it carries `if: always()` on purpose — a FAILED smoke test is exactly when the\n * sizes are worth reading. Without this field kit had two options and both were\n * worse: drop the condition, losing the summary on the only runs that need it, or\n * `except('.github/workflows/ci.yml')`, which buys one repository its file back and\n * costs the estate the guarantee on it. One optional key gives every repository the\n * same freedom, which is the order of preference this module documents.\n * ⚠️ NOT VALIDATED. GitHub's expression grammar is the vendor's; a bad condition is\n * caught by `actionlint` in the `workflow lint` job, which is where it belongs.\n */\n readonly if?: 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/dependabot-automerge.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 * `timeout-minutes:` for this job. Omitted takes GitHub's 360-minute default.\n *\n * ⚠️ AN INPUT BECAUSE A HUNG JOB IS NOT A FAILED JOB. Measured 2026-09-22:\n * homeflare-blog's `runtime` job drives Playwright against a local workerd, and a\n * browser that never reaches its first paint holds a self-hosted slot for six hours\n * rather than reporting red. On a 3-slot pool that is the whole pool. Only a job\n * that starts something with its own wait — a browser, a server, a container — needs\n * this; `check` does not, because `bun run check` exits.\n */\n readonly timeout?: number;\n}\n\ninterface ExtraJobInput extends Omit<ExtraJob, 'needs' | 'bun' | 'timeout'> {\n readonly needs?: readonly string[];\n readonly bun?: boolean;\n readonly timeout?: number;\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 ...(job.timeout === undefined ? {} : { timeout: requireMinutes(job.timeout) }),\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 /**\n * Node major to install before Bun, for a repository whose own gate needs a real\n * `node` on `PATH`. Omit it — twelve of fourteen repositories are Bun-only.\n *\n * ⛔ AN INPUT, NOT AN EXCEPTION, AND THE MEASUREMENT SAYS WHY. The mini's job image\n * carries no node at all (ubuntu-latest always did), so a Bun-only prologue is right\n * for most of the estate and *silently wrong* for two repositories:\n * · homeflare-alerts — tests/alchemy-import.test.ts spawns `node` to prove the\n * modules load the way the Alchemy CLI (`node …/cli.js`) loads them. Without it,\n * three tests fail with `Executable not found in $PATH: \"node\"` (measured on the\n * runner, 2026-09-22).\n * · homeflare-blog — Payload requires Node >= 24.15, so every lane needs it.\n * Both repositories carried the same hand-written `actions/setup-node@v6` block\n * before this existed. Excepting `ci.yml` instead would hand the estate's two most\n * complicated CI files back to hand-editing, which is the opposite of the point.\n *\n * ⚠️ `package-manager-cache: false` IS RENDERED WITH IT. Bun does the installing here;\n * letting setup-node prime an npm cache costs time and caches nothing anyone reads.\n */\n readonly node?: number;\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\n/**\n * The validated Node major this shape asks for, or `undefined` for a Bun-only prologue.\n * ★ `RepoShape` is a plain object, so this is where `node:` is checked — at render, not\n * at declaration. A bad value fails the refresh rather than the job.\n */\nexport function nodeMajor(shape: RepoShape): number | undefined {\n return shape.node === undefined ? undefined : requireMajor(shape.node);\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
7
|
"/**\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 // ★ `name:` then `if:` then the body, because that is the order a reader scans: what\n // this step is, whether it runs, what it does. All three are ordinary mapping keys\n // to GitHub, so the order is for the person reading the diff, not the parser.\n const head: string[] = [];\n if (step.name !== undefined) head.push(`${indent(depth + 1)}name: ${scalar(step.name)}`);\n if (step.if !== undefined) head.push(`${indent(depth + 1)}if: ${scalar(step.if)}`);\n\n // ⚠️ The first line of the item carries the dash, whichever key it turns out to be —\n // an unnamed, unconditional step still inlines its `run:` or `uses:` after the dash\n // and lets any block body follow at its own indent.\n const [first = '', ...rest] = [...head, ...body];\n lines.push(`${indent(depth)}- ${first.trimStart()}`, ...rest);\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 * `.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, JobStep, RepoShape } from './shape.ts';\nimport { nodeMajor, 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';\nconst SETUP_NODE = 'actions/setup-node@v6';\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\nconst NODE_NOTE = ` # ⛔ REAL NODE, NOT BUN'S SHIM, AND ONLY WHERE THE GATE NEEDS IT. The mini's job\n # image has no node on PATH (ubuntu-latest always did), so a repository whose own\n # \\`check\\` spawns \\`node\\` — or whose framework demands a Node runtime — fails with\n # \\`Executable not found in $PATH: \"node\"\\` without this. Declared as \\`node:\\` in\n # repo-shape.ts, so it is one input rather than a hand-edited block per repository.\n # ⚠️ NO PACKAGE-MANAGER CACHE: bun does the installing, so priming npm's cache costs\n # time and caches nothing anything here reads.`;\n\nfunction prologue(shape: RepoShape): string {\n const node = nodeMajor(shape);\n const bun: JobStep[] = [\n { uses: SETUP_BUN, with: { 'bun-version': BUN_VERSION } },\n { run: 'bun install --frozen-lockfile' },\n ];\n if (node === undefined) return renderSteps([{ uses: CHECKOUT }, ...bun], 3);\n return [\n renderSteps([{ uses: CHECKOUT }], 3),\n NODE_NOTE,\n renderSteps(\n [{ uses: SETUP_NODE, with: { 'node-version': node, 'package-manager-cache': 'false' } }],\n 3,\n ),\n renderSteps(bun, 3),\n ].join('\\n');\n}\n\nfunction renderExtraJob(job: ExtraJob, shape: RepoShape, on: string): string {\n const needs =\n (job.needs ?? []).length === 0 ? '' : ` needs: [${(job.needs ?? []).join(', ')}]\\n`;\n // ★ A TIMEOUT IS THE JOB'S, NOT THE SHAPE'S. Only a job that starts something with its\n // own wait needs one, and it is rendered where a reader looks for it.\n const timeout = job.timeout === undefined ? '' : ` timeout-minutes: ${job.timeout}\\n`;\n const steps =\n job.bun === false\n ? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3)\n : [prologue(shape), 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${timeout} 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(shape)}\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, shape, 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
9
|
"/**\n * `@homeflare/config/versions`: the one aligned set of versions the estate runs.\n *\n * import { ESTATE_VERSIONS } from '@homeflare/config/versions';\n * ESTATE_VERSIONS.alchemy; // '2.0.0-beta.79'\n *\n * ★ WHY IT LIVES HERE. Until this file, the set had two homes and neither was reachable from\n * outside the kit. Bun was `BUN_VERSION` in repo-shape/ci.ts. Every other pin sat in\n * homeflare-kit's root `catalog`, which is authoritative but never published. Measured\n * 2026-09-22, read-only, from each repository's lockfile: seven app repositories resolve\n * `alchemy` 2.0.0-beta.78, the monorepo resolves beta.77 with `effect` rc.112, and one app\n * resolves TypeScript 5.9.3. None of them had anything to compare against.\n * `@homeflare/config` is the one package almost every estate repository already depends\n * on (repo-shape.ts header: 13 of 14), so the set goes here.\n *\n * ⛔ THE KIT'S ROOT CATALOG STAYS AUTHORITATIVE. `tests/estate-versions.test.ts` fails when any\n * value below differs from it, and it also fails when `bun` differs from `BUN_VERSION` or\n * from the root's `packageManager`. A catalog bump that does not reach this file is\n * therefore a red test in the same PR. Otherwise the estate would drift from a published\n * source that looks correct.\n *\n * ★ RUNTIME PINS FOLLOW THE PINNED ALCHEMY RELEASE, NEVER THE OTHER WAY. `effect` and\n * `@distilled.cloud/cloudflare` are the versions `alchemy@2.0.0-beta.79` was built and\n * tested against. That release's own `dependencies` pin every `@distilled.cloud/*` at\n * exactly 1.0.0-rc.12, and its workspace overrides pin `effect` 4.0.0-rc.115\n * (alchemy-run/alchemy `pnpm-workspace.yaml@v2.0.0-beta.79#overrides`). The test reads the\n * installed `alchemy/package.json` to prove the distilled pin. These values move only in\n * the same PR as the `alchemy` bump.\n *\n * ⚠️ PUBLISHING THE SET ENFORCES NOTHING. No consumer check reads it yet. Wiring it into\n * `checkProject`, so that each repository's lockfile is compared with it, is a separate,\n * estate-wide rollout. Doing that here would turn the next bump into thirteen red CIs at\n * once.\n */\nimport { BUN_VERSION } from './repo-shape/ci.ts';\n\n/** The package names the set pins. One entry per tool or runtime the whole estate shares. */\nexport type EstatePackage =\n | 'bun'\n | 'alchemy'\n | 'effect'\n | '@distilled.cloud/cloudflare'\n | 'typescript'\n | 'oxfmt'\n | 'oxlint'\n | '@types/bun';\n\n/**\n * Exact versions, never ranges. A range is what lets two repositories resolve two different\n * versions from one line, which is exactly the drift this set exists to name.\n */\nexport const ESTATE_VERSIONS: Readonly<Record<EstatePackage, string>> = {\n bun: BUN_VERSION,\n alchemy: '2.0.0-beta.79',\n effect: '4.0.0-rc.115',\n '@distilled.cloud/cloudflare': '1.0.0-rc.12',\n typescript: '7.0.2',\n oxfmt: '0.68.0',\n oxlint: '1.83.0',\n '@types/bun': '1.4.2',\n};\n"
|
|
9
10
|
],
|
|
10
|
-
"mappings": ";;
|
|
11
|
-
"debugId": "
|
|
11
|
+
"mappings": "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",
|
|
12
|
+
"debugId": "B457A8EBCFEA8F5F64756E2164756E21",
|
|
12
13
|
"names": []
|
|
13
14
|
}
|
package/docs/hooks.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# git hooks
|
|
2
|
+
|
|
3
|
+
One hook layer for the whole estate. The behaviour ships in `@homeflare/config`; a repo
|
|
4
|
+
commits a two-file delegating wrapper and a `prepare` line, and nothing else.
|
|
5
|
+
|
|
6
|
+
## Adopt
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun add -D @homeflare/config
|
|
10
|
+
bun node_modules/@homeflare/config/bin/hooks.ts install # writes .husky/pre-commit, .husky/pre-push
|
|
11
|
+
npm pkg set scripts.prepare="bun node_modules/@homeflare/config/bin/hooks.ts activate"
|
|
12
|
+
bun install # prepare → core.hooksPath=.husky
|
|
13
|
+
git add .husky/pre-commit .husky/pre-push package.json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A repo that used husky drops it: remove `husky` from `prepare` and from
|
|
17
|
+
`devDependencies`. `problemsInHooks` reports a `prepare` that still runs it.
|
|
18
|
+
|
|
19
|
+
## What runs
|
|
20
|
+
|
|
21
|
+
| hook | runs | cost |
|
|
22
|
+
| ------------ | ------------------------------------------------------------------------------------- | ---------- |
|
|
23
|
+
| `pre-commit` | `gitleaks git --staged`, then `oxfmt` + `oxlint --deny-warnings` on staged files | sub-second |
|
|
24
|
+
| `pre-push` | the repo's own `check`, with `bun test` narrowed to the push, `build`/`smoke` skipped | seconds |
|
|
25
|
+
|
|
26
|
+
⛔ **The secret scan is first and fails closed.** A missing `gitleaks` fails the commit
|
|
27
|
+
with the install line (`brew install gitleaks`). It does not skip: a scan that quietly
|
|
28
|
+
does nothing reads as coverage.
|
|
29
|
+
|
|
30
|
+
⚠️ **pre-commit rewrites files.** It formats the staged formattable files (`.md` too,
|
|
31
|
+
since house `oxfmt` formats markdown), names the ones it changed, and restages exactly
|
|
32
|
+
those. A file with unstaged edits on top is checked, never rewritten.
|
|
33
|
+
|
|
34
|
+
## pre-push, scoped to the push
|
|
35
|
+
|
|
36
|
+
The base comes from git. The hook reads the pushed refs on stdin:
|
|
37
|
+
|
|
38
|
+
- **Second push to a branch**: measured from what the remote already has, so only
|
|
39
|
+
the new commits are checked.
|
|
40
|
+
- **New branch**: measured from where it left `<remote>/HEAD`, `main` or `master`.
|
|
41
|
+
- **Force-push**, or a remote sha that was never fetched: also measured from where the
|
|
42
|
+
branch left the default branch.
|
|
43
|
+
- **Deletion**, or no file changed: nothing is checked.
|
|
44
|
+
- **No usable base** (no remote-tracking branch, no merge base, a shallow clone): every
|
|
45
|
+
lane runs and the tests run in full. The run gets wider, never empty.
|
|
46
|
+
|
|
47
|
+
The lanes are the repo's own `check`, read as an `&&` chain:
|
|
48
|
+
|
|
49
|
+
- `bun test …` becomes `bun test … --changed=<base>`. Bun follows the import graph
|
|
50
|
+
and runs only the test files the changed files can reach.
|
|
51
|
+
- `build*` and `smoke*` scripts are skipped. CI runs them on every pull request.
|
|
52
|
+
- A script that hides a `bun test` or a build is expanded. For example
|
|
53
|
+
`check → verify → bun test` becomes a narrowed test lane. Any other script runs
|
|
54
|
+
under its own name, exactly as written.
|
|
55
|
+
- A test script that is not a plain `bun test`, such as vitest with coverage thresholds
|
|
56
|
+
or `node --test`, runs in full and is labelled `(IN FULL)`.
|
|
57
|
+
- A push that changes a `package.json`, `bun.lock`, `bunfig.toml` or `tsconfig*.json`
|
|
58
|
+
runs the tests in full. Imports cannot see those files, but every test runs on them.
|
|
59
|
+
|
|
60
|
+
⛔ **It never calls `verify` by name.** In `homeflare-proxmox`, `verify` is a live
|
|
61
|
+
adoption verifier. The hook follows `verify` only when `check` itself delegates to it,
|
|
62
|
+
because then `verify` is what CI runs.
|
|
63
|
+
|
|
64
|
+
⚠️ **The hook no longer certifies that CI will pass.** It certifies that `check`'s lint
|
|
65
|
+
and type lanes pass and that every test the push can reach passes. The build, the smoke
|
|
66
|
+
test and the tests the push cannot reach are left to CI, and the success line says so.
|
|
67
|
+
|
|
68
|
+
## Every worktree
|
|
69
|
+
|
|
70
|
+
`activate` sets `core.hooksPath=.husky`. The path is relative and lives in the clone's
|
|
71
|
+
shared config, so each worktree runs its own checked-out `.husky/`, including a fresh
|
|
72
|
+
`git worktree add` that has never been installed.
|
|
73
|
+
|
|
74
|
+
husky's `.husky/_` only existed where husky had run, so every other worktree of the clone
|
|
75
|
+
had no hooks at all. In an uninstalled worktree, the wrapper prints that `bun install` is
|
|
76
|
+
needed and exits 0. It fails open, because the required checks on `main` are the gate.
|
|
77
|
+
|
|
78
|
+
`activate` does nothing under `CI`, or outside a git work tree. It never fails, because it
|
|
79
|
+
runs inside `bun install`.
|
|
80
|
+
|
|
81
|
+
⚠️ **While the rollout is in progress, husky can undo it.** `core.hooksPath` is one
|
|
82
|
+
value for the whole clone. If a worktree on a branch from before adoption runs
|
|
83
|
+
`bun install`, husky runs and resets it to `.husky/_`. That is the behaviour before this
|
|
84
|
+
change: installed worktrees keep their hooks, because husky's runner calls the same
|
|
85
|
+
tracked files, and never-installed worktrees have none. To restore it, run `bun install`
|
|
86
|
+
on an adopted branch, or `bun node_modules/@homeflare/config/bin/hooks.ts activate`.
|
|
87
|
+
|
|
88
|
+
⚠️ **Only a push of the checked-out commit can be checked.** The lanes run on the working
|
|
89
|
+
tree. Pushing another ref, as in `git push origin other-branch`, is reported as
|
|
90
|
+
**NOT CHECKED** and left to CI. It is never reported as passed.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { problemsInHooks } from '@homeflare/config/hooks';
|
|
94
|
+
|
|
95
|
+
expect(await problemsInHooks(process.cwd())).toEqual([]);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
⛔ `problemsInHooks` is deliberately **not** part of `checkProject`: folding it in would turn
|
|
99
|
+
every repo that has not adopted yet red on `main` in one commit. A repo opts in by calling it.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Dependabot, and how a kit release reaches a consumer
|
|
2
|
+
|
|
3
|
+
`repo-shape` renders `.github/dependabot.yml` and `.github/workflows/dependabot-automerge.yml`
|
|
4
|
+
together. The first opens one pull request per kit release in each consumer (the
|
|
5
|
+
`homeflare` group); the second arms GitHub's auto-merge on that pull request and on nothing
|
|
6
|
+
else. The branch ruleset's required checks decide whether it merges.
|
|
7
|
+
|
|
8
|
+
**Status (2026-09-22): the Actions half works; the bun half is blocked upstream.** See
|
|
9
|
+
[the blocker](#the-blocker-bunlock-lockfileversion-2) — it is the trigger for everything
|
|
10
|
+
below doing anything.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
Merging and releasing a kit change are automated. Bumping a consumer was not, and it is the
|
|
15
|
+
leg that silently stops: measured 2026-09-22, `homeflare-proxmox` and `homeflare-mini`
|
|
16
|
+
pinned `@homeflare/alchemy` 0.13.0 and `@homeflare/config` 0.5.1 while the kit had published
|
|
17
|
+
0.19.1 and 0.8.0. Tim's decision (2026-09-23): kit releases reach consumers by Dependabot,
|
|
18
|
+
grouped, checked daily, merged on green; no GitHub organization and no tokens.
|
|
19
|
+
|
|
20
|
+
## What each rendered choice rests on
|
|
21
|
+
|
|
22
|
+
Read on 2026-09-22 from GitHub's docs and from dependabot-core's source at v0.397.0.
|
|
23
|
+
|
|
24
|
+
| Choice | Why | Source |
|
|
25
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
26
|
+
| One bun block, daily | Two blocks for one ecosystem and target branch must have "no overlap in directories defined", so a group cannot have its own schedule | [options reference: `directories`][options] |
|
|
27
|
+
| `cooldown.exclude: ['@homeflare/*']` | Dependabot applies a 3-day cooldown "even when `cooldown` is not configured"; without the exclude, a release waits three days | [options reference: `cooldown`][options] |
|
|
28
|
+
| `cooldown.default-days: 7` | Keeps third-party updates to roughly the weekly pace they had, by age rather than by calendar | same |
|
|
29
|
+
| `homeflare` group first, no `update-types` | "If a dependency matches more than one rule, it's included in the first group that it matches"; a kit release moves as one set | [options reference: `groups`][options] |
|
|
30
|
+
| `daily` means Monday–Friday | "Use `daily` to run on every weekday, Monday to Friday" | [options reference: `schedule`][options] |
|
|
31
|
+
| Auto-merge by `gh pr merge --auto` | GitHub's documented pattern for Dependabot pull requests | [Automating Dependabot with GitHub Actions][automating] |
|
|
32
|
+
| Refuse when the base branch requires no check | `gh pr merge --auto` merges a CLEAN or UNSTABLE pull request at once instead of arming it, so only a required status check keeps the bump waiting for `check` | cli/cli `pkg/cmd/pr/merge/merge.go`, `isImmediatelyMergeable` (v2.101.0) |
|
|
33
|
+
| No `dependabot/fetch-metadata` | The same page labels it "not certified by GitHub"; the house CI is first-party only | same |
|
|
34
|
+
| Group recognised by branch name | `dependabot/bun/homeflare-<10 hex>`: prefix, package manager, directory (root collapses), then group name and the first 10 hex of an MD5 digest | dependabot-core `common/lib/dependabot/pull_request_creator/branch_namer/dependency_group_strategy.rb` |
|
|
35
|
+
| `contents: write` + `pull-requests: write` on the job only | A Dependabot-started run gets a read-only `GITHUB_TOKEN` unless the `permissions` key raises it; these two are what GitHub's own example grants | [Troubleshooting Dependabot on GitHub Actions][troubleshoot] |
|
|
36
|
+
| `--squash` | The house policy (`declareRepoPolicy`) allows squash merges only | `@homeflare/alchemy` `repo-policy-form.ts` |
|
|
37
|
+
|
|
38
|
+
## Where Dependabot runs, and the billing lock
|
|
39
|
+
|
|
40
|
+
GitHub-hosted runners are refused for the estate's private repositories (billing lock,
|
|
41
|
+
measured 2026-09-22). Dependabot is not affected, for two documented reasons and one
|
|
42
|
+
measurement:
|
|
43
|
+
|
|
44
|
+
- Dependabot "will always run on GitHub Actions, bypassing both Actions policy checks and
|
|
45
|
+
disablement" ([automating]).
|
|
46
|
+
- "Running Dependabot on standard GitHub-hosted or self-hosted runners **does not** count
|
|
47
|
+
towards your included GitHub Actions minutes" ([about Dependabot on Actions][concepts]).
|
|
48
|
+
- **Measured:** at 18:30Z a private estate repository's ordinary `ubuntu-latest` job was
|
|
49
|
+
refused with "The job was not started because recent account payments have failed or
|
|
50
|
+
your spending limit needs to be increased". At 19:56Z the same account's private
|
|
51
|
+
monorepo ran a `Dependabot Updates` job on a GitHub-hosted runner for 17 minutes; it
|
|
52
|
+
failed on a dependency resolution error, not on billing.
|
|
53
|
+
|
|
54
|
+
⛔ **It cannot run on the mini, and does not need to.** A self-hosted Dependabot runner must
|
|
55
|
+
be "Linux", "x64 architecture", with "Docker installed with access for the runner users",
|
|
56
|
+
and a standalone repository's runner must carry the `dependabot` label
|
|
57
|
+
([Dependabot on Actions reference][reference], [self-hosted how-to][selfhosted]). The mini's
|
|
58
|
+
runners are arm64 containers with no Docker socket, by design. Enabling "Dependabot on
|
|
59
|
+
self-hosted runners" with no such runner leaves jobs "queued indefinitely".
|
|
60
|
+
|
|
61
|
+
## The blocker: `bun.lock` lockfileVersion 2
|
|
62
|
+
|
|
63
|
+
Every estate repository's `bun.lock` is `"lockfileVersion": 2` (bun 1.4 raised the default;
|
|
64
|
+
oven-sh/bun pull request 31539). dependabot-core bundles bun 1.3.14 and sets
|
|
65
|
+
`MAX_SUPPORTED_LOCKFILE_VERSION = 1` (`bun/lib/dependabot/bun/bun_package_manager.rb`), so
|
|
66
|
+
the bun update fails before it reads a manifest. Measured on this repository, 2026-09-22
|
|
67
|
+
21:17Z, eight seconds after `.github/dependabot.yml` first landed:
|
|
68
|
+
|
|
69
|
+
ERROR Unsupported bun.lock 'lockfileVersion' 2 in /bun.lock.
|
|
70
|
+
The bun version Dependabot runs supports up to 1.
|
|
71
|
+
|
|
72
|
+
The github-actions update in the same push succeeded.
|
|
73
|
+
|
|
74
|
+
**TRIGGER:** dependabot/dependabot-core pull request 16071 ("bun: support bun.lock
|
|
75
|
+
lockfileVersion 2 and 3", open since 2026-08-28) is merged and deployed, and a
|
|
76
|
+
`Dependabot Updates` run for `bun` in an estate repository completes without that error.
|
|
77
|
+
Until then a kit release is bumped in each consumer by hand.
|
|
78
|
+
|
|
79
|
+
⛔ **Not a workaround: writing lockfileVersion 1.** bun 1.4's writer always emits the
|
|
80
|
+
current version, and version 2 is where bun enforces integrity for off-registry tarballs
|
|
81
|
+
and rejects unsafe git tags. Downgrading the lockfile to suit Dependabot gives both up.
|
|
82
|
+
|
|
83
|
+
## What still needs a person
|
|
84
|
+
|
|
85
|
+
- **A kit release that changes what `@homeflare/config` renders.** The group's pull
|
|
86
|
+
request fails the drift test by design ([repo-shape.md](repo-shape.md), "Bumping
|
|
87
|
+
`@homeflare/config` will go red before it goes green"). Run `bun run repo-shape:refresh`
|
|
88
|
+
on the Dependabot branch and push; the push starts CI as any person's push does.
|
|
89
|
+
⛔ The workflow cannot do it: "events triggered by the `GITHUB_TOKEN` will not create a
|
|
90
|
+
new workflow run" ([GITHUB_TOKEN][token]), so a refreshed commit pushed with it would
|
|
91
|
+
never get its checks.
|
|
92
|
+
- **A repository whose base branch requires no status check.** The arming job fails on
|
|
93
|
+
purpose there (measured 2026-09-22: `homeflare-builds`, whose ruleset is not deployed yet,
|
|
94
|
+
and `homeflare-desktop`). `gh pr merge --auto` would otherwise merge the bump at once,
|
|
95
|
+
before `check` ran. Deploy the repository's ruleset; the next Dependabot rebase arms it.
|
|
96
|
+
- **A release whose plan changes.** A merged bump deploys nothing, but the next deploy of
|
|
97
|
+
that consumer applies whatever the new kit plans. Read the plan before deploying.
|
|
98
|
+
|
|
99
|
+
⚠️ **The merge itself starts no workflow on `main`.** Auto-merge armed with `GITHUB_TOKEN`
|
|
100
|
+
merges as that token, and its push triggers nothing. The rendered `ci.yml` has no push
|
|
101
|
+
trigger anyway, and a bump carries no changeset, so nothing is lost — but a repository that
|
|
102
|
+
adds a `push: main` workflow should know it will not run for these merges.
|
|
103
|
+
|
|
104
|
+
⚠️ **`open-pull-requests-limit: 5` is per block.** Five stale third-party pull requests
|
|
105
|
+
could, in principle, hold the group back. dependabot-core runs grouped updates before
|
|
106
|
+
ungrouped ones in each job (`group_update_all_versions.rb`), but the limit is enforced by
|
|
107
|
+
the service, whose code is not public — so this is reasoned, not measured.
|
|
108
|
+
|
|
109
|
+
## Proving it on a repository
|
|
110
|
+
|
|
111
|
+
The run appears as event `dynamic`, workflow `Dependabot Updates`, within seconds of a
|
|
112
|
+
`dependabot.yml` change reaching the default branch (measured 2026-09-22: 8 s):
|
|
113
|
+
|
|
114
|
+
gh run list -R <owner>/<repo> --event dynamic --limit 10 \
|
|
115
|
+
--json workflowName,name,conclusion,createdAt
|
|
116
|
+
|
|
117
|
+
A failed run's reason is in its job log (`gh run view <id> --log`), not in its annotation,
|
|
118
|
+
which only says "The updater encountered one or more errors".
|
|
119
|
+
|
|
120
|
+
[options]: https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference
|
|
121
|
+
[automating]: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions
|
|
122
|
+
[troubleshoot]: https://docs.github.com/en/code-security/reference/supply-chain-security/troubleshoot-dependabot/dependabot-on-actions
|
|
123
|
+
[concepts]: https://docs.github.com/en/code-security/concepts/supply-chain-security/dependabot-on-actions
|
|
124
|
+
[reference]: https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-on-actions
|
|
125
|
+
[selfhosted]: https://docs.github.com/en/code-security/dependabot/maintain-dependencies/managing-dependabot-on-self-hosted-runners
|
|
126
|
+
[token]: https://docs.github.com/en/actions/concepts/security/github_token
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Every input a repository declares, and what it renders
|
|
2
|
+
|
|
3
|
+
`RepoShape` is the whole declaration. This is the field reference; the model —
|
|
4
|
+
why a difference is an input or an exception and never a hand edit — is in
|
|
5
|
+
[repo-shape.md](./repo-shape.md).
|
|
6
|
+
|
|
7
|
+
## The fields
|
|
8
|
+
|
|
9
|
+
| Field | Renders | Measured reason it is an input |
|
|
10
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
|
11
|
+
| `owner` | The `repo:` in `.changeset/config.json` | The only text that varied in that file across 11 of 13 repositories |
|
|
12
|
+
| `repository` | The same, plus the Dependabot target | As above |
|
|
13
|
+
| `runner` | `runs-on:` in every job, whether `.github/actionlint.yaml` exists, and the header's billing note | 12 repositories on the mini, 1 (`homeflare-kit`) on `ubuntu-latest`. One variable, three files |
|
|
14
|
+
| `publishes` | `access: public\|restricted`, whether `privatePackages` is written, whether Dependabot watches `/packages/*` | `@changesets/cli` versions **nothing** when `privatePackages.version` is absent — a silent no-op |
|
|
15
|
+
| `node` | `actions/setup-node@v6` before `setup-bun`, in `check` and every bun extra job | See below |
|
|
16
|
+
| `extraJobs` | One job per entry, its stated reason above it, and its id in the `ci` aggregate's `needs` | 4 genuinely different jobs across 3 repositories |
|
|
17
|
+
| `exceptions` | Nothing — it removes a file from the drift comparison | The escape hatch, priced so it is used last |
|
|
18
|
+
|
|
19
|
+
## `node` — the input added 2026-09-22
|
|
20
|
+
|
|
21
|
+
⛔ **The mini's job image carries no node at all.** `ubuntu-latest` always did, so a
|
|
22
|
+
Bun-only prologue is right for twelve repositories and _silently wrong_ for two:
|
|
23
|
+
|
|
24
|
+
- **homeflare-alerts** — `tests/alchemy-import.test.ts` spawns `node` to prove the modules
|
|
25
|
+
load the way the Alchemy CLI (`node …/cli.js`) loads them. Without a real node, three
|
|
26
|
+
tests fail with `Executable not found in $PATH: "node"`, measured on the runner.
|
|
27
|
+
- **homeflare-blog** — Payload requires Node >= 24.15, so every lane needs it.
|
|
28
|
+
|
|
29
|
+
Both had written the identical `actions/setup-node@v6` block by hand before this existed.
|
|
30
|
+
The alternative was `except({ file: '.github/workflows/ci.yml', … })` in both — which
|
|
31
|
+
would have handed the estate's two most complicated CI files back to hand-editing, in the
|
|
32
|
+
round whose whole purpose was to stop that.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
export const shape: RepoShape = {
|
|
36
|
+
owner: 'taslabs-net',
|
|
37
|
+
repository: 'homeflare-alerts',
|
|
38
|
+
runner: 'mini',
|
|
39
|
+
publishes: false,
|
|
40
|
+
node: 24, // a major, not a range: setup-node resolves the newest 24.x
|
|
41
|
+
};
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
⚠️ `package-manager-cache: false` is rendered with it. Bun does the installing, so priming
|
|
45
|
+
npm's cache costs time and caches nothing anything in the job reads.
|
|
46
|
+
|
|
47
|
+
★ **`workflow lint` never gets it.** That job installs nothing — actionlint is preloaded
|
|
48
|
+
on the image — so adding node there would pay for a toolchain no step uses.
|
|
49
|
+
|
|
50
|
+
## `timeout` on an extra job
|
|
51
|
+
|
|
52
|
+
`extraJob({ …, timeout: 15 })` renders `timeout-minutes: 15`.
|
|
53
|
+
|
|
54
|
+
⚠️ **A hung job is not a failed job.** homeflare-blog's `runtime` job drives Playwright
|
|
55
|
+
against a local workerd; a browser that never reaches its first paint holds a self-hosted
|
|
56
|
+
slot for GitHub's default 360 minutes rather than reporting red, and on a 3-slot pool that
|
|
57
|
+
is the whole pool. Only a job that starts something with its own wait — a browser, a
|
|
58
|
+
server, a container — needs one. `check` does not: `bun run check` exits.
|
|
59
|
+
|
|
60
|
+
## What the renderer owns, and what it does not
|
|
61
|
+
|
|
62
|
+
The rendered `check` job runs one step: `bun run check`, the repository's own gate.
|
|
63
|
+
|
|
64
|
+
That is deliberate. A workflow that re-lists `lint`, `types` and `test` is a second copy
|
|
65
|
+
of the gate, and a copy can check _less_ than the original. Measured 2026-09-22:
|
|
66
|
+
`bun run check` in `homeflare-kit` is `lint && types && build && test`, and its
|
|
67
|
+
`tests/dist.test.ts` skips itself when `dist/` is absent — so a workflow that ran the
|
|
68
|
+
three lanes without the build would drop that test silently and still report green.
|
|
69
|
+
`homeflare-alerts` runs `check:types` and `build:web` in its check; `homeflare-subnet-calc`
|
|
70
|
+
delegates to `verify`. All fourteen have a `check` script, and all fourteen are local-only.
|
|
71
|
+
|
|
72
|
+
So the split is:
|
|
73
|
+
|
|
74
|
+
| Owned by the renderer | Owned by the repository |
|
|
75
|
+
| ------------------------------------------------ | ------------------------------------------ |
|
|
76
|
+
| Triggers, permissions, concurrency, runner label | What `bun run check` runs |
|
|
77
|
+
| Action versions and their pins | Which extra jobs exist, each with a reason |
|
|
78
|
+
| The aggregate `ci` job and its `needs` | Its `package.json` scripts |
|
|
79
|
+
| The actionlint, Dependabot and changeset configs | — |
|
|
80
|
+
|
|
81
|
+
The cost, said plainly: a red X says `check` rather than naming the lane. `bun run check`
|
|
82
|
+
short-circuits on the first failure and names the lane in its output, which is the same
|
|
83
|
+
signal a person gets locally.
|
package/docs/repo-shape.md
CHANGED
|
@@ -150,28 +150,10 @@ Three further things the check refuses, each because it hides a reason:
|
|
|
150
150
|
|
|
151
151
|
## What the renderer owns, and what it does not
|
|
152
152
|
|
|
153
|
-
The rendered `check` job runs one step: `bun run check`, the repository's own gate
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
153
|
+
The rendered `check` job runs one step: `bun run check`, the repository's own gate — so
|
|
154
|
+
the renderer owns the plumbing and the repository owns what its gate runs. That split,
|
|
155
|
+
and every field of `RepoShape` with the measurement behind it, is in
|
|
156
|
+
[repo-shape-inputs.md](./repo-shape-inputs.md).
|
|
175
157
|
|
|
176
158
|
## Bumping `@homeflare/config` will go red before it goes green
|
|
177
159
|
|
|
@@ -179,7 +161,7 @@ signal a person gets locally.
|
|
|
179
161
|
renderer changes — a new action version, a fixed permission — every repository that bumps
|
|
180
162
|
`@homeflare/config` fails `bun run check` on the next run, because its committed files are
|
|
181
163
|
now the _old_ render. That includes the Dependabot pull request that does the bumping,
|
|
182
|
-
which cannot fix itself.
|
|
164
|
+
which cannot fix itself ([repo-shape-dependabot.md](repo-shape-dependabot.md) says why).
|
|
183
165
|
|
|
184
166
|
The fix is one command, and the failure names it. The rule that follows:
|
|
185
167
|
|