@homeflare/config 0.5.1 → 0.8.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.
Files changed (50) hide show
  1. package/README.md +46 -0
  2. package/bin/hooks.ts +19 -0
  3. package/dist/hooks/gates.d.ts +19 -0
  4. package/dist/hooks/gates.d.ts.map +1 -0
  5. package/dist/hooks/install.d.ts +20 -0
  6. package/dist/hooks/install.d.ts.map +1 -0
  7. package/dist/hooks/report.d.ts +33 -0
  8. package/dist/hooks/report.d.ts.map +1 -0
  9. package/dist/hooks/staged.d.ts +19 -0
  10. package/dist/hooks/staged.d.ts.map +1 -0
  11. package/dist/hooks.d.ts +15 -0
  12. package/dist/hooks.d.ts.map +1 -0
  13. package/dist/hooks.js +202 -0
  14. package/dist/hooks.js.map +14 -0
  15. package/dist/repo-shape/ci.d.ts +20 -0
  16. package/dist/repo-shape/ci.d.ts.map +1 -0
  17. package/dist/repo-shape/companions.d.ts +39 -0
  18. package/dist/repo-shape/companions.d.ts.map +1 -0
  19. package/dist/repo-shape/drift.d.ts +29 -0
  20. package/dist/repo-shape/drift.d.ts.map +1 -0
  21. package/dist/repo-shape/refresh.d.ts +18 -0
  22. package/dist/repo-shape/refresh.d.ts.map +1 -0
  23. package/dist/repo-shape/render.d.ts +39 -0
  24. package/dist/repo-shape/render.d.ts.map +1 -0
  25. package/dist/repo-shape/security.d.ts +22 -0
  26. package/dist/repo-shape/security.d.ts.map +1 -0
  27. package/dist/repo-shape/shape.d.ts +153 -0
  28. package/dist/repo-shape/shape.d.ts.map +1 -0
  29. package/dist/repo-shape/yaml.d.ts +18 -0
  30. package/dist/repo-shape/yaml.d.ts.map +1 -0
  31. package/dist/repo-shape.d.ts +43 -0
  32. package/dist/repo-shape.d.ts.map +1 -0
  33. package/dist/repo-shape.js +615 -0
  34. package/dist/repo-shape.js.map +17 -0
  35. package/docs/repo-shape.md +199 -0
  36. package/package.json +11 -1
  37. package/src/hooks/gates.ts +104 -0
  38. package/src/hooks/install.ts +95 -0
  39. package/src/hooks/report.ts +96 -0
  40. package/src/hooks/staged.ts +68 -0
  41. package/src/hooks.ts +48 -0
  42. package/src/repo-shape/ci.ts +215 -0
  43. package/src/repo-shape/companions.ts +151 -0
  44. package/src/repo-shape/drift.ts +130 -0
  45. package/src/repo-shape/refresh.ts +113 -0
  46. package/src/repo-shape/render.ts +88 -0
  47. package/src/repo-shape/security.ts +115 -0
  48. package/src/repo-shape/shape.ts +229 -0
  49. package/src/repo-shape/yaml.ts +100 -0
  50. package/src/repo-shape.ts +69 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * How a hook talks to whoever triggered it.
3
+ *
4
+ * ★ EVERY FAILURE PRINTS BOTH COMMANDS — the one that fixes it and the one that skips
5
+ * it. A hook that exits non-zero and says nothing teaches `--no-verify` as a reflex,
6
+ * and that switch turns off every check rather than the one that was wrong.
7
+ *
8
+ * ⚠️ Output goes to STDERR. Git hooks share stdout with porcelain in some flows, and a
9
+ * hook that writes there can corrupt what a caller is parsing.
10
+ */
11
+
12
+ /** The two hooks this package implements. Named exactly as the git hook files are. */
13
+ export type Hook = 'pre-commit' | 'pre-push';
14
+
15
+ const BYPASS: Record<Hook, string> = {
16
+ 'pre-commit': 'git commit --no-verify',
17
+ 'pre-push': 'git push --no-verify',
18
+ };
19
+
20
+ /**
21
+ * 🔴 A GIT HOOK EXPORTS `GIT_DIR` AND `GIT_INDEX_FILE`, AND EVERYTHING IT SPAWNS
22
+ * INHERITS THEM. Measured 2026-09-22 at the cost of two junk files and a stray commit
23
+ * on this repository's `main`: `pre-push` runs `bun run check`, `check` runs the test
24
+ * suite, and a test that builds a throwaway git repository and commits in it commits
25
+ * into THIS repository instead — `cwd` is ignored once `GIT_DIR` is set. The estate's
26
+ * own `packages/site/tests/checkout.test.ts` already carried a comment warning about
27
+ * exactly this, which is how much a convention is worth.
28
+ * ⛔ So the hook strips them rather than trusting fourteen test suites to remember. The
29
+ * gate must see the repository through `cwd`, the way CI does.
30
+ */
31
+ function withoutGitEnv(): Record<string, string | undefined> {
32
+ return Object.fromEntries(
33
+ Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Run a command, streaming its output. Returns its exit code.
39
+ * `isolated` drops the inherited `GIT_*` variables — see `withoutGitEnv`.
40
+ */
41
+ export async function run(cmd: readonly string[], isolated = false): Promise<number> {
42
+ const proc = Bun.spawn([...cmd], {
43
+ stdout: 'inherit',
44
+ stderr: 'inherit',
45
+ ...(isolated ? { env: withoutGitEnv() } : {}),
46
+ });
47
+ return await proc.exited;
48
+ }
49
+
50
+ /** Capture a command's stdout. Used for git plumbing only. */
51
+ export async function capture(cmd: readonly string[]): Promise<string> {
52
+ const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'ignore' });
53
+ const text = await new Response(proc.stdout).text();
54
+ await proc.exited;
55
+ return text;
56
+ }
57
+
58
+ /**
59
+ * Resolve a dev tool to the project's own copy.
60
+ *
61
+ * ⚠️ NOT `bunx` BY DEFAULT. On a cache miss `bunx` downloads from the registry, and a
62
+ * git hook that reaches the network mid-commit is a hang waiting for a flaky link.
63
+ * husky puts `node_modules/.bin` on PATH, but this runs outside husky in tests, so
64
+ * the local binary is named outright when it exists and `bunx` is only the fallback.
65
+ */
66
+ export function tool(root: string, name: string): readonly string[] {
67
+ const local = `${root}/node_modules/.bin/${name}`;
68
+ return Bun.file(local).size > 0 ? [local] : ['bunx', name];
69
+ }
70
+
71
+ /**
72
+ * ⚠️ `process.stderr.write`, NOT `console`. Two reasons, and the lint rule is the lesser
73
+ * one: a hook shares stdout with git porcelain in some flows, and a synchronous write
74
+ * is the only kind guaranteed to land before `process.exit` below throws the buffer
75
+ * away. Using `console.error` here would also make every consumer of this package
76
+ * need a `no-console` exemption for code they never call directly.
77
+ */
78
+ function line(text: string): void {
79
+ process.stderr.write(`${text}\n`);
80
+ }
81
+
82
+ export function ok(what: string): void {
83
+ line(`✓ ${what}`);
84
+ }
85
+
86
+ export function note(what: string): void {
87
+ line(` ${what}`);
88
+ }
89
+
90
+ /** ⛔ ALWAYS GIVE THE FIX. "lint failed" is a dead end; the command that repairs it is not. */
91
+ export function fail(hook: Hook, what: string, fix: string): never {
92
+ line(`\n✗ ${hook}: ${what}`);
93
+ line(` fix: ${fix}`);
94
+ line(` bypass: ${BYPASS[hook]} — CI still runs the real gate\n`);
95
+ process.exit(1);
96
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What git has staged, split by what the hook may safely touch.
3
+ *
4
+ * ⛔ A STAGED FILE THAT ALSO HAS UNSTAGED EDITS IS OFF LIMITS. Formatting it in place
5
+ * and running `git add` would sweep the contributor's work-in-progress into a commit
6
+ * they did not ask for — the single worst thing a hook can do. Those files are
7
+ * reported and checked, never rewritten.
8
+ */
9
+ import { capture } from './report.ts';
10
+
11
+ /**
12
+ * ⚠️ `.md` IS IN THIS LIST ON PURPOSE. House `oxfmt` formats markdown, so a hook that
13
+ * skipped it would let an unformatted changeset through and CI would fail a commit
14
+ * that looked clean locally. A hook must check what CI checks or it trains distrust.
15
+ */
16
+ const FORMATTABLE = /\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|md)$/;
17
+ const CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
18
+
19
+ export type Staged = {
20
+ /** Fully staged and formattable — safe to rewrite and restage. */
21
+ readonly formattable: readonly string[];
22
+ /** The subset of `formattable` that oxlint understands. */
23
+ readonly code: readonly string[];
24
+ /** Staged but also dirty in the worktree — checked, never rewritten. */
25
+ readonly partial: readonly string[];
26
+ };
27
+
28
+ /**
29
+ * 🔴 `-z`, AND SPLIT ON NUL. Measured 2026-09-22: without it git applies `core.quotePath`
30
+ * and a file named `café .ts` comes back as the literal 12 characters
31
+ * `"caf\303\251 .ts"` — quotes, backslashes and octal escapes. Passing that to oxfmt
32
+ * names a file that does not exist, so every commit touching it fails with a message
33
+ * about the wrong path, and the fix anyone would reach for is `--no-verify`.
34
+ */
35
+ async function names(args: readonly string[]): Promise<readonly string[]> {
36
+ const out = await capture(['git', ...args, '-z']);
37
+ return out.split('\0').filter((line) => line.length > 0);
38
+ }
39
+
40
+ /** Classify the index. Deletions are excluded — there is nothing to format in them. */
41
+ export async function staged(): Promise<Staged> {
42
+ const indexed = await names(['diff', '--cached', '--name-only', '--diff-filter=ACMR']);
43
+ const dirty = new Set(await names(['diff', '--name-only', '--diff-filter=ACMR']));
44
+ const candidates = indexed.filter((file) => FORMATTABLE.test(file));
45
+
46
+ return {
47
+ formattable: candidates.filter((file) => !dirty.has(file)),
48
+ code: candidates.filter((file) => !dirty.has(file) && CODE.test(file)),
49
+ partial: candidates.filter((file) => dirty.has(file)),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Content fingerprints, so only the files a formatter actually changed get restaged.
55
+ *
56
+ * ★ WHY NOT RESTAGE EVERYTHING. `git add` on an untouched file is harmless but noisy:
57
+ * the hook would claim it rewrote files it left alone, and a hook that overstates
58
+ * what it did is one nobody reads.
59
+ */
60
+ export async function fingerprints(files: readonly string[]): Promise<ReadonlyMap<string, string>> {
61
+ const out = new Map<string, string>();
62
+ for (const file of files) {
63
+ const handle = Bun.file(file);
64
+ if (!(await handle.exists())) continue;
65
+ out.set(file, String(Bun.hash(await handle.arrayBuffer())));
66
+ }
67
+ return out;
68
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The HomeFlare git hooks, as a package.
3
+ *
4
+ * ★ WHY THIS IS NOT A SCRIPT IN EVERY REPO. Fourteen copies of a hook script is the
5
+ * exact drift `@homeflare/config` exists to prevent: the copies diverge, nobody
6
+ * notices, and two repos disagree about what a commit must satisfy. Here the repo
7
+ * commits a delegating wrapper and the behaviour ships with the package, so changing
8
+ * the rule is one release and a version bump rather than fourteen edits.
9
+ *
10
+ * ⚠️ HUSKY, NOT lefthook OR A BARE `core.hooksPath`. It is already the estate's
11
+ * mechanism in the repos that have working hooks, it installs from `prepare` on a
12
+ * plain `bun install`, and it keeps the hook files tracked and reviewable. A second
13
+ * mechanism alongside it would mean two ways to answer "are hooks on in this repo".
14
+ *
15
+ * Usage from a hook file — see `HUSKY_HOOK`:
16
+ *
17
+ * bun node_modules/@homeflare/config/bin/hooks.ts pre-commit
18
+ */
19
+ import { preCommit, prePush } from './hooks/gates.ts';
20
+ import { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks } from './hooks/install.ts';
21
+ import { type Hook, note, ok } from './hooks/report.ts';
22
+
23
+ export { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks };
24
+ export type { Hook };
25
+
26
+ /** The commands `bin/hooks.ts` accepts. */
27
+ export type Command = Hook | 'install';
28
+
29
+ export function isCommand(value: string): value is Command {
30
+ return value === 'pre-commit' || value === 'pre-push' || value === 'install';
31
+ }
32
+
33
+ /**
34
+ * Run one hook, or install the wrappers.
35
+ *
36
+ * ⛔ Never exits non-zero for a reason the caller cannot act on: an unknown command is a
37
+ * programming error in the wrapper and is reported as such, not as a failed commit.
38
+ */
39
+ export async function runCommand(command: Command, root: string): Promise<void> {
40
+ if (command === 'install') {
41
+ const written = await installHooks(root);
42
+ ok(`wrote ${written.join(', ')} — commit them`);
43
+ note('they do nothing until `bun install` runs `prepare` (husky)');
44
+ return;
45
+ }
46
+ if (command === 'pre-commit') return await preCommit(root);
47
+ return await prePush(root);
48
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `.github/workflows/ci.yml`, rendered.
3
+ *
4
+ * ★ THE COMMENTS ARE PART OF THE RENDER, NOT DECORATION. Thirteen repositories carried
5
+ * thirteen hand-edited copies of the same reasoning; measured 2026-09-22, the header
6
+ * comment alone had four different wordings and the actionlint block had three. Written
7
+ * here once, every repository gets the same explanation, and correcting it is one edit.
8
+ *
9
+ * ⛔ THE AGGREGATE `ci` JOB IS THE ONLY REQUIRED CHECK. Every other job feeds it through
10
+ * `needs`, so adding a job never means editing a branch ruleset. `repoShapeChecks()`
11
+ * returns exactly `['ci', 'secret scan']`, which is what `declareRepoPolicy` requires.
12
+ */
13
+ import type { ExtraJob, RepoShape } from './shape.ts';
14
+ import { runsOn } from './shape.ts';
15
+ import { renderSteps } from './yaml.ts';
16
+
17
+ /** Bun the whole estate is pinned to. One line, one place. */
18
+ export const BUN_VERSION = '1.4.0';
19
+ /** actionlint the `workflow lint` job runs, and the mini's job image preloads. */
20
+ export const ACTIONLINT_VERSION = '1.7.12';
21
+
22
+ const CHECKOUT = 'actions/checkout@v7';
23
+ const SETUP_BUN = 'oven-sh/setup-bun@v2';
24
+
25
+ function runnerNote(shape: RepoShape): string {
26
+ if (shape.runner !== 'mini') {
27
+ 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";
28
+ }
29
+ return [
30
+ '# ⚠️ RUNS ON THE MINI (`[self-hosted, homeflare-mini]`): GitHub-hosted runners are refused for',
31
+ '# this account (billing lock, 2026-09-22), so every job here runs in a one-job Linux arm64',
32
+ "# container started by homeflare-mini's src/ci-runner (docs/ci-runner.md there). Each action",
33
+ `# below was checked for linux/arm64. The job image preloads bun ${
34
+ BUN_VERSION
35
+ } where setup-bun`,
36
+ '# looks, gh, actionlint, gitleaks, typos and shfmt; no node on PATH.',
37
+ '',
38
+ ].join('\n');
39
+ }
40
+
41
+ const HEADER = `# Pull requests: is this code correct?
42
+ #
43
+ # 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
44
+ # Its input is this repository's \`repo-shape.ts\`. Change that, then:
45
+ # bun run repo-shape:refresh
46
+ # A hand edit is reverted by the next refresh and fails \`bun run check\` before that.
47
+ # A file this repository must own outright is declared as an \`except({...})\` with a
48
+ # reason, which stops the check from comparing it — see @homeflare/config/repo-shape.
49
+ #
50
+ # ★ SPLIT BY CONCERN, NOT ONE BIG JOB — AND NOT ONE JOB PER COMMAND EITHER: lanes that
51
+ # share a prologue are steps inside \`check\`, so the install is paid once.
52
+ # ⛔ EVERY ACTION IS FIRST-PARTY OR THE VENDOR'S OWN, PINNED TO A MAJOR TAG.
53
+ `;
54
+
55
+ const TRIGGER = `name: ci
56
+
57
+ on:
58
+ # ⛔ NO \`push: branches: [main]\`. Every commit reaches main through a pull request whose
59
+ # \`ci\` had to be green — the branch ruleset requires it and carries no bypass actors —
60
+ # so a second run on the squash commit recomputed an answer it already had. Measured
61
+ # 2026-09-15..22 across the estate: 613 of 619 main-push runs had a head_sha identical
62
+ # to the merge_commit_sha of an already-green PR. That duplication was ~41% of the Mac
63
+ # mini's entire CI load.
64
+ # ⚠️ WHAT THIS GIVES UP, SAID OUT LOUD: the ~4% of merges whose base DID move between the
65
+ # PR run and the squash no longer get a post-merge re-test. That run gated nothing — it
66
+ # reported after main already had the commit — and the next PR, which branches from the
67
+ # merged main, is what actually catches a semantic conflict.
68
+ pull_request:
69
+
70
+ permissions:
71
+ contents: read
72
+
73
+ concurrency:
74
+ group: ci-\${{ github.ref }}
75
+ cancel-in-progress: true
76
+ `;
77
+
78
+ const CHECK_NOTE = ` # ── One job, one install ────────────────────────────────────────────────────
79
+ # ★ WAS 3 SEPARATE JOBS: lint and format, types, tests. They shared an identical
80
+ # prologue — checkout, setup-bun, \`bun install --frozen-lockfile\` — and the install,
81
+ # not the check, was the cost: measured on the mini 2026-09-22, install p50 51s /
82
+ # p90 81s against 1-3s for the command the install existed to enable. 3 jobs meant 3
83
+ # installs and 3 containers to compute one answer, and on a 3-slot pool that was one
84
+ # pull request asking for every slot to answer a single question.
85
+ # ⛔ ONE STEP: \`bun run check\`, THE REPOSITORY'S OWN GATE, NOT A COPY OF ITS LANES.
86
+ # The house rule is that CI runs the same command a person runs, and a workflow that
87
+ # re-lists \`lint\`, \`types\`, \`test\` is a second copy of that command which can quietly
88
+ # check LESS than the local gate. Measured 2026-09-22: \`bun run check\` in homeflare-kit
89
+ # is \`lint && types && build && test\`, and its tests/dist.test.ts SKIPS ITSELF when
90
+ # dist/ is absent — so a workflow running lint/types/test without the build would drop
91
+ # that test silently and still report green. homeflare-alerts' check runs
92
+ # \`check:types\` and \`build:web\`; homeflare-subnet-calc's delegates to \`verify\`. All
93
+ # fourteen repositories have a \`check\` script and every one is local-only — no network,
94
+ # no deploy. Checked, not assumed.
95
+ # ★ THE SPLIT OF RESPONSIBILITY: this renderer owns the PLUMBING — triggers, permissions,
96
+ # concurrency, runner, action versions, the aggregate gate — and the repository owns
97
+ # WHAT ITS GATE RUNS, in package.json, where a change to it shows up in that
98
+ # repository's own diff rather than in a workflow nobody reads.
99
+ # ⚠️ THE TRADE, STATED: a red X says \`check\` rather than naming the lane. \`bun run check\`
100
+ # short-circuits on the first failure and its output names the lane, which is the same
101
+ # signal a person gets locally.`;
102
+
103
+ const WORKFLOWS_NOTE = ` # ★ \`workflow lint\` STAYS ITS OWN JOB on purpose: it needs no \`bun install\` at all
104
+ # (actionlint is preloaded on the mini's job image — measured 5s end to end), so
105
+ # folding it into \`check\` would make a YAML-only change pay the install for nothing.`;
106
+
107
+ const ACTIONLINT_NOTE = ` # ⛔ THERE IS NO FIRST-PARTY actionlint ACTION, and the npm package by that name is
108
+ # an unrelated wasm port with no \`bin\`. The vendor's own documented CI path is
109
+ # \`download-actionlint.bash\` — but that script does NOT verify a checksum (read at
110
+ # the v${ACTIONLINT_VERSION} tag, 2026-09-16: \`curl -L "$url" | tar xvz\`, no sha256sum anywhere).
111
+ # rhysd's releases DO publish a \`_checksums.txt\` per version; this verifies against
112
+ # that instead of trusting the tarball on receipt.
113
+ # ⚠️ ARCH FROM THE RUNNER, NOT HARD-CODED: the mini's runners are arm64, and an amd64
114
+ # binary fails with "exec format error" (the checksum file lists both).`;
115
+
116
+ const INSTALL_ACTIONLINT = `set -euo pipefail
117
+ version=${ACTIONLINT_VERSION}
118
+ if command -v actionlint >/dev/null 2>&1 &&
119
+ [ "$(actionlint -version 2>/dev/null | sed -n 1p)" = "\${version}" ]; then
120
+ cp "$(command -v actionlint)" ./actionlint
121
+ exit 0
122
+ fi
123
+ case "$(uname -m)" in
124
+ x86_64) arch=amd64 ;;
125
+ aarch64 | arm64) arch=arm64 ;;
126
+ *) echo "unsupported architecture $(uname -m)" >&2; exit 1 ;;
127
+ esac
128
+ file="actionlint_\${version}_linux_\${arch}.tar.gz"
129
+ base="https://github.com/rhysd/actionlint/releases/download/v\${version}"
130
+ curl -sSfLO "\${base}/\${file}"
131
+ curl -sSfLO "\${base}/actionlint_\${version}_checksums.txt"
132
+ grep " \${file}\\$" "actionlint_\${version}_checksums.txt" | sha256sum -c -
133
+ tar xzf "\${file}" actionlint`;
134
+
135
+ const AGGREGATE_NOTE = ` # ── The one check a branch rule can require ─────────────────────────────────
136
+ # ★ A single required check that depends on all of them. Without this, adding a job
137
+ # means editing the branch ruleset too, and forgetting to means the new job is
138
+ # advisory without anyone noticing.
139
+ # ⛔ FAILS ON ANYTHING OTHER THAN success, INCLUDING skipped. A job result has exactly
140
+ # four values: success, failure, cancelled, skipped. Checking only the first two lets
141
+ # a skipped job — a bad \`if:\` condition, a misconfigured dependency, a runner picking
142
+ # up nothing — report this aggregate green with a required job never having run.`;
143
+
144
+ const VERIFY = `if [ "\${{ contains(needs.*.result, 'failure') }}" = "true" ] || \\
145
+ [ "\${{ contains(needs.*.result, 'cancelled') }}" = "true" ] || \\
146
+ [ "\${{ contains(needs.*.result, 'skipped') }}" = "true" ]; then
147
+ echo "one or more required jobs did not succeed: \${{ toJSON(needs.*.result) }}" >&2
148
+ exit 1
149
+ fi`;
150
+
151
+ function prologue(): string {
152
+ return renderSteps(
153
+ [
154
+ { uses: CHECKOUT },
155
+ { uses: SETUP_BUN, with: { 'bun-version': BUN_VERSION } },
156
+ { run: 'bun install --frozen-lockfile' },
157
+ ],
158
+ 3,
159
+ );
160
+ }
161
+
162
+ function renderExtraJob(job: ExtraJob, on: string): string {
163
+ const needs =
164
+ (job.needs ?? []).length === 0 ? '' : ` needs: [${(job.needs ?? []).join(', ')}]\n`;
165
+ const steps =
166
+ job.bun === false
167
+ ? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3)
168
+ : [prologue(), renderSteps(job.steps, 3)].join('\n');
169
+ // ★ The stated reason is rendered into the file. A job nobody can explain is a job
170
+ // nobody dares delete, so the explanation travels with it.
171
+ return ` # ★ NOT PART OF THE STANDARD SHAPE — ${job.reason}
172
+ ${job.id}:
173
+ name: ${job.name}
174
+ ${needs} runs-on: ${on}
175
+ steps:
176
+ ${steps}
177
+ `;
178
+ }
179
+
180
+ /** The whole `ci.yml` for a shape. */
181
+ export function renderCi(shape: RepoShape): string {
182
+ const on = runsOn(shape.runner);
183
+ const extras = shape.extraJobs ?? [];
184
+ const needs = ['check', 'workflows', ...extras.map((job) => job.id)];
185
+
186
+ return `${HEADER}${runnerNote(shape)}${TRIGGER}
187
+ jobs:
188
+ ${CHECK_NOTE}
189
+ check:
190
+ name: check
191
+ runs-on: ${on}
192
+ steps:
193
+ ${prologue()}
194
+ ${renderSteps([{ run: 'bun run check' }], 3)}
195
+
196
+ ${WORKFLOWS_NOTE}
197
+ workflows:
198
+ name: workflow lint
199
+ runs-on: ${on}
200
+ steps:
201
+ ${renderSteps([{ uses: CHECKOUT }], 3)}
202
+ ${ACTIONLINT_NOTE}
203
+ ${renderSteps([{ name: 'Install actionlint (checksum-verified)', run: INSTALL_ACTIONLINT }], 3)}
204
+ ${renderSteps([{ name: 'Lint workflows', run: './actionlint -color' }], 3)}
205
+
206
+ ${extras.map((job) => `${renderExtraJob(job, on)}\n`).join('')}${AGGREGATE_NOTE}
207
+ ci:
208
+ name: ci
209
+ if: always()
210
+ needs: [${needs.join(', ')}]
211
+ runs-on: ${on}
212
+ steps:
213
+ ${renderSteps([{ name: 'Verify every job succeeded', run: VERIFY }], 3)}
214
+ `;
215
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The three smaller rendered files: actionlint's config, the changeset config, and
3
+ * Dependabot.
4
+ *
5
+ * ★ EACH ONE WAS MEASURED BEFORE IT WAS RENDERED (2026-09-22, across 13 repositories):
6
+ * · `.changeset/config.json` — byte-identical apart from the repository name in 11 of
7
+ * 13. The three that differed did so by accident: two pinned an older `$schema`
8
+ * (3.1.4 against 4.0.1) and one used `@changesets/cli/changelog` instead of the
9
+ * GitHub changelog everyone else had, which silently drops PR links from releases.
10
+ * · `.github/actionlint.yaml` — 12 of 13 had it, with three wordings of one comment.
11
+ * The one without it is the one repository still on `ubuntu-latest`, which is the
12
+ * only case where it is genuinely not needed. That is an input, not an exception.
13
+ * · `.github/dependabot.yml` — 1 of 14. Twelve repositories take no dependency or
14
+ * Action updates at all, and nothing said so. Rendering it is the fix.
15
+ */
16
+ import type { RepoShape } from './shape.ts';
17
+
18
+ /**
19
+ * ⚠️ PINNED, NOT `latest`. `unpkg.com/@changesets/config@latest/schema.json` would change
20
+ * what editors validate against without a commit, and the two repositories that drifted
21
+ * here drifted by being pinned to different versions — not by being pinned.
22
+ */
23
+ const CHANGESET_SCHEMA = '4.0.1';
24
+
25
+ /**
26
+ * `.changeset/config.json`.
27
+ *
28
+ * ⛔ `privatePackages` IS WRITTEN ONLY FOR A REPOSITORY THAT DOES NOT PUBLISH, AND BOTH
29
+ * OF ITS FIELDS ARE REQUIRED. `@changesets/cli` silently versions nothing when
30
+ * `version` is absent, so a private repository without this key opens a Version
31
+ * Packages PR that changes no version and tags no release — a release pipeline that
32
+ * reports success and ships nothing.
33
+ */
34
+ export function renderChangesetConfig(shape: RepoShape): string {
35
+ const config = {
36
+ $schema: `https://unpkg.com/@changesets/config@${CHANGESET_SCHEMA}/schema.json`,
37
+ // ★ `@changesets/changelog-github` over the built-in: it writes the pull request and
38
+ // author into CHANGELOG.md, which is the difference between a changelog you can
39
+ // audit and a list of sentences.
40
+ changelog: ['@changesets/changelog-github', { repo: `${shape.owner}/${shape.repository}` }],
41
+ commit: false,
42
+ fixed: [] as string[],
43
+ linked: [] as string[],
44
+ access: shape.publishes ? 'public' : 'restricted',
45
+ baseBranch: 'main',
46
+ updateInternalDependencies: 'patch',
47
+ ignore: [] as string[],
48
+ ...(shape.publishes ? {} : { privatePackages: { version: true, tag: false } }),
49
+ };
50
+ return `${JSON.stringify(config, undefined, 2)}\n`;
51
+ }
52
+
53
+ /**
54
+ * `.github/actionlint.yaml` — rendered only for a self-hosted runner.
55
+ *
56
+ * ★ DECLARED, NOT SUPPRESSED. actionlint's `runner-label` rule stays on, so a typo'd
57
+ * label (`homeflare-mnii`) still fails the `workflow lint` job rather than silently
58
+ * queueing a job no runner ever claims. Measured 2026-09-22 with actionlint 1.7.12: an
59
+ * undeclared `homeflare-mini` is an error, not a warning, so this file is the reason
60
+ * twelve repositories are green rather than a nicety.
61
+ */
62
+ export function renderActionlintConfig(shape: RepoShape): string | undefined {
63
+ if (shape.runner !== 'mini') return undefined;
64
+ return `# actionlint's list of self-hosted runner labels (its \`runner-label\` check).
65
+ #
66
+ # 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
67
+ # Refresh with \`bun run repo-shape:refresh\`.
68
+ #
69
+ # ★ DECLARED, NOT SUPPRESSED: the rule stays on, so a typo'd label (\`homeflare-mnii\`)
70
+ # still fails the \`workflow lint\` job rather than queueing a job no runner claims.
71
+ # \`homeflare-mini\` is the interim runner on the Mac mini (homeflare-mini's
72
+ # docs/ci-runner.md); setting this repository's shape to \`runner: 'github'\` removes
73
+ # this file with the \`runs-on\` that needed it.
74
+ self-hosted-runner:
75
+ labels:
76
+ - homeflare-mini
77
+ `;
78
+ }
79
+
80
+ /** `.github/dependabot.yml`. */
81
+ export function renderDependabot(shape: RepoShape): string {
82
+ // ⚠️ `directories`, not `directory`, for bun: a workspace keeps a dependency in the
83
+ // package that declares it, so pointing only at `/` leaves every `packages/*`
84
+ // manifest unwatched — and the symptom is silence, not an error.
85
+ const bunDirs = shape.publishes ? ['/', '/packages/*'] : ['/'];
86
+ return `# Dependabot for ${shape.repository}.
87
+ #
88
+ # 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
89
+ # Refresh with \`bun run repo-shape:refresh\`.
90
+ #
91
+ # ⛔ THIS FILE LIVES AT .github/dependabot.yml, NOT IN .github/workflows/. Dependabot is a
92
+ # platform feature, not an Action — a config placed among the workflows is silently
93
+ # ignored, and the symptom is simply that no pull requests ever arrive. Measured
94
+ # 2026-09-22: 13 of 14 HomeFlare repositories had no dependabot config at all, so their
95
+ # Actions and their toolchain went stale invisibly, which is exactly how nothing fails.
96
+ #
97
+ # ★ WHY GROUPED RATHER THAN ONE PR PER DEPENDENCY. The default opens a pull request per
98
+ # outdated package; that is a trickle nobody reviews properly. Each group below is a set
99
+ # that is either safe to take together or needs deciding together.
100
+ version: 2
101
+
102
+ updates:
103
+ # ── The toolchain (bun.lock) ────────────────────────────────────────────────
104
+ - package-ecosystem: bun
105
+ directories:
106
+ ${bunDirs.map((dir) => ` - ${dir}`).join('\n')}
107
+ schedule:
108
+ interval: weekly
109
+ day: monday
110
+ time: '09:00'
111
+ timezone: America/New_York
112
+ open-pull-requests-limit: 5
113
+ commit-message:
114
+ prefix: 'chore'
115
+ include: scope
116
+ labels: [dependencies]
117
+ groups:
118
+ # oxfmt and oxlint move together and only affect style. Minor and patch bumps are
119
+ # noise unless they fail CI, which is what CI is for.
120
+ lint-and-format:
121
+ patterns: ['oxfmt', 'oxlint']
122
+ update-types: [minor, patch]
123
+
124
+ # ⚠️ MAJORS EXCLUDED DELIBERATELY. TypeScript majors change what typechecks;
125
+ # changesets majors have renamed inputs and dropped compatibility (the action's v2
126
+ # did both). These want reading, not merging on green.
127
+ build-tooling:
128
+ patterns: ['typescript', '@changesets/*', '@types/bun']
129
+ update-types: [minor, patch]
130
+
131
+ # ── The workflows themselves ────────────────────────────────────────────────
132
+ # ★ Actions go stale invisibly: nothing fails, they just keep running old code. The
133
+ # estate's first workflows pinned checkout@v5 (current: v7) and changesets/action@v1
134
+ # (current: v2, with every input renamed) — measured 2026-09-15.
135
+ - package-ecosystem: github-actions
136
+ directory: /
137
+ schedule:
138
+ interval: weekly
139
+ day: monday
140
+ time: '09:00'
141
+ timezone: America/New_York
142
+ open-pull-requests-limit: 5
143
+ commit-message:
144
+ prefix: 'ci'
145
+ labels: [dependencies, github-actions]
146
+ groups:
147
+ actions:
148
+ patterns: ['*']
149
+ update-types: [minor, patch]
150
+ `;
151
+ }