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