@homeflare/config 0.8.0 → 0.10.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.
@@ -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.
@@ -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
- That is deliberate. A workflow that re-lists `lint`, `types` and `test` is a second copy
156
- of the gate, and a copy can check _less_ than the original. Measured 2026-09-22:
157
- `bun run check` in `homeflare-kit` is `lint && types && build && test`, and its
158
- `tests/dist.test.ts` skips itself when `dist/` is absent — so a workflow that ran the
159
- three lanes without the build would drop that test silently and still report green.
160
- `homeflare-alerts` runs `check:types` and `build:web` in its check; `homeflare-subnet-calc`
161
- delegates to `verify`. All fourteen have a `check` script, and all fourteen are local-only.
162
-
163
- So the split is:
164
-
165
- | Owned by the renderer | Owned by the repository |
166
- | ------------------------------------------------ | ------------------------------------------ |
167
- | Triggers, permissions, concurrency, runner label | What `bun run check` runs |
168
- | Action versions and their pins | Which extra jobs exist, each with a reason |
169
- | The aggregate `ci` job and its `needs` | Its `package.json` scripts |
170
- | The actionlint, Dependabot and changeset configs | — |
171
-
172
- The cost, said plainly: a red X says `check` rather than naming the lane. `bun run check`
173
- short-circuits on the first failure and names the lane in its output, which is the same
174
- signal a person gets locally.
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
 
@@ -0,0 +1,50 @@
1
+ # The estate version set: `@homeflare/config/versions`
2
+
3
+ Status: published source. Not yet enforced anywhere outside the kit. Verified 2026-09-22.
4
+
5
+ ```ts
6
+ import { ESTATE_VERSIONS } from '@homeflare/config/versions';
7
+
8
+ ESTATE_VERSIONS.alchemy; // '2.0.0-beta.79'
9
+ ESTATE_VERSIONS.bun; // '1.4.0', the same value as BUN_VERSION in ./repo-shape
10
+ ```
11
+
12
+ | package | version | why this one |
13
+ | ----------------------------- | ------------------- | ---------------------------------------------------------- |
14
+ | `bun` | `1.4.0` | the value every rendered CI installs (`BUN_VERSION`) |
15
+ | `alchemy` | `2.0.0-beta.79` | the newest release on npm when this was written |
16
+ | `effect` | `4.0.0-rc.115` | what that alchemy release overrides its whole workspace to |
17
+ | `@distilled.cloud/cloudflare` | `1.0.0-rc.12` | what that alchemy release depends on, exactly |
18
+ | `typescript` | `7.0.2` | the kit catalog |
19
+ | `oxfmt` / `oxlint` | `0.68.0` / `1.83.0` | the kit catalog; ahead of upstream's `^0.66.0` / `^1.82.0` |
20
+ | `@types/bun` | `1.4.2` | matches `bun` |
21
+
22
+ ## Where the truth lives
23
+
24
+ ⛔ **The kit's root `catalog` is the source, and this export is its published copy.** Before
25
+ this export existed, the pins were written in two places. Bun was `BUN_VERSION` in
26
+ `./repo-shape`. Everything else sat in the kit's root `catalog`, which is never published.
27
+ So no other repository had anything to compare its lockfile against.
28
+ `tests/estate-versions.test.ts` in the kit fails whenever a value here differs from the
29
+ catalog, from the root `overrides`, from `packageManager`, or from what the installed
30
+ `alchemy` itself depends on. A bump therefore changes both in one PR, or it goes red.
31
+
32
+ ★ **Runtime pins follow `alchemy`.** `effect` and `@distilled.cloud/*` are whatever the
33
+ pinned `alchemy` release was built and tested against. They move in the PR that bumps
34
+ `alchemy`, never ahead of it. Upstream `main` already runs `effect` rc.117. That is not a
35
+ reason to move until a release carries it.
36
+
37
+ ## What drifts today
38
+
39
+ The kit's `alchemy-provider-standard` audit measured each estate repository's lockfile
40
+ read-only on 2026-09-22:
41
+
42
+ - Seven app repositories resolve `alchemy` 2.0.0-beta.78.
43
+ - The monorepo resolves beta.77, with `effect` rc.112, distilled rc.9, oxfmt 0.66.0 and
44
+ oxlint 1.81.0, and installs with pnpm.
45
+ - One app resolves TypeScript 5.9.3.
46
+ - Every consumer resolves an older `@homeflare/config` than the one published.
47
+
48
+ ⚠️ **This export compares nothing on its own.** Adding a lockfile check to `checkProject`,
49
+ so that every repository fails on drift, is a separate estate-wide rollout. It has to land
50
+ together with the bumps it would demand. Otherwise every CI goes red on the same day.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/config",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Shared tsconfig, oxlint, oxfmt, and non-npm release helpers for HomeFlare projects.",
5
5
  "license": "MIT",
6
6
  "author": "Timothy Schneider",
@@ -53,6 +53,10 @@
53
53
  "types": "./dist/require-release-config.d.ts",
54
54
  "default": "./dist/require-release-config.js"
55
55
  },
56
+ "./versions": {
57
+ "types": "./dist/versions.d.ts",
58
+ "default": "./dist/versions.js"
59
+ },
56
60
  "./package.json": "./package.json"
57
61
  },
58
62
  "publishConfig": {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `.github/workflows/dependabot-automerge.yml`, rendered.
3
+ *
4
+ * ★ THE OTHER HALF OF THE `homeflare` GROUP. Dependabot opens the bump; this arms GitHub's
5
+ * own auto-merge on it; the branch ruleset's required checks (`ci`, `secret scan`)
6
+ * decide whether it ever merges. Nothing here merges anything itself, and a merge
7
+ * deploys nothing — every stack in the estate is deployed by hand.
8
+ *
9
+ * ⛔ `gh pr merge --auto` IS NOT ALWAYS "ARM". When the pull request is already CLEAN,
10
+ * UNSTABLE or HAS_HOOKS it merges AT ONCE instead (cli/cli `pkg/cmd/pr/merge/merge.go`,
11
+ * `isImmediatelyMergeable`, read at v2.101.0 — the version the mini's job image ships).
12
+ * UNSTABLE is "mergeable, with a non-passing status", which is every pull request on a
13
+ * branch whose rules require no check: before `check` has run, and even after it failed.
14
+ * So the step reads the base branch's active rules first and refuses — a red check, never
15
+ * a merge — when none requires a status check. Measured 2026-09-22: homeflare-builds (its
16
+ * ruleset not deployed yet) and homeflare-desktop have none.
17
+ *
18
+ * ⛔ FIRST-PARTY ONLY: A `run:` STEP AND THE GitHub CLI, NO `uses:` AT ALL. GitHub's own
19
+ * example ("Automating Dependabot with GitHub Actions") identifies the update with
20
+ * `dependabot/fetch-metadata`, which the same page marks as "not certified by GitHub".
21
+ * The group is identified by the branch Dependabot gives it instead, whose format is
22
+ * dependabot-core's (`branch_namer/dependency_group_strategy.rb`, read at v0.397.0):
23
+ * `<prefix>/<package manager>/<directory>/<group>-<10 hex MD5 digest>`, with the root
24
+ * directory collapsing to nothing.
25
+ */
26
+ import { HOMEFLARE_GROUP } from './dependabot.ts';
27
+ import type { RepoShape } from './shape.ts';
28
+ import { runsOn } from './shape.ts';
29
+ import { renderSteps } from './yaml.ts';
30
+
31
+ /**
32
+ * The branch prefix a job-level `if:` can test with `startsWith`. GitHub's expression
33
+ * language has no regex, so this is the cheap filter that keeps every other pull request
34
+ * from taking a runner slot; `GROUP_BRANCH` is the exact test inside the step.
35
+ */
36
+ export const GROUP_BRANCH_PREFIX: string = `dependabot/bun/${HOMEFLARE_GROUP}-`;
37
+
38
+ /**
39
+ * ⛔ EXACT, AND FAILS CLOSED. A solo update of `@homeflare/config` is
40
+ * `dependabot/bun/homeflare/config-0.9.0` and a package named `homeflare-x` would be
41
+ * `dependabot/bun/homeflare-x-1.2.3`; neither matches. If Dependabot ever changes its
42
+ * format, the symptom is a bump that waits for a person — never one merged by mistake.
43
+ */
44
+ export const GROUP_BRANCH: string = `^${GROUP_BRANCH_PREFIX}[0-9a-f]{10}$`;
45
+
46
+ const HEADER = `# Arms auto-merge on Dependabot's @homeflare/* group, and on nothing else.
47
+ #
48
+ # 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
49
+ # Its input is this repository's \`repo-shape.ts\`; refresh with \`bun run repo-shape:refresh\`.
50
+ #
51
+ # ★ HOW A KIT RELEASE ARRIVES: the \`${HOMEFLARE_GROUP}\` group in .github/dependabot.yml opens one
52
+ # pull request; this arms GitHub's auto-merge on it; the ruleset's required checks decide.
53
+ # Nothing here merges anything itself, and merging deploys nothing.
54
+ # ⛔ NO THIRD-PARTY ACTION. GitHub's own example uses dependabot/fetch-metadata, which its
55
+ # docs mark "not certified by GitHub"; the group is recognised by its branch name instead.
56
+ # ⛔ NO REQUIRED CHECK ON THE BASE BRANCH, NO ARMING: there \`gh pr merge --auto\` would merge
57
+ # at once, unchecked. The job fails instead, so the pull request waits for a person.
58
+ # ⚠️ A RENDERER CHANGE STILL NEEDS A PERSON. When a kit release changes what @homeflare/config
59
+ # renders, the bump fails \`check\` (the drift test) and never goes green. Run
60
+ # \`bun run repo-shape:refresh\` on Dependabot's branch and push. ⛔ This workflow does not do
61
+ # it for you: a push made with GITHUB_TOKEN starts no workflow run, so a refreshed commit
62
+ # would never get the required checks and the pull request would wait forever.
63
+ `;
64
+
65
+ const TRIGGER = `name: dependabot auto-merge
66
+
67
+ on:
68
+ pull_request:
69
+
70
+ # ⛔ NOTHING AT THE TOP; the one job asks for exactly what \`gh pr merge --auto\` needs.
71
+ permissions: {}
72
+
73
+ concurrency:
74
+ group: automerge-\${{ github.ref }}
75
+ cancel-in-progress: true
76
+ `;
77
+
78
+ const JOB_NOTE = ` # ⛔ BOTH LOGINS, NOT EITHER. \`user.login\` is who opened the pull request; \`github.actor\` is
79
+ # who started this run. A person pushing to Dependabot's branch changes the actor, so
80
+ # their commit never arms anything; only Dependabot's own rebases do.`;
81
+
82
+ const PERMISSIONS_NOTE = ` # ★ WHAT GitHub'S OWN EXAMPLE GRANTS FOR THIS STEP, AND NO MORE. A run Dependabot starts gets
83
+ # a read-only GITHUB_TOKEN unless the workflow raises it ("Troubleshooting Dependabot on
84
+ # GitHub Actions" → "Changing GITHUB_TOKEN permissions").`;
85
+
86
+ const ARM = `set -euo pipefail
87
+ if [[ ! "$HEAD_REF" =~ ${GROUP_BRANCH} ]]; then
88
+ echo "::notice::$HEAD_REF is not the ${HOMEFLARE_GROUP} group's branch; auto-merge not armed"
89
+ exit 0
90
+ fi
91
+ # ⛔ gh merges a CLEAN or UNSTABLE pull request at once rather than arming it. Only a branch
92
+ # rule that requires a status check keeps it BLOCKED until the checks have passed.
93
+ required=$(gh api "repos/$GITHUB_REPOSITORY/rules/branches/$BASE_REF" \\
94
+ --jq '[.[] | select(.type == "required_status_checks")] | length')
95
+ if [ "$required" = 0 ]; then
96
+ echo "::error::$BASE_REF requires no status check, so gh would merge at once; auto-merge not armed"
97
+ exit 1
98
+ fi
99
+ # ★ --squash: the house repositories allow squash merges only (declareRepoPolicy).
100
+ gh pr merge --auto --squash "$PR_URL"`;
101
+
102
+ /** The whole `dependabot-automerge.yml` for a shape. */
103
+ export function renderAutomerge(shape: RepoShape): string {
104
+ // ⚠️ GitHub ACTIONS EXPRESSIONS, NOT TEMPLATE LITERALS: the runner interpolates `${{ … }}`
105
+ // at job time, so they must reach the file verbatim (see security.ts for the same note).
106
+ // oxlint-disable-next-line no-template-curly-in-string
107
+ const headRef = '${{ github.head_ref }}';
108
+ // oxlint-disable-next-line no-template-curly-in-string
109
+ const baseRef = '${{ github.base_ref }}';
110
+ // oxlint-disable-next-line no-template-curly-in-string
111
+ const prUrl = '${{ github.event.pull_request.html_url }}';
112
+ // oxlint-disable-next-line no-template-curly-in-string
113
+ const token = '${{ secrets.GITHUB_TOKEN }}';
114
+ const condition = [
115
+ "github.event.pull_request.user.login == 'dependabot[bot]'",
116
+ "github.actor == 'dependabot[bot]'",
117
+ `startsWith(github.head_ref, '${GROUP_BRANCH_PREFIX}')`,
118
+ ].join(' && ');
119
+
120
+ return `${HEADER}
121
+ ${TRIGGER}
122
+ jobs:
123
+ ${JOB_NOTE}
124
+ arm:
125
+ name: arm auto-merge
126
+ if: ${condition}
127
+ runs-on: ${runsOn(shape.runner)}
128
+ ${PERMISSIONS_NOTE}
129
+ permissions:
130
+ contents: write
131
+ pull-requests: write
132
+ steps:
133
+ ${renderSteps(
134
+ [
135
+ {
136
+ env: { BASE_REF: baseRef, GH_TOKEN: token, HEAD_REF: headRef, PR_URL: prUrl },
137
+ name: 'Arm auto-merge on the homeflare group',
138
+ run: ARM,
139
+ },
140
+ ],
141
+ 3,
142
+ )}
143
+ `;
144
+ }
@@ -10,8 +10,8 @@
10
10
  * `needs`, so adding a job never means editing a branch ruleset. `repoShapeChecks()`
11
11
  * returns exactly `['ci', 'secret scan']`, which is what `declareRepoPolicy` requires.
12
12
  */
13
- import type { ExtraJob, RepoShape } from './shape.ts';
14
- import { runsOn } from './shape.ts';
13
+ import type { ExtraJob, JobStep, RepoShape } from './shape.ts';
14
+ import { nodeMajor, runsOn } from './shape.ts';
15
15
  import { renderSteps } from './yaml.ts';
16
16
 
17
17
  /** Bun the whole estate is pinned to. One line, one place. */
@@ -21,6 +21,7 @@ export const ACTIONLINT_VERSION = '1.7.12';
21
21
 
22
22
  const CHECKOUT = 'actions/checkout@v7';
23
23
  const SETUP_BUN = 'oven-sh/setup-bun@v2';
24
+ const SETUP_NODE = 'actions/setup-node@v6';
24
25
 
25
26
  function runnerNote(shape: RepoShape): string {
26
27
  if (shape.runner !== 'mini') {
@@ -148,31 +149,49 @@ const VERIFY = `if [ "\${{ contains(needs.*.result, 'failure') }}" = "true" ] ||
148
149
  exit 1
149
150
  fi`;
150
151
 
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
- );
152
+ const NODE_NOTE = ` # ⛔ REAL NODE, NOT BUN'S SHIM, AND ONLY WHERE THE GATE NEEDS IT. The mini's job
153
+ # image has no node on PATH (ubuntu-latest always did), so a repository whose own
154
+ # \`check\` spawns \`node\` — or whose framework demands a Node runtime — fails with
155
+ # \`Executable not found in $PATH: "node"\` without this. Declared as \`node:\` in
156
+ # repo-shape.ts, so it is one input rather than a hand-edited block per repository.
157
+ # ⚠️ NO PACKAGE-MANAGER CACHE: bun does the installing, so priming npm's cache costs
158
+ # time and caches nothing anything here reads.`;
159
+
160
+ function prologue(shape: RepoShape): string {
161
+ const node = nodeMajor(shape);
162
+ const bun: JobStep[] = [
163
+ { uses: SETUP_BUN, with: { 'bun-version': BUN_VERSION } },
164
+ { run: 'bun install --frozen-lockfile' },
165
+ ];
166
+ if (node === undefined) return renderSteps([{ uses: CHECKOUT }, ...bun], 3);
167
+ return [
168
+ renderSteps([{ uses: CHECKOUT }], 3),
169
+ NODE_NOTE,
170
+ renderSteps(
171
+ [{ uses: SETUP_NODE, with: { 'node-version': node, 'package-manager-cache': 'false' } }],
172
+ 3,
173
+ ),
174
+ renderSteps(bun, 3),
175
+ ].join('\n');
160
176
  }
161
177
 
162
- function renderExtraJob(job: ExtraJob, on: string): string {
178
+ function renderExtraJob(job: ExtraJob, shape: RepoShape, on: string): string {
163
179
  const needs =
164
180
  (job.needs ?? []).length === 0 ? '' : ` needs: [${(job.needs ?? []).join(', ')}]\n`;
181
+ // ★ A TIMEOUT IS THE JOB'S, NOT THE SHAPE'S. Only a job that starts something with its
182
+ // own wait needs one, and it is rendered where a reader looks for it.
183
+ const timeout = job.timeout === undefined ? '' : ` timeout-minutes: ${job.timeout}\n`;
165
184
  const steps =
166
185
  job.bun === false
167
186
  ? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3)
168
- : [prologue(), renderSteps(job.steps, 3)].join('\n');
187
+ : [prologue(shape), renderSteps(job.steps, 3)].join('\n');
169
188
  // ★ The stated reason is rendered into the file. A job nobody can explain is a job
170
189
  // nobody dares delete, so the explanation travels with it.
171
190
  return ` # ★ NOT PART OF THE STANDARD SHAPE — ${job.reason}
172
191
  ${job.id}:
173
192
  name: ${job.name}
174
193
  ${needs} runs-on: ${on}
175
- steps:
194
+ ${timeout} steps:
176
195
  ${steps}
177
196
  `;
178
197
  }
@@ -190,7 +209,7 @@ ${CHECK_NOTE}
190
209
  name: check
191
210
  runs-on: ${on}
192
211
  steps:
193
- ${prologue()}
212
+ ${prologue(shape)}
194
213
  ${renderSteps([{ run: 'bun run check' }], 3)}
195
214
 
196
215
  ${WORKFLOWS_NOTE}
@@ -203,7 +222,7 @@ ${ACTIONLINT_NOTE}
203
222
  ${renderSteps([{ name: 'Install actionlint (checksum-verified)', run: INSTALL_ACTIONLINT }], 3)}
204
223
  ${renderSteps([{ name: 'Lint workflows', run: './actionlint -color' }], 3)}
205
224
 
206
- ${extras.map((job) => `${renderExtraJob(job, on)}\n`).join('')}${AGGREGATE_NOTE}
225
+ ${extras.map((job) => `${renderExtraJob(job, shape, on)}\n`).join('')}${AGGREGATE_NOTE}
207
226
  ci:
208
227
  name: ci
209
228
  if: always()
@@ -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
- }