@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,615 @@
1
+ // @bun
2
+ // src/repo-shape/shape.ts
3
+ function except(exception) {
4
+ return {
5
+ file: exception.file,
6
+ reason: requireSentence("reason", exception.reason),
7
+ since: requireIsoDate(exception.since)
8
+ };
9
+ }
10
+ function extraJob(job) {
11
+ if (job.steps.length === 0) {
12
+ throw new Error(`repo-shape: extra job "${job.id}" has no steps`);
13
+ }
14
+ return {
15
+ bun: job.bun ?? true,
16
+ id: requireJobId(job.id),
17
+ name: requireNonEmpty("name", job.name),
18
+ needs: [...job.needs ?? []],
19
+ reason: requireSentence("reason", job.reason),
20
+ steps: [...job.steps]
21
+ };
22
+ }
23
+ var SENTENCE = 12;
24
+ function requireNonEmpty(field, value) {
25
+ const trimmed = value.trim();
26
+ if (trimmed.length === 0)
27
+ throw new Error(`repo-shape: ${field} must not be blank`);
28
+ return trimmed;
29
+ }
30
+ function requireSentence(field, value) {
31
+ const trimmed = value.trim();
32
+ if (trimmed.length < SENTENCE) {
33
+ throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);
34
+ }
35
+ return trimmed;
36
+ }
37
+ function requireIsoDate(value) {
38
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
39
+ throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
40
+ }
41
+ return value;
42
+ }
43
+ function requireJobId(value) {
44
+ if (!/^[a-z][a-z0-9_-]*$/.test(value)) {
45
+ throw new Error(`repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`);
46
+ }
47
+ return value;
48
+ }
49
+ function runsOn(runner) {
50
+ return runner === "mini" ? "[self-hosted, homeflare-mini]" : "ubuntu-latest";
51
+ }
52
+ function isExcepted(shape, file) {
53
+ return (shape.exceptions ?? []).some((exception) => exception.file === file);
54
+ }
55
+
56
+ // src/repo-shape/yaml.ts
57
+ var INDENT = " ";
58
+ function indent(depth) {
59
+ return INDENT.repeat(depth);
60
+ }
61
+ var PLAIN = /^[A-Za-z0-9_./][A-Za-z0-9_ ./@:+-]*$/;
62
+ var YAML_KEYWORD = /^(y|Y|n|N|on|On|ON|no|No|NO|yes|Yes|YES|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/;
63
+ function scalar(value) {
64
+ if (typeof value === "number")
65
+ return String(value);
66
+ if (value === "")
67
+ return "''";
68
+ const plain = PLAIN.test(value) && !YAML_KEYWORD.test(value) && !value.includes(": ") && !value.includes(" #") && !/^\d.*:/.test(value) && value.trimEnd() === value;
69
+ return plain ? value : `'${value.replaceAll("'", "''")}'`;
70
+ }
71
+ function renderMapping(entries, depth) {
72
+ return Object.entries(entries).map(([key, value]) => `${indent(depth)}${key}: ${scalar(value)}`);
73
+ }
74
+ function renderRun(command, depth) {
75
+ const lines = command.replace(/\n+$/, "").split(`
76
+ `);
77
+ if (lines.length === 1)
78
+ return [`${indent(depth)}run: ${scalar(lines[0] ?? "")}`];
79
+ return [`${indent(depth)}run: |`, ...lines.map((line) => `${indent(depth + 1)}${line}`)];
80
+ }
81
+ function renderStep(step, depth) {
82
+ const lines = [];
83
+ const body = step.uses === undefined ? renderRun(step.run ?? "", depth + 1) : [`${indent(depth + 1)}uses: ${step.uses}`];
84
+ const head = [];
85
+ if (step.name !== undefined)
86
+ head.push(`${indent(depth + 1)}name: ${scalar(step.name)}`);
87
+ if (step.if !== undefined)
88
+ head.push(`${indent(depth + 1)}if: ${scalar(step.if)}`);
89
+ const [first = "", ...rest] = [...head, ...body];
90
+ lines.push(`${indent(depth)}- ${first.trimStart()}`, ...rest);
91
+ if (step.with !== undefined && Object.keys(step.with).length > 0) {
92
+ lines.push(`${indent(depth + 1)}with:`, ...renderMapping(step.with, depth + 2));
93
+ }
94
+ if (step.env !== undefined && Object.keys(step.env).length > 0) {
95
+ lines.push(`${indent(depth + 1)}env:`, ...renderMapping(step.env, depth + 2));
96
+ }
97
+ return lines;
98
+ }
99
+ function renderSteps(steps, depth) {
100
+ return steps.flatMap((step) => renderStep(step, depth)).join(`
101
+ `);
102
+ }
103
+
104
+ // src/repo-shape/ci.ts
105
+ var BUN_VERSION = "1.4.0";
106
+ var ACTIONLINT_VERSION = "1.7.12";
107
+ var CHECKOUT = "actions/checkout@v7";
108
+ var SETUP_BUN = "oven-sh/setup-bun@v2";
109
+ function runnerNote(shape) {
110
+ if (shape.runner !== "mini") {
111
+ return "# \u26A0\uFE0F 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";
112
+ }
113
+ return [
114
+ "# \u26A0\uFE0F RUNS ON THE MINI (`[self-hosted, homeflare-mini]`): GitHub-hosted runners are refused for",
115
+ "# this account (billing lock, 2026-09-22), so every job here runs in a one-job Linux arm64",
116
+ "# container started by homeflare-mini's src/ci-runner (docs/ci-runner.md there). Each action",
117
+ `# below was checked for linux/arm64. The job image preloads bun ${BUN_VERSION} where setup-bun`,
118
+ "# looks, gh, actionlint, gitleaks, typos and shfmt; no node on PATH.",
119
+ ""
120
+ ].join(`
121
+ `);
122
+ }
123
+ var HEADER = `# Pull requests: is this code correct?
124
+ #
125
+ # \uD83E\uDD16 RENDERED BY @homeflare/config \u2014 DO NOT EDIT THIS FILE BY HAND.
126
+ # Its input is this repository's \`repo-shape.ts\`. Change that, then:
127
+ # bun run repo-shape:refresh
128
+ # A hand edit is reverted by the next refresh and fails \`bun run check\` before that.
129
+ # A file this repository must own outright is declared as an \`except({...})\` with a
130
+ # reason, which stops the check from comparing it \u2014 see @homeflare/config/repo-shape.
131
+ #
132
+ # \u2605 SPLIT BY CONCERN, NOT ONE BIG JOB \u2014 AND NOT ONE JOB PER COMMAND EITHER: lanes that
133
+ # share a prologue are steps inside \`check\`, so the install is paid once.
134
+ # \u26D4 EVERY ACTION IS FIRST-PARTY OR THE VENDOR'S OWN, PINNED TO A MAJOR TAG.
135
+ `;
136
+ var TRIGGER = `name: ci
137
+
138
+ on:
139
+ # \u26D4 NO \`push: branches: [main]\`. Every commit reaches main through a pull request whose
140
+ # \`ci\` had to be green \u2014 the branch ruleset requires it and carries no bypass actors \u2014
141
+ # so a second run on the squash commit recomputed an answer it already had. Measured
142
+ # 2026-09-15..22 across the estate: 613 of 619 main-push runs had a head_sha identical
143
+ # to the merge_commit_sha of an already-green PR. That duplication was ~41% of the Mac
144
+ # mini's entire CI load.
145
+ # \u26A0\uFE0F WHAT THIS GIVES UP, SAID OUT LOUD: the ~4% of merges whose base DID move between the
146
+ # PR run and the squash no longer get a post-merge re-test. That run gated nothing \u2014 it
147
+ # reported after main already had the commit \u2014 and the next PR, which branches from the
148
+ # merged main, is what actually catches a semantic conflict.
149
+ pull_request:
150
+
151
+ permissions:
152
+ contents: read
153
+
154
+ concurrency:
155
+ group: ci-\${{ github.ref }}
156
+ cancel-in-progress: true
157
+ `;
158
+ var CHECK_NOTE = ` # \u2500\u2500 One job, one install \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
159
+ # \u2605 WAS 3 SEPARATE JOBS: lint and format, types, tests. They shared an identical
160
+ # prologue \u2014 checkout, setup-bun, \`bun install --frozen-lockfile\` \u2014 and the install,
161
+ # not the check, was the cost: measured on the mini 2026-09-22, install p50 51s /
162
+ # p90 81s against 1-3s for the command the install existed to enable. 3 jobs meant 3
163
+ # installs and 3 containers to compute one answer, and on a 3-slot pool that was one
164
+ # pull request asking for every slot to answer a single question.
165
+ # \u26D4 ONE STEP: \`bun run check\`, THE REPOSITORY'S OWN GATE, NOT A COPY OF ITS LANES.
166
+ # The house rule is that CI runs the same command a person runs, and a workflow that
167
+ # re-lists \`lint\`, \`types\`, \`test\` is a second copy of that command which can quietly
168
+ # check LESS than the local gate. Measured 2026-09-22: \`bun run check\` in homeflare-kit
169
+ # is \`lint && types && build && test\`, and its tests/dist.test.ts SKIPS ITSELF when
170
+ # dist/ is absent \u2014 so a workflow running lint/types/test without the build would drop
171
+ # that test silently and still report green. homeflare-alerts' check runs
172
+ # \`check:types\` and \`build:web\`; homeflare-subnet-calc's delegates to \`verify\`. All
173
+ # fourteen repositories have a \`check\` script and every one is local-only \u2014 no network,
174
+ # no deploy. Checked, not assumed.
175
+ # \u2605 THE SPLIT OF RESPONSIBILITY: this renderer owns the PLUMBING \u2014 triggers, permissions,
176
+ # concurrency, runner, action versions, the aggregate gate \u2014 and the repository owns
177
+ # WHAT ITS GATE RUNS, in package.json, where a change to it shows up in that
178
+ # repository's own diff rather than in a workflow nobody reads.
179
+ # \u26A0\uFE0F THE TRADE, STATED: a red X says \`check\` rather than naming the lane. \`bun run check\`
180
+ # short-circuits on the first failure and its output names the lane, which is the same
181
+ # signal a person gets locally.`;
182
+ var WORKFLOWS_NOTE = ` # \u2605 \`workflow lint\` STAYS ITS OWN JOB on purpose: it needs no \`bun install\` at all
183
+ # (actionlint is preloaded on the mini's job image \u2014 measured 5s end to end), so
184
+ # folding it into \`check\` would make a YAML-only change pay the install for nothing.`;
185
+ var ACTIONLINT_NOTE = ` # \u26D4 THERE IS NO FIRST-PARTY actionlint ACTION, and the npm package by that name is
186
+ # an unrelated wasm port with no \`bin\`. The vendor's own documented CI path is
187
+ # \`download-actionlint.bash\` \u2014 but that script does NOT verify a checksum (read at
188
+ # the v${ACTIONLINT_VERSION} tag, 2026-09-16: \`curl -L "$url" | tar xvz\`, no sha256sum anywhere).
189
+ # rhysd's releases DO publish a \`_checksums.txt\` per version; this verifies against
190
+ # that instead of trusting the tarball on receipt.
191
+ # \u26A0\uFE0F ARCH FROM THE RUNNER, NOT HARD-CODED: the mini's runners are arm64, and an amd64
192
+ # binary fails with "exec format error" (the checksum file lists both).`;
193
+ var INSTALL_ACTIONLINT = `set -euo pipefail
194
+ version=${ACTIONLINT_VERSION}
195
+ if command -v actionlint >/dev/null 2>&1 &&
196
+ [ "$(actionlint -version 2>/dev/null | sed -n 1p)" = "\${version}" ]; then
197
+ cp "$(command -v actionlint)" ./actionlint
198
+ exit 0
199
+ fi
200
+ case "$(uname -m)" in
201
+ x86_64) arch=amd64 ;;
202
+ aarch64 | arm64) arch=arm64 ;;
203
+ *) echo "unsupported architecture $(uname -m)" >&2; exit 1 ;;
204
+ esac
205
+ file="actionlint_\${version}_linux_\${arch}.tar.gz"
206
+ base="https://github.com/rhysd/actionlint/releases/download/v\${version}"
207
+ curl -sSfLO "\${base}/\${file}"
208
+ curl -sSfLO "\${base}/actionlint_\${version}_checksums.txt"
209
+ grep " \${file}\\$" "actionlint_\${version}_checksums.txt" | sha256sum -c -
210
+ tar xzf "\${file}" actionlint`;
211
+ var AGGREGATE_NOTE = ` # \u2500\u2500 The one check a branch rule can require \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
212
+ # \u2605 A single required check that depends on all of them. Without this, adding a job
213
+ # means editing the branch ruleset too, and forgetting to means the new job is
214
+ # advisory without anyone noticing.
215
+ # \u26D4 FAILS ON ANYTHING OTHER THAN success, INCLUDING skipped. A job result has exactly
216
+ # four values: success, failure, cancelled, skipped. Checking only the first two lets
217
+ # a skipped job \u2014 a bad \`if:\` condition, a misconfigured dependency, a runner picking
218
+ # up nothing \u2014 report this aggregate green with a required job never having run.`;
219
+ var VERIFY = `if [ "\${{ contains(needs.*.result, 'failure') }}" = "true" ] || \\
220
+ [ "\${{ contains(needs.*.result, 'cancelled') }}" = "true" ] || \\
221
+ [ "\${{ contains(needs.*.result, 'skipped') }}" = "true" ]; then
222
+ echo "one or more required jobs did not succeed: \${{ toJSON(needs.*.result) }}" >&2
223
+ exit 1
224
+ fi`;
225
+ function prologue() {
226
+ return renderSteps([
227
+ { uses: CHECKOUT },
228
+ { uses: SETUP_BUN, with: { "bun-version": BUN_VERSION } },
229
+ { run: "bun install --frozen-lockfile" }
230
+ ], 3);
231
+ }
232
+ function renderExtraJob(job, on) {
233
+ const needs = (job.needs ?? []).length === 0 ? "" : ` needs: [${(job.needs ?? []).join(", ")}]
234
+ `;
235
+ const steps = job.bun === false ? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3) : [prologue(), renderSteps(job.steps, 3)].join(`
236
+ `);
237
+ return ` # \u2605 NOT PART OF THE STANDARD SHAPE \u2014 ${job.reason}
238
+ ${job.id}:
239
+ name: ${job.name}
240
+ ${needs} runs-on: ${on}
241
+ steps:
242
+ ${steps}
243
+ `;
244
+ }
245
+ function renderCi(shape) {
246
+ const on = runsOn(shape.runner);
247
+ const extras = shape.extraJobs ?? [];
248
+ const needs = ["check", "workflows", ...extras.map((job) => job.id)];
249
+ return `${HEADER}${runnerNote(shape)}${TRIGGER}
250
+ jobs:
251
+ ${CHECK_NOTE}
252
+ check:
253
+ name: check
254
+ runs-on: ${on}
255
+ steps:
256
+ ${prologue()}
257
+ ${renderSteps([{ run: "bun run check" }], 3)}
258
+
259
+ ${WORKFLOWS_NOTE}
260
+ workflows:
261
+ name: workflow lint
262
+ runs-on: ${on}
263
+ steps:
264
+ ${renderSteps([{ uses: CHECKOUT }], 3)}
265
+ ${ACTIONLINT_NOTE}
266
+ ${renderSteps([{ name: "Install actionlint (checksum-verified)", run: INSTALL_ACTIONLINT }], 3)}
267
+ ${renderSteps([{ name: "Lint workflows", run: "./actionlint -color" }], 3)}
268
+
269
+ ${extras.map((job) => `${renderExtraJob(job, on)}
270
+ `).join("")}${AGGREGATE_NOTE}
271
+ ci:
272
+ name: ci
273
+ if: always()
274
+ needs: [${needs.join(", ")}]
275
+ runs-on: ${on}
276
+ steps:
277
+ ${renderSteps([{ name: "Verify every job succeeded", run: VERIFY }], 3)}
278
+ `;
279
+ }
280
+ // src/repo-shape/companions.ts
281
+ var CHANGESET_SCHEMA = "4.0.1";
282
+ function renderChangesetConfig(shape) {
283
+ const config = {
284
+ $schema: `https://unpkg.com/@changesets/config@${CHANGESET_SCHEMA}/schema.json`,
285
+ changelog: ["@changesets/changelog-github", { repo: `${shape.owner}/${shape.repository}` }],
286
+ commit: false,
287
+ fixed: [],
288
+ linked: [],
289
+ access: shape.publishes ? "public" : "restricted",
290
+ baseBranch: "main",
291
+ updateInternalDependencies: "patch",
292
+ ignore: [],
293
+ ...shape.publishes ? {} : { privatePackages: { version: true, tag: false } }
294
+ };
295
+ return `${JSON.stringify(config, undefined, 2)}
296
+ `;
297
+ }
298
+ function renderActionlintConfig(shape) {
299
+ if (shape.runner !== "mini")
300
+ return;
301
+ return `# actionlint's list of self-hosted runner labels (its \`runner-label\` check).
302
+ #
303
+ # \uD83E\uDD16 RENDERED BY @homeflare/config \u2014 DO NOT EDIT THIS FILE BY HAND.
304
+ # Refresh with \`bun run repo-shape:refresh\`.
305
+ #
306
+ # \u2605 DECLARED, NOT SUPPRESSED: the rule stays on, so a typo'd label (\`homeflare-mnii\`)
307
+ # still fails the \`workflow lint\` job rather than queueing a job no runner claims.
308
+ # \`homeflare-mini\` is the interim runner on the Mac mini (homeflare-mini's
309
+ # docs/ci-runner.md); setting this repository's shape to \`runner: 'github'\` removes
310
+ # this file with the \`runs-on\` that needed it.
311
+ self-hosted-runner:
312
+ labels:
313
+ - homeflare-mini
314
+ `;
315
+ }
316
+ function renderDependabot(shape) {
317
+ const bunDirs = shape.publishes ? ["/", "/packages/*"] : ["/"];
318
+ return `# Dependabot for ${shape.repository}.
319
+ #
320
+ # \uD83E\uDD16 RENDERED BY @homeflare/config \u2014 DO NOT EDIT THIS FILE BY HAND.
321
+ # Refresh with \`bun run repo-shape:refresh\`.
322
+ #
323
+ # \u26D4 THIS FILE LIVES AT .github/dependabot.yml, NOT IN .github/workflows/. Dependabot is a
324
+ # platform feature, not an Action \u2014 a config placed among the workflows is silently
325
+ # ignored, and the symptom is simply that no pull requests ever arrive. Measured
326
+ # 2026-09-22: 13 of 14 HomeFlare repositories had no dependabot config at all, so their
327
+ # Actions and their toolchain went stale invisibly, which is exactly how nothing fails.
328
+ #
329
+ # \u2605 WHY GROUPED RATHER THAN ONE PR PER DEPENDENCY. The default opens a pull request per
330
+ # outdated package; that is a trickle nobody reviews properly. Each group below is a set
331
+ # that is either safe to take together or needs deciding together.
332
+ version: 2
333
+
334
+ updates:
335
+ # \u2500\u2500 The toolchain (bun.lock) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
336
+ - package-ecosystem: bun
337
+ directories:
338
+ ${bunDirs.map((dir) => ` - ${dir}`).join(`
339
+ `)}
340
+ schedule:
341
+ interval: weekly
342
+ day: monday
343
+ time: '09:00'
344
+ timezone: America/New_York
345
+ open-pull-requests-limit: 5
346
+ commit-message:
347
+ prefix: 'chore'
348
+ include: scope
349
+ labels: [dependencies]
350
+ groups:
351
+ # oxfmt and oxlint move together and only affect style. Minor and patch bumps are
352
+ # noise unless they fail CI, which is what CI is for.
353
+ lint-and-format:
354
+ patterns: ['oxfmt', 'oxlint']
355
+ update-types: [minor, patch]
356
+
357
+ # \u26A0\uFE0F MAJORS EXCLUDED DELIBERATELY. TypeScript majors change what typechecks;
358
+ # changesets majors have renamed inputs and dropped compatibility (the action's v2
359
+ # did both). These want reading, not merging on green.
360
+ build-tooling:
361
+ patterns: ['typescript', '@changesets/*', '@types/bun']
362
+ update-types: [minor, patch]
363
+
364
+ # \u2500\u2500 The workflows themselves \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
365
+ # \u2605 Actions go stale invisibly: nothing fails, they just keep running old code. The
366
+ # estate's first workflows pinned checkout@v5 (current: v7) and changesets/action@v1
367
+ # (current: v2, with every input renamed) \u2014 measured 2026-09-15.
368
+ - package-ecosystem: github-actions
369
+ directory: /
370
+ schedule:
371
+ interval: weekly
372
+ day: monday
373
+ time: '09:00'
374
+ timezone: America/New_York
375
+ open-pull-requests-limit: 5
376
+ commit-message:
377
+ prefix: 'ci'
378
+ labels: [dependencies, github-actions]
379
+ groups:
380
+ actions:
381
+ patterns: ['*']
382
+ update-types: [minor, patch]
383
+ `;
384
+ }
385
+ // src/repo-shape/security.ts
386
+ var CRON = "41 6 * * 1";
387
+ var HEADER2 = `# Secret scanning, on its own schedule and its own check.
388
+ #
389
+ # \uD83E\uDD16 RENDERED BY @homeflare/config \u2014 DO NOT EDIT THIS FILE BY HAND.
390
+ # Its input is this repository's \`repo-shape.ts\`; refresh with \`bun run repo-shape:refresh\`.
391
+ #
392
+ # \u2605 SEPARATE FROM ci.yml BY CONCERN. A secret scan answers a different question from "is
393
+ # this code correct", it runs on a schedule as well as on pull requests (a rule added
394
+ # tomorrow can find a secret committed today), and it needs full history, which the CI
395
+ # jobs deliberately do not fetch.
396
+ `;
397
+ var TRIGGER2 = `name: security
398
+
399
+ on:
400
+ # \u26D4 NO \`push: branches: [main]\`, for the reason ci.yml gives: main only moves by
401
+ # squashing a pull request this same workflow already scanned, so the post-merge scan
402
+ # re-read commits it had just cleared. The weekly cron is what covers "a rule added
403
+ # tomorrow finds a secret committed today" \u2014 and it is the one that fetches full history.
404
+ pull_request:
405
+ schedule:
406
+ - cron: '${CRON}'
407
+
408
+ permissions:
409
+ contents: read
410
+ # \uD83D\uDD34 gitleaks-action lists pull-request commits through GitHub's API before scanning,
411
+ # and posts inline annotations. Without this, \`pull_request\` runs fail 403 before
412
+ # inspecting any code. Two of thirteen repositories were missing it (2026-09-22).
413
+ pull-requests: read
414
+
415
+ # \u26D4 cancel-in-progress IS CONDITIONAL, NOT \`true\`. The scheduled run's ref is
416
+ # refs/heads/main; a plain \`true\` would let anything sharing that group kill the weekly
417
+ # full-history scan. Only pull-request runs are ever cancelled.
418
+ concurrency:
419
+ group: security-\${{ github.ref }}
420
+ cancel-in-progress: \${{ github.event_name == 'pull_request' }}
421
+ `;
422
+ var FETCH_NOTE = ` with:
423
+ # \u26D4 FULL HISTORY. A shallow clone scans only the tip, which misses the case that
424
+ # matters most: a secret added and then removed in a later commit. It is still
425
+ # in the history, still fetchable, and still compromised.
426
+ fetch-depth: 0`;
427
+ var GITLEAKS_NOTE = ` # \u26D4 v3, NOT v2. v2 declares \`runs: node20\`. GitHub removed that runtime from
428
+ # its HOSTED runner images on 2026-09-16 (gitleaks/gitleaks-action's own v3.0.0
429
+ # migration notes, verified 2026-09-17), so v2 fails on ubuntu-latest and still
430
+ # runs on the mini's self-hosted runner. Moving to v3 drops a dependency on a
431
+ # runtime the platform has already withdrawn, before any job moves back to a
432
+ # hosted runner \u2014 it is not repairing a scan that is currently broken. v3
433
+ # changes only the runtime to node24 \u2014 inputs, outputs and behaviour are unchanged.
434
+ # \u2605 arm64-SAFE ON THE MINI, verified rather than assumed: v3's src/gitleaks.js builds
435
+ # its download URL from \`process.arch\`, so the runner asks for
436
+ # gitleaks_<version>_linux_arm64.tar.gz, an asset its default release publishes
437
+ # (both read 2026-09-22). Nothing here is hard-coded to x64.
438
+ # \u2605 No \`GITLEAKS_LICENSE\`: that env var is required only for GitHub Organization
439
+ # accounts, and \`taslabs-net\` is a personal User account (2026-09-17,
440
+ # \`gh api users/taslabs-net --jq .type\` -> \`User\`).`;
441
+ function renderSecurity(shape) {
442
+ const on = runsOn(shape.runner);
443
+ const token = "${{ secrets.GITHUB_TOKEN }}";
444
+ const scheduled = shape.runner === "mini" ? `
445
+ # \u26D4 THE SCHEDULED RUN GOES TO THE MINI TOO: if the runner daemon is down this queues
446
+ # rather than failing, so a missed weekly scan is silent. homeflare-mini's
447
+ # \`ci-runner\` vmalert group is what notices that, not this workflow.` : "";
448
+ return `${HEADER2}${TRIGGER2}
449
+ jobs:
450
+ secrets:
451
+ name: secret scan${scheduled}
452
+ runs-on: ${on}
453
+ steps:
454
+ ${renderSteps([{ uses: "actions/checkout@v7" }], 3)}
455
+ ${FETCH_NOTE}
456
+
457
+ ${GITLEAKS_NOTE}
458
+ ${renderSteps([{ uses: "gitleaks/gitleaks-action@v3", env: { GITHUB_TOKEN: token } }], 3)}
459
+ `;
460
+ }
461
+
462
+ // src/repo-shape/render.ts
463
+ function renderRepoShape(shape) {
464
+ const files = {
465
+ ".changeset/config.json": renderChangesetConfig(shape),
466
+ ".github/dependabot.yml": renderDependabot(shape),
467
+ ".github/workflows/ci.yml": renderCi(shape),
468
+ ".github/workflows/security.yml": renderSecurity(shape)
469
+ };
470
+ const actionlint = renderActionlintConfig(shape);
471
+ if (actionlint !== undefined)
472
+ files[".github/actionlint.yaml"] = actionlint;
473
+ return {
474
+ files,
475
+ policy: { checks: ["ci", "secret scan"], owner: shape.owner, repository: shape.repository }
476
+ };
477
+ }
478
+ var RENDERED_PATHS = [
479
+ ".changeset/config.json",
480
+ ".github/actionlint.yaml",
481
+ ".github/dependabot.yml",
482
+ ".github/workflows/ci.yml",
483
+ ".github/workflows/security.yml"
484
+ ];
485
+
486
+ // src/repo-shape/drift.ts
487
+ var REFRESH_COMMAND = "bun run repo-shape:refresh";
488
+ function normalize(text) {
489
+ return text.replaceAll(`\r
490
+ `, `
491
+ `);
492
+ }
493
+ async function readIfPresent(path) {
494
+ const file = Bun.file(path);
495
+ return await file.exists() ? normalize(await file.text()) : undefined;
496
+ }
497
+ function staleExceptions(rendered, exceptions) {
498
+ return exceptions.filter((exception) => rendered.files[exception.file] === undefined).map((exception) => `${exception.file}: excepted (since ${exception.since}) but this shape renders no such file \u2014 drop the exception`);
499
+ }
500
+ function duplicateExceptions(exceptions) {
501
+ const seen = new Set;
502
+ const duplicated = new Set;
503
+ for (const exception of exceptions) {
504
+ if (seen.has(exception.file))
505
+ duplicated.add(exception.file);
506
+ seen.add(exception.file);
507
+ }
508
+ return [...duplicated].map((file) => `${file}: declared as an exception more than once \u2014 keep one, with one reason`);
509
+ }
510
+ async function driftInRepoShape(projectDir, shape) {
511
+ const rendered = renderRepoShape(shape);
512
+ const exceptions = shape.exceptions ?? [];
513
+ const excepted = new Map(exceptions.map((exception) => [exception.file, exception]));
514
+ const problems = [
515
+ ...duplicateExceptions(exceptions),
516
+ ...staleExceptions(rendered, exceptions)
517
+ ];
518
+ const drifted = [];
519
+ for (const [path, expected] of Object.entries(rendered.files)) {
520
+ if (excepted.has(path))
521
+ continue;
522
+ const actual = await readIfPresent(`${projectDir}/${path}`);
523
+ if (actual === undefined) {
524
+ problems.push(`${path}: missing \u2014 run \`${REFRESH_COMMAND}\``);
525
+ drifted.push(path);
526
+ continue;
527
+ }
528
+ if (actual !== normalize(expected)) {
529
+ drifted.push(path);
530
+ problems.push(`${path}: differs from what @homeflare/config renders for this shape. ` + `Run \`${REFRESH_COMMAND}\` to take the standard, ` + `or declare it with except({ file: '${path}', reason: '\u2026', since: '\u2026' }) in repo-shape.ts.`);
531
+ }
532
+ }
533
+ return { drifted, excepted: exceptions, problems };
534
+ }
535
+ function exceptionSummary(shape) {
536
+ return (shape.exceptions ?? []).map((exception) => `${exception.file}: not checked \u2014 ${exception.reason} (since ${exception.since})`);
537
+ }
538
+ // src/repo-shape/refresh.ts
539
+ async function refreshRepoShape(projectDir, shape) {
540
+ const rendered = renderRepoShape(shape);
541
+ const written = [];
542
+ const unchanged = [];
543
+ const skipped = [];
544
+ for (const [path, contents] of Object.entries(rendered.files)) {
545
+ if (isExcepted(shape, path)) {
546
+ skipped.push(path);
547
+ continue;
548
+ }
549
+ const target = `${projectDir}/${path}`;
550
+ const file = Bun.file(target);
551
+ if (await file.exists() && await file.text() === contents) {
552
+ unchanged.push(path);
553
+ continue;
554
+ }
555
+ await Bun.write(target, contents);
556
+ written.push(path);
557
+ }
558
+ return { skipped, unchanged, written };
559
+ }
560
+ async function say(stream, lines) {
561
+ if (lines.length === 0)
562
+ return;
563
+ await Bun.write(stream, `${lines.join(`
564
+ `)}
565
+ `);
566
+ }
567
+ async function repoShapeCli(projectDir, shape, argv) {
568
+ if (argv.includes("--check")) {
569
+ const report = await driftInRepoShape(projectDir, shape);
570
+ await say(Bun.stdout, exceptionSummary(shape).map((note) => `\xB7 ${note}`));
571
+ if (report.problems.length === 0) {
572
+ const count = report.excepted.length;
573
+ await say(Bun.stdout, [
574
+ `repo shape: in step with @homeflare/config (${count} declared exception(s))`
575
+ ]);
576
+ return 0;
577
+ }
578
+ await say(Bun.stderr, [
579
+ ...report.problems.map((problem) => `\u2717 ${problem}`),
580
+ "",
581
+ `Run \`${REFRESH_COMMAND}\`, or declare the deviation with a reason.`
582
+ ]);
583
+ return 1;
584
+ }
585
+ const result = await refreshRepoShape(projectDir, shape);
586
+ await say(Bun.stdout, [
587
+ ...result.written.map((path) => `wrote ${path}`),
588
+ ...result.unchanged.map((path) => `ok ${path}`),
589
+ ...result.skipped.map((path) => `excepted ${path}`)
590
+ ]);
591
+ return 0;
592
+ }
593
+ export {
594
+ ACTIONLINT_VERSION,
595
+ BUN_VERSION,
596
+ REFRESH_COMMAND,
597
+ RENDERED_PATHS,
598
+ driftInRepoShape,
599
+ except,
600
+ exceptionSummary,
601
+ extraJob,
602
+ isExcepted,
603
+ refreshRepoShape,
604
+ renderActionlintConfig,
605
+ renderChangesetConfig,
606
+ renderCi,
607
+ renderDependabot,
608
+ renderRepoShape,
609
+ renderSecurity,
610
+ repoShapeCli,
611
+ runsOn
612
+ };
613
+
614
+ //# debugId=90AE28FCE975727B64756E2164756E21
615
+ //# sourceMappingURL=repo-shape.js.map