@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.
Files changed (59) hide show
  1. package/README.md +5 -40
  2. package/bin/hooks.ts +8 -4
  3. package/dist/hooks/activate.d.ts +7 -0
  4. package/dist/hooks/activate.d.ts.map +1 -0
  5. package/dist/hooks/gates.d.ts +7 -14
  6. package/dist/hooks/gates.d.ts.map +1 -1
  7. package/dist/hooks/install.d.ts +7 -1
  8. package/dist/hooks/install.d.ts.map +1 -1
  9. package/dist/hooks/push-plan.d.ts +59 -0
  10. package/dist/hooks/push-plan.d.ts.map +1 -0
  11. package/dist/hooks/push-range.d.ts +51 -0
  12. package/dist/hooks/push-range.d.ts.map +1 -0
  13. package/dist/hooks/report.d.ts +29 -2
  14. package/dist/hooks/report.d.ts.map +1 -1
  15. package/dist/hooks/secrets.d.ts +2 -0
  16. package/dist/hooks/secrets.d.ts.map +1 -0
  17. package/dist/hooks.d.ts +30 -6
  18. package/dist/hooks.d.ts.map +1 -1
  19. package/dist/hooks.js +339 -31
  20. package/dist/hooks.js.map +11 -7
  21. package/dist/repo-shape/automerge.d.ts +17 -0
  22. package/dist/repo-shape/automerge.d.ts.map +1 -0
  23. package/dist/repo-shape/ci.d.ts.map +1 -1
  24. package/dist/repo-shape/companions.d.ts +2 -6
  25. package/dist/repo-shape/companions.d.ts.map +1 -1
  26. package/dist/repo-shape/dependabot.d.ts +35 -0
  27. package/dist/repo-shape/dependabot.d.ts.map +1 -0
  28. package/dist/repo-shape/guards.d.ts +25 -0
  29. package/dist/repo-shape/guards.d.ts.map +1 -0
  30. package/dist/repo-shape/render.d.ts.map +1 -1
  31. package/dist/repo-shape/shape.d.ts +40 -2
  32. package/dist/repo-shape/shape.d.ts.map +1 -1
  33. package/dist/repo-shape.d.ts +5 -2
  34. package/dist/repo-shape.d.ts.map +1 -1
  35. package/dist/repo-shape.js +269 -105
  36. package/dist/repo-shape.js.map +10 -7
  37. package/dist/versions.js +68 -31
  38. package/dist/versions.js.map +6 -5
  39. package/docs/hooks.md +99 -0
  40. package/docs/repo-shape-dependabot.md +126 -0
  41. package/docs/repo-shape-inputs.md +83 -0
  42. package/docs/repo-shape.md +5 -23
  43. package/package.json +1 -1
  44. package/src/hooks/activate.ts +71 -0
  45. package/src/hooks/gates.ts +87 -28
  46. package/src/hooks/install.ts +39 -20
  47. package/src/hooks/push-plan.ts +167 -0
  48. package/src/hooks/push-range.ts +177 -0
  49. package/src/hooks/report.ts +31 -6
  50. package/src/hooks/secrets.ts +42 -0
  51. package/src/hooks.ts +30 -14
  52. package/src/repo-shape/automerge.ts +144 -0
  53. package/src/repo-shape/ci.ts +35 -16
  54. package/src/repo-shape/companions.ts +2 -77
  55. package/src/repo-shape/dependabot.ts +136 -0
  56. package/src/repo-shape/guards.ts +68 -0
  57. package/src/repo-shape/render.ts +5 -1
  58. package/src/repo-shape/shape.ts +51 -35
  59. package/src/repo-shape.ts +8 -4
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The three smaller rendered files: actionlint's config, the changeset config, and
3
- * Dependabot.
2
+ * The two smaller rendered files: actionlint's config and the changeset config.
3
+ * (Dependabot was the third; it grew a group and a schedule rule, so it is `dependabot.ts`.)
4
4
  *
5
5
  * ★ EACH ONE WAS MEASURED BEFORE IT WAS RENDERED (2026-09-22, across 13 repositories):
6
6
  * · `.changeset/config.json` — byte-identical apart from the repository name in 11 of
@@ -10,8 +10,6 @@
10
10
  * · `.github/actionlint.yaml` — 12 of 13 had it, with three wordings of one comment.
11
11
  * The one without it is the one repository still on `ubuntu-latest`, which is the
12
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
13
  */
16
14
  import type { RepoShape } from './shape.ts';
17
15
 
@@ -76,76 +74,3 @@ self-hosted-runner:
76
74
  - homeflare-mini
77
75
  `;
78
76
  }
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
- }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `.github/dependabot.yml`, rendered — and the one group that carries kit releases.
3
+ *
4
+ * ★ MEASURED BEFORE IT WAS RENDERED (2026-09-22): 1 of 14 repositories had a Dependabot
5
+ * config. Twelve took no dependency or Action updates at all, and nothing said so.
6
+ *
7
+ * ★ THE `homeflare` GROUP IS HOW A KIT RELEASE REACHES A CONSUMER (Tim, 2026-09-23:
8
+ * "Dependabot, grouped", checked daily, merged on green). Measured the same day, the
9
+ * leg nobody automated had drifted: proxmox and mini pinned `@homeflare/alchemy` 0.13.0
10
+ * while the kit had published 0.19.1. `automerge.ts` renders the workflow that arms
11
+ * auto-merge on this group's pull request and on nothing else.
12
+ *
13
+ * Every Dependabot claim below was read from GitHub's docs or dependabot-core's source on
14
+ * 2026-09-22 (dependabot-core v0.397.0); `docs/repo-shape-dependabot.md` has the citations.
15
+ */
16
+ import type { RepoShape } from './shape.ts';
17
+
18
+ /**
19
+ * The group's identifier. ⛔ IT IS ALSO HALF OF A BRANCH NAME the auto-merge workflow
20
+ * matches — dependabot-core names a group's branch `dependabot/bun/<group>-<10 hex>`
21
+ * (branch_namer/dependency_group_strategy.rb) — so it is exported, not retyped there.
22
+ * Dependabot requires an identifier that starts and ends with a letter.
23
+ */
24
+ export const HOMEFLARE_GROUP = 'homeflare';
25
+
26
+ /** The first-party scope. Every package the kit publishes is under it. */
27
+ export const HOMEFLARE_PATTERN = '@homeflare/*';
28
+
29
+ /**
30
+ * ★ SEVEN DAYS FOR THIRD-PARTY VERSIONS. The bun block has to run daily for the kit's sake
31
+ * (see below), so the weekly pace the estate had for everything else is kept by age
32
+ * instead of by calendar: a third-party release is proposed once it is a week old. That
33
+ * is also the supply-chain half — a compromised release is usually yanked within days.
34
+ */
35
+ export const THIRD_PARTY_COOLDOWN_DAYS = 7;
36
+
37
+ /** `.github/dependabot.yml`. */
38
+ export function renderDependabot(shape: RepoShape): string {
39
+ // ⚠️ `directories`, not `directory`, for bun: a workspace keeps a dependency in the
40
+ // package that declares it, so pointing only at `/` leaves every `packages/*`
41
+ // manifest unwatched — and the symptom is silence, not an error.
42
+ const bunDirs = shape.publishes ? ['/', '/packages/*'] : ['/'];
43
+ return `# Dependabot for ${shape.repository}.
44
+ #
45
+ # 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
46
+ # Refresh with \`bun run repo-shape:refresh\`.
47
+ #
48
+ # ⛔ THIS FILE LIVES AT .github/dependabot.yml, NOT IN .github/workflows/. Dependabot is a
49
+ # platform feature, not an Action — a config placed among the workflows is silently
50
+ # ignored, and the symptom is simply that no pull requests ever arrive. Measured
51
+ # 2026-09-22: 13 of 14 HomeFlare repositories had no dependabot config at all, so their
52
+ # Actions and their toolchain went stale invisibly, which is exactly how nothing fails.
53
+ #
54
+ # ★ WHY GROUPED RATHER THAN ONE PR PER DEPENDENCY. The default opens a pull request per
55
+ # outdated package; that is a trickle nobody reviews properly. Each group below is a set
56
+ # that is either safe to take together or needs deciding together.
57
+ #
58
+ # ★ THE BILLING LOCK DOES NOT STOP THIS FILE. Dependabot runs as a GitHub-hosted "dynamic"
59
+ # workflow that GitHub's docs say "does not count towards your included GitHub Actions
60
+ # minutes"; measured 2026-09-22, a private estate repository's Dependabot job got a hosted
61
+ # runner 86 minutes after that account's ordinary hosted jobs were refused for billing.
62
+ # It cannot use the mini anyway: a self-hosted Dependabot runner must be Linux x64 with
63
+ # Docker, and the mini's runners are arm64 containers with no Docker socket.
64
+ version: 2
65
+
66
+ updates:
67
+ # ── The toolchain (bun.lock) ────────────────────────────────────────────────
68
+ # ⚠️ BLOCKED UPSTREAM, 2026-09-22: bun 1.4 writes bun.lock \`lockfileVersion\` 2 and
69
+ # Dependabot's updater bundles bun 1.3.14, which reads up to 1, so this block fails in
70
+ # every estate repository with "Unsupported bun.lock 'lockfileVersion' 2". The fix is
71
+ # dependabot/dependabot-core pull request 16071. The github-actions block is unaffected.
72
+ # ⛔ ONE BUN BLOCK, SO ONE SCHEDULE. Dependabot refuses two blocks for one ecosystem and
73
+ # target branch whose directories overlap, so the \`homeflare\` group cannot be daily
74
+ # while the rest stays weekly. The block is daily; \`cooldown\` slows the rest.
75
+ - package-ecosystem: bun
76
+ directories:
77
+ ${bunDirs.map((dir) => ` - ${dir}`).join('\n')}
78
+ schedule:
79
+ # ⚠️ Dependabot's \`daily\` is Monday to Friday; a weekend kit release lands on Monday.
80
+ interval: daily
81
+ time: '09:00'
82
+ timezone: America/New_York
83
+ # ⛔ THE EXCLUDE IS NOT OPTIONAL. Dependabot applies a 3-day cooldown to every version
84
+ # update even when this key is absent, so without it a kit release would wait three
85
+ # days before its bump opened — and "daily" would quietly mean "three days late".
86
+ cooldown:
87
+ default-days: ${THIRD_PARTY_COOLDOWN_DAYS}
88
+ exclude: ['${HOMEFLARE_PATTERN}']
89
+ open-pull-requests-limit: 5
90
+ commit-message:
91
+ prefix: 'chore'
92
+ include: scope
93
+ labels: [dependencies]
94
+ groups:
95
+ # ★ FIRST, AND EVERY UPDATE TYPE. A kit release is one set of packages built to work
96
+ # together, so they move as one pull request; \`bun run check\` is what reads it, and
97
+ # .github/workflows/dependabot-automerge.yml merges it when that is green.
98
+ # ⚠️ A release that changes what @homeflare/config renders fails the drift test here
99
+ # by design; \`bun run repo-shape:refresh\` on the branch is the one-command fix.
100
+ ${HOMEFLARE_GROUP}:
101
+ patterns: ['${HOMEFLARE_PATTERN}']
102
+
103
+ # oxfmt and oxlint move together and only affect style. Minor and patch bumps are
104
+ # noise unless they fail CI, which is what CI is for.
105
+ lint-and-format:
106
+ patterns: ['oxfmt', 'oxlint']
107
+ update-types: [minor, patch]
108
+
109
+ # ⚠️ MAJORS EXCLUDED DELIBERATELY. TypeScript majors change what typechecks;
110
+ # changesets majors have renamed inputs and dropped compatibility (the action's v2
111
+ # did both). These want reading, not merging on green.
112
+ build-tooling:
113
+ patterns: ['typescript', '@changesets/*', '@types/bun']
114
+ update-types: [minor, patch]
115
+
116
+ # ── The workflows themselves ────────────────────────────────────────────────
117
+ # ★ Actions go stale invisibly: nothing fails, they just keep running old code. The
118
+ # estate's first workflows pinned checkout@v5 (current: v7) and changesets/action@v1
119
+ # (current: v2, with every input renamed) — measured 2026-09-15.
120
+ - package-ecosystem: github-actions
121
+ directory: /
122
+ schedule:
123
+ interval: weekly
124
+ day: monday
125
+ time: '09:00'
126
+ timezone: America/New_York
127
+ open-pull-requests-limit: 5
128
+ commit-message:
129
+ prefix: 'ci'
130
+ labels: [dependencies, github-actions]
131
+ groups:
132
+ actions:
133
+ patterns: ['*']
134
+ update-types: [minor, patch]
135
+ `;
136
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The runtime guards `repo-shape` needs and its types cannot express.
3
+ *
4
+ * ★ EXTRACTED FROM `shape.ts`, NOT INVENTED HERE. The types carry one half of the
5
+ * contract — `Stated<R>` refuses an empty or computed reason at compile time — and
6
+ * these carry the other half: the cases a literal type still lets through. Keeping
7
+ * them in one file is what lets `shape.ts` stay the declaration and nothing else.
8
+ *
9
+ * ⚠️ EVERY MESSAGE STARTS `repo-shape:` AND NAMES THE FIELD. These throw at render or at
10
+ * declaration, far from the workflow file they would otherwise break at job time, so
11
+ * the message is the only thing pointing back at the call that caused it.
12
+ */
13
+
14
+ const SENTENCE = 12;
15
+
16
+ export function requireNonEmpty(field: string, value: string): string {
17
+ const trimmed = value.trim();
18
+ if (trimmed.length === 0) throw new Error(`repo-shape: ${field} must not be blank`);
19
+ return trimmed;
20
+ }
21
+
22
+ export function requireSentence(field: string, value: string): string {
23
+ const trimmed = value.trim();
24
+ // ⚠️ THE TYPE CANNOT CATCH `reason: ' '`. `' '` is a non-empty literal, so `Stated`
25
+ // lets it through and only this does not. Type and guard cover different halves.
26
+ if (trimmed.length < SENTENCE) {
27
+ throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);
28
+ }
29
+ return trimmed;
30
+ }
31
+
32
+ export function requireIsoDate(value: string): string {
33
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
34
+ throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
35
+ }
36
+ return value;
37
+ }
38
+
39
+ /**
40
+ * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`
41
+ * and resolves the newest 24.x; a fractional or negative value renders YAML the action
42
+ * accepts and then fails to resolve, mid-job, on the runner.
43
+ */
44
+ export function requireMajor(value: number): number {
45
+ if (!Number.isInteger(value) || value <= 0) {
46
+ throw new Error(`repo-shape: node must be a positive integer major, got ${value}`);
47
+ }
48
+ return value;
49
+ }
50
+
51
+ /** `timeout-minutes` must be a positive whole number of minutes. */
52
+ export function requireMinutes(value: number): number {
53
+ if (!Number.isInteger(value) || value <= 0) {
54
+ throw new Error(`repo-shape: timeout must be a positive whole minute count, got ${value}`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ export function requireJobId(value: string): string {
60
+ // ⛔ The id becomes a YAML key and a `needs:` entry. Anything else renders a workflow
61
+ // GitHub rejects at parse time, which reports as "workflow file issue" with no line.
62
+ if (!/^[a-z][a-z0-9_-]*$/.test(value)) {
63
+ throw new Error(
64
+ `repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`,
65
+ );
66
+ }
67
+ return value;
68
+ }
@@ -20,8 +20,10 @@
20
20
  * test lives in `packages/alchemy/tests/repo-shape-policy.test.ts`, which is the one
21
21
  * place that imports both.
22
22
  */
23
+ import { renderAutomerge } from './automerge.ts';
23
24
  import { renderCi } from './ci.ts';
24
- import { renderActionlintConfig, renderChangesetConfig, renderDependabot } from './companions.ts';
25
+ import { renderActionlintConfig, renderChangesetConfig } from './companions.ts';
26
+ import { renderDependabot } from './dependabot.ts';
25
27
  import { renderSecurity } from './security.ts';
26
28
  import type { RenderedPath, RepoShape } from './shape.ts';
27
29
 
@@ -66,6 +68,7 @@ export function renderRepoShape(shape: RepoShape): RenderedRepo {
66
68
  '.changeset/config.json': renderChangesetConfig(shape),
67
69
  '.github/dependabot.yml': renderDependabot(shape),
68
70
  '.github/workflows/ci.yml': renderCi(shape),
71
+ '.github/workflows/dependabot-automerge.yml': renderAutomerge(shape),
69
72
  '.github/workflows/security.yml': renderSecurity(shape),
70
73
  };
71
74
 
@@ -84,5 +87,6 @@ export const RENDERED_PATHS: readonly RenderedPath[] = [
84
87
  '.github/actionlint.yaml',
85
88
  '.github/dependabot.yml',
86
89
  '.github/workflows/ci.yml',
90
+ '.github/workflows/dependabot-automerge.yml',
87
91
  '.github/workflows/security.yml',
88
92
  ];
@@ -22,6 +22,15 @@
22
22
  * has to be written at the exception. See `Stated`.
23
23
  */
24
24
 
25
+ import {
26
+ requireIsoDate,
27
+ requireJobId,
28
+ requireMajor,
29
+ requireMinutes,
30
+ requireNonEmpty,
31
+ requireSentence,
32
+ } from './guards.ts';
33
+
25
34
  /** Where a repository's jobs run. */
26
35
  export type RepoRunner =
27
36
  /**
@@ -77,6 +86,7 @@ export type RenderedPath =
77
86
  | '.github/actionlint.yaml'
78
87
  | '.github/dependabot.yml'
79
88
  | '.github/workflows/ci.yml'
89
+ | '.github/workflows/dependabot-automerge.yml'
80
90
  | '.github/workflows/security.yml';
81
91
 
82
92
  export interface RepoShapeException {
@@ -133,11 +143,23 @@ export interface ExtraJob {
133
143
  readonly needs?: readonly string[];
134
144
  /** `false` skips the rendered bun prologue — for a job that needs another toolchain. */
135
145
  readonly bun?: boolean;
146
+ /**
147
+ * `timeout-minutes:` for this job. Omitted takes GitHub's 360-minute default.
148
+ *
149
+ * ⚠️ AN INPUT BECAUSE A HUNG JOB IS NOT A FAILED JOB. Measured 2026-09-22:
150
+ * homeflare-blog's `runtime` job drives Playwright against a local workerd, and a
151
+ * browser that never reaches its first paint holds a self-hosted slot for six hours
152
+ * rather than reporting red. On a 3-slot pool that is the whole pool. Only a job
153
+ * that starts something with its own wait — a browser, a server, a container — needs
154
+ * this; `check` does not, because `bun run check` exits.
155
+ */
156
+ readonly timeout?: number;
136
157
  }
137
158
 
138
- interface ExtraJobInput extends Omit<ExtraJob, 'needs' | 'bun'> {
159
+ interface ExtraJobInput extends Omit<ExtraJob, 'needs' | 'bun' | 'timeout'> {
139
160
  readonly needs?: readonly string[];
140
161
  readonly bun?: boolean;
162
+ readonly timeout?: number;
141
163
  }
142
164
 
143
165
  /**
@@ -160,6 +182,7 @@ export function extraJob<const J extends ExtraJobInput>(
160
182
  needs: [...(job.needs ?? [])],
161
183
  reason: requireSentence('reason', job.reason),
162
184
  steps: [...job.steps],
185
+ ...(job.timeout === undefined ? {} : { timeout: requireMinutes(job.timeout) }),
163
186
  };
164
187
  }
165
188
 
@@ -176,46 +199,39 @@ export interface RepoShape {
176
199
  * that differ between `homeflare-kit` and every other repository.
177
200
  */
178
201
  readonly publishes: boolean;
202
+ /**
203
+ * Node major to install before Bun, for a repository whose own gate needs a real
204
+ * `node` on `PATH`. Omit it — twelve of fourteen repositories are Bun-only.
205
+ *
206
+ * ⛔ AN INPUT, NOT AN EXCEPTION, AND THE MEASUREMENT SAYS WHY. The mini's job image
207
+ * carries no node at all (ubuntu-latest always did), so a Bun-only prologue is right
208
+ * for most of the estate and *silently wrong* for two repositories:
209
+ * · homeflare-alerts — tests/alchemy-import.test.ts spawns `node` to prove the
210
+ * modules load the way the Alchemy CLI (`node …/cli.js`) loads them. Without it,
211
+ * three tests fail with `Executable not found in $PATH: "node"` (measured on the
212
+ * runner, 2026-09-22).
213
+ * · homeflare-blog — Payload requires Node >= 24.15, so every lane needs it.
214
+ * Both repositories carried the same hand-written `actions/setup-node@v6` block
215
+ * before this existed. Excepting `ci.yml` instead would hand the estate's two most
216
+ * complicated CI files back to hand-editing, which is the opposite of the point.
217
+ *
218
+ * ⚠️ `package-manager-cache: false` IS RENDERED WITH IT. Bun does the installing here;
219
+ * letting setup-node prime an npm cache costs time and caches nothing anyone reads.
220
+ */
221
+ readonly node?: number;
179
222
  /** Jobs beyond `check` and `workflows`. Each carries its own stated reason. */
180
223
  readonly extraJobs?: readonly ExtraJob[];
181
224
  /** Rendered files this repository keeps its own copy of, each with a reason. */
182
225
  readonly exceptions?: readonly RepoShapeException[];
183
226
  }
184
227
 
185
- const SENTENCE = 12;
186
-
187
- function requireNonEmpty(field: string, value: string): string {
188
- const trimmed = value.trim();
189
- if (trimmed.length === 0) throw new Error(`repo-shape: ${field} must not be blank`);
190
- return trimmed;
191
- }
192
-
193
- function requireSentence(field: string, value: string): string {
194
- const trimmed = value.trim();
195
- // ⚠️ THE TYPE CANNOT CATCH `reason: ' '`. `' '` is a non-empty literal, so `Stated`
196
- // lets it through and only this does not. Type and guard cover different halves.
197
- if (trimmed.length < SENTENCE) {
198
- throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);
199
- }
200
- return trimmed;
201
- }
202
-
203
- function requireIsoDate(value: string): string {
204
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
205
- throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
206
- }
207
- return value;
208
- }
209
-
210
- function requireJobId(value: string): string {
211
- // ⛔ The id becomes a YAML key and a `needs:` entry. Anything else renders a workflow
212
- // GitHub rejects at parse time, which reports as "workflow file issue" with no line.
213
- if (!/^[a-z][a-z0-9_-]*$/.test(value)) {
214
- throw new Error(
215
- `repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`,
216
- );
217
- }
218
- return value;
228
+ /**
229
+ * The validated Node major this shape asks for, or `undefined` for a Bun-only prologue.
230
+ * ★ `RepoShape` is a plain object, so this is where `node:` is checked — at render, not
231
+ * at declaration. A bad value fails the refresh rather than the job.
232
+ */
233
+ export function nodeMajor(shape: RepoShape): number | undefined {
234
+ return shape.node === undefined ? undefined : requireMajor(shape.node);
219
235
  }
220
236
 
221
237
  /** `runs-on:` for a runner. */
package/src/repo-shape.ts CHANGED
@@ -17,7 +17,8 @@
17
17
  * That one file gives the repository:
18
18
  *
19
19
  * · its FILES — `bun run repo-shape:refresh` writes ci.yml, security.yml,
20
- * actionlint.yaml, dependabot.yml and the changeset config;
20
+ * dependabot-automerge.yml, actionlint.yaml, dependabot.yml and
21
+ * the changeset config;
21
22
  * · its DRIFT GATE — a `bun:test` calling `driftInRepoShape` fails on a hand edit;
22
23
  * · its SETTINGS — `renderRepoShape(shape).policy` is the options object
23
24
  * `@homeflare/alchemy`'s `declareRepoPolicy` takes, so the ruleset
@@ -33,12 +34,15 @@
33
34
  * including the ones with no Alchemy stack, so it belongs to the package they all
34
35
  * already have — and it takes no dependency on Alchemy or Effect to get there.
35
36
  */
37
+ export { GROUP_BRANCH, GROUP_BRANCH_PREFIX, renderAutomerge } from './repo-shape/automerge.ts';
36
38
  export { renderCi, ACTIONLINT_VERSION, BUN_VERSION } from './repo-shape/ci.ts';
39
+ export { renderActionlintConfig, renderChangesetConfig } from './repo-shape/companions.ts';
37
40
  export {
38
- renderActionlintConfig,
39
- renderChangesetConfig,
41
+ HOMEFLARE_GROUP,
42
+ HOMEFLARE_PATTERN,
40
43
  renderDependabot,
41
- } from './repo-shape/companions.ts';
44
+ THIRD_PARTY_COOLDOWN_DAYS,
45
+ } from './repo-shape/dependabot.ts';
42
46
  export {
43
47
  type DriftReport,
44
48
  type Problem,