@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.
- package/README.md +46 -0
- package/bin/hooks.ts +19 -0
- package/dist/hooks/gates.d.ts +19 -0
- package/dist/hooks/gates.d.ts.map +1 -0
- package/dist/hooks/install.d.ts +20 -0
- package/dist/hooks/install.d.ts.map +1 -0
- package/dist/hooks/report.d.ts +30 -0
- package/dist/hooks/report.d.ts.map +1 -0
- package/dist/hooks/staged.d.ts +19 -0
- package/dist/hooks/staged.d.ts.map +1 -0
- package/dist/hooks.d.ts +15 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +196 -0
- package/dist/hooks.js.map +14 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +9 -2
- package/dist/release.js.map +3 -3
- package/dist/repo-shape/ci.d.ts +20 -0
- package/dist/repo-shape/ci.d.ts.map +1 -0
- package/dist/repo-shape/companions.d.ts +39 -0
- package/dist/repo-shape/companions.d.ts.map +1 -0
- package/dist/repo-shape/drift.d.ts +29 -0
- package/dist/repo-shape/drift.d.ts.map +1 -0
- package/dist/repo-shape/refresh.d.ts +18 -0
- package/dist/repo-shape/refresh.d.ts.map +1 -0
- package/dist/repo-shape/render.d.ts +39 -0
- package/dist/repo-shape/render.d.ts.map +1 -0
- package/dist/repo-shape/security.d.ts +19 -0
- package/dist/repo-shape/security.d.ts.map +1 -0
- package/dist/repo-shape/shape.d.ts +138 -0
- package/dist/repo-shape/shape.d.ts.map +1 -0
- package/dist/repo-shape/yaml.d.ts +18 -0
- package/dist/repo-shape/yaml.d.ts.map +1 -0
- package/dist/repo-shape.d.ts +43 -0
- package/dist/repo-shape.d.ts.map +1 -0
- package/dist/repo-shape.js +611 -0
- package/dist/repo-shape.js.map +17 -0
- package/docs/repo-shape.md +199 -0
- package/package.json +11 -1
- package/src/hooks/gates.ts +102 -0
- package/src/hooks/install.ts +95 -0
- package/src/hooks/report.ts +72 -0
- package/src/hooks/staged.ts +61 -0
- package/src/hooks.ts +48 -0
- package/src/release.ts +15 -1
- package/src/repo-shape/ci.ts +215 -0
- package/src/repo-shape/companions.ts +151 -0
- package/src/repo-shape/drift.ts +130 -0
- package/src/repo-shape/refresh.ts +113 -0
- package/src/repo-shape/render.ts +88 -0
- package/src/repo-shape/security.ts +109 -0
- package/src/repo-shape/shape.ts +214 -0
- package/src/repo-shape/yaml.ts +96 -0
- 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
|