@homeflare/config 0.9.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.
- 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/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/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/src/repo-shape/ci.ts
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
+
}
|
package/src/repo-shape/render.ts
CHANGED
|
@@ -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
|
|
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
|
];
|
package/src/repo-shape/shape.ts
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
41
|
+
HOMEFLARE_GROUP,
|
|
42
|
+
HOMEFLARE_PATTERN,
|
|
40
43
|
renderDependabot,
|
|
41
|
-
|
|
44
|
+
THIRD_PARTY_COOLDOWN_DAYS,
|
|
45
|
+
} from './repo-shape/dependabot.ts';
|
|
42
46
|
export {
|
|
43
47
|
type DriftReport,
|
|
44
48
|
type Problem,
|