@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,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a hook talks to whoever triggered it.
|
|
3
|
+
*
|
|
4
|
+
* ★ EVERY FAILURE PRINTS BOTH COMMANDS — the one that fixes it and the one that skips
|
|
5
|
+
* it. A hook that exits non-zero and says nothing teaches `--no-verify` as a reflex,
|
|
6
|
+
* and that switch turns off every check rather than the one that was wrong.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ Output goes to STDERR. Git hooks share stdout with porcelain in some flows, and a
|
|
9
|
+
* hook that writes there can corrupt what a caller is parsing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The two hooks this package implements. Named exactly as the git hook files are. */
|
|
13
|
+
export type Hook = 'pre-commit' | 'pre-push';
|
|
14
|
+
|
|
15
|
+
const BYPASS: Record<Hook, string> = {
|
|
16
|
+
'pre-commit': 'git commit --no-verify',
|
|
17
|
+
'pre-push': 'git push --no-verify',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Run a command, streaming its output. Returns its exit code. */
|
|
21
|
+
export async function run(cmd: readonly string[]): Promise<number> {
|
|
22
|
+
const proc = Bun.spawn([...cmd], { stdout: 'inherit', stderr: 'inherit' });
|
|
23
|
+
return await proc.exited;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Capture a command's stdout. Used for git plumbing only. */
|
|
27
|
+
export async function capture(cmd: readonly string[]): Promise<string> {
|
|
28
|
+
const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'ignore' });
|
|
29
|
+
const text = await new Response(proc.stdout).text();
|
|
30
|
+
await proc.exited;
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a dev tool to the project's own copy.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ NOT `bunx` BY DEFAULT. On a cache miss `bunx` downloads from the registry, and a
|
|
38
|
+
* git hook that reaches the network mid-commit is a hang waiting for a flaky link.
|
|
39
|
+
* husky puts `node_modules/.bin` on PATH, but this runs outside husky in tests, so
|
|
40
|
+
* the local binary is named outright when it exists and `bunx` is only the fallback.
|
|
41
|
+
*/
|
|
42
|
+
export function tool(root: string, name: string): readonly string[] {
|
|
43
|
+
const local = `${root}/node_modules/.bin/${name}`;
|
|
44
|
+
return Bun.file(local).size > 0 ? [local] : ['bunx', name];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ⚠️ `process.stderr.write`, NOT `console`. Two reasons, and the lint rule is the lesser
|
|
49
|
+
* one: a hook shares stdout with git porcelain in some flows, and a synchronous write
|
|
50
|
+
* is the only kind guaranteed to land before `process.exit` below throws the buffer
|
|
51
|
+
* away. Using `console.error` here would also make every consumer of this package
|
|
52
|
+
* need a `no-console` exemption for code they never call directly.
|
|
53
|
+
*/
|
|
54
|
+
function line(text: string): void {
|
|
55
|
+
process.stderr.write(`${text}\n`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ok(what: string): void {
|
|
59
|
+
line(`✓ ${what}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function note(what: string): void {
|
|
63
|
+
line(` ${what}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** ⛔ ALWAYS GIVE THE FIX. "lint failed" is a dead end; the command that repairs it is not. */
|
|
67
|
+
export function fail(hook: Hook, what: string, fix: string): never {
|
|
68
|
+
line(`\n✗ ${hook}: ${what}`);
|
|
69
|
+
line(` fix: ${fix}`);
|
|
70
|
+
line(` bypass: ${BYPASS[hook]} — CI still runs the real gate\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What git has staged, split by what the hook may safely touch.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A STAGED FILE THAT ALSO HAS UNSTAGED EDITS IS OFF LIMITS. Formatting it in place
|
|
5
|
+
* and running `git add` would sweep the contributor's work-in-progress into a commit
|
|
6
|
+
* they did not ask for — the single worst thing a hook can do. Those files are
|
|
7
|
+
* reported and checked, never rewritten.
|
|
8
|
+
*/
|
|
9
|
+
import { capture } from './report.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ⚠️ `.md` IS IN THIS LIST ON PURPOSE. House `oxfmt` formats markdown, so a hook that
|
|
13
|
+
* skipped it would let an unformatted changeset through and CI would fail a commit
|
|
14
|
+
* that looked clean locally. A hook must check what CI checks or it trains distrust.
|
|
15
|
+
*/
|
|
16
|
+
const FORMATTABLE = /\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|md)$/;
|
|
17
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
18
|
+
|
|
19
|
+
export type Staged = {
|
|
20
|
+
/** Fully staged and formattable — safe to rewrite and restage. */
|
|
21
|
+
readonly formattable: readonly string[];
|
|
22
|
+
/** The subset of `formattable` that oxlint understands. */
|
|
23
|
+
readonly code: readonly string[];
|
|
24
|
+
/** Staged but also dirty in the worktree — checked, never rewritten. */
|
|
25
|
+
readonly partial: readonly string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
async function names(args: readonly string[]): Promise<readonly string[]> {
|
|
29
|
+
const out = await capture(['git', ...args]);
|
|
30
|
+
return out.split('\n').filter((line) => line.length > 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Classify the index. Deletions are excluded — there is nothing to format in them. */
|
|
34
|
+
export async function staged(): Promise<Staged> {
|
|
35
|
+
const indexed = await names(['diff', '--cached', '--name-only', '--diff-filter=ACMR']);
|
|
36
|
+
const dirty = new Set(await names(['diff', '--name-only', '--diff-filter=ACMR']));
|
|
37
|
+
const candidates = indexed.filter((file) => FORMATTABLE.test(file));
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
formattable: candidates.filter((file) => !dirty.has(file)),
|
|
41
|
+
code: candidates.filter((file) => !dirty.has(file) && CODE.test(file)),
|
|
42
|
+
partial: candidates.filter((file) => dirty.has(file)),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Content fingerprints, so only the files a formatter actually changed get restaged.
|
|
48
|
+
*
|
|
49
|
+
* ★ WHY NOT RESTAGE EVERYTHING. `git add` on an untouched file is harmless but noisy:
|
|
50
|
+
* the hook would claim it rewrote files it left alone, and a hook that overstates
|
|
51
|
+
* what it did is one nobody reads.
|
|
52
|
+
*/
|
|
53
|
+
export async function fingerprints(files: readonly string[]): Promise<ReadonlyMap<string, string>> {
|
|
54
|
+
const out = new Map<string, string>();
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
const handle = Bun.file(file);
|
|
57
|
+
if (!(await handle.exists())) continue;
|
|
58
|
+
out.set(file, String(Bun.hash(await handle.arrayBuffer())));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HomeFlare git hooks, as a package.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY THIS IS NOT A SCRIPT IN EVERY REPO. Fourteen copies of a hook script is the
|
|
5
|
+
* exact drift `@homeflare/config` exists to prevent: the copies diverge, nobody
|
|
6
|
+
* notices, and two repos disagree about what a commit must satisfy. Here the repo
|
|
7
|
+
* commits a delegating wrapper and the behaviour ships with the package, so changing
|
|
8
|
+
* the rule is one release and a version bump rather than fourteen edits.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ HUSKY, NOT lefthook OR A BARE `core.hooksPath`. It is already the estate's
|
|
11
|
+
* mechanism in the repos that have working hooks, it installs from `prepare` on a
|
|
12
|
+
* plain `bun install`, and it keeps the hook files tracked and reviewable. A second
|
|
13
|
+
* mechanism alongside it would mean two ways to answer "are hooks on in this repo".
|
|
14
|
+
*
|
|
15
|
+
* Usage from a hook file — see `HUSKY_HOOK`:
|
|
16
|
+
*
|
|
17
|
+
* bun node_modules/@homeflare/config/bin/hooks.ts pre-commit
|
|
18
|
+
*/
|
|
19
|
+
import { preCommit, prePush } from './hooks/gates.ts';
|
|
20
|
+
import { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks } from './hooks/install.ts';
|
|
21
|
+
import { type Hook, note, ok } from './hooks/report.ts';
|
|
22
|
+
|
|
23
|
+
export { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks };
|
|
24
|
+
export type { Hook };
|
|
25
|
+
|
|
26
|
+
/** The commands `bin/hooks.ts` accepts. */
|
|
27
|
+
export type Command = Hook | 'install';
|
|
28
|
+
|
|
29
|
+
export function isCommand(value: string): value is Command {
|
|
30
|
+
return value === 'pre-commit' || value === 'pre-push' || value === 'install';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run one hook, or install the wrappers.
|
|
35
|
+
*
|
|
36
|
+
* ⛔ Never exits non-zero for a reason the caller cannot act on: an unknown command is a
|
|
37
|
+
* programming error in the wrapper and is reported as such, not as a failed commit.
|
|
38
|
+
*/
|
|
39
|
+
export async function runCommand(command: Command, root: string): Promise<void> {
|
|
40
|
+
if (command === 'install') {
|
|
41
|
+
const written = await installHooks(root);
|
|
42
|
+
ok(`wrote ${written.join(', ')} — commit them`);
|
|
43
|
+
note('they do nothing until `bun install` runs `prepare` (husky)');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (command === 'pre-commit') return await preCommit(root);
|
|
47
|
+
return await prePush(root);
|
|
48
|
+
}
|
package/src/release.ts
CHANGED
|
@@ -34,9 +34,23 @@ export async function changelogHasEntry(cwd: string, version: string): Promise<b
|
|
|
34
34
|
return new RegExp(`^## ${escaped}$`, 'm').test(await file.text());
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* ⛔ DROP HUSKY GIT_DIR. `cwd` is not enough — pre-push exports GIT_DIR
|
|
39
|
+
* and `git tag -l` then reads this checkout, not the throwaway repo.
|
|
40
|
+
* Symptom: shouldRelease is false during verify and a test commits `init`
|
|
41
|
+
* onto the branch being pushed.
|
|
42
|
+
*/
|
|
43
|
+
const gitEnv = (): NodeJS.ProcessEnv => {
|
|
44
|
+
const env = { ...process.env };
|
|
45
|
+
delete env.GIT_DIR;
|
|
46
|
+
delete env.GIT_WORK_TREE;
|
|
47
|
+
delete env.GIT_INDEX_FILE;
|
|
48
|
+
return env;
|
|
49
|
+
};
|
|
50
|
+
|
|
37
51
|
/** Does this exact tag already exist? Local `git tag -l`, no network. */
|
|
38
52
|
export function tagExists(cwd: string, tag: string): boolean {
|
|
39
|
-
const result = Bun.spawnSync(['git', 'tag', '-l', tag], { cwd });
|
|
53
|
+
const result = Bun.spawnSync(['git', 'tag', '-l', tag], { cwd, env: gitEnv() });
|
|
40
54
|
return result.stdout.toString().trim() === tag;
|
|
41
55
|
}
|
|
42
56
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.github/workflows/ci.yml`, rendered.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE COMMENTS ARE PART OF THE RENDER, NOT DECORATION. Thirteen repositories carried
|
|
5
|
+
* thirteen hand-edited copies of the same reasoning; measured 2026-09-22, the header
|
|
6
|
+
* comment alone had four different wordings and the actionlint block had three. Written
|
|
7
|
+
* here once, every repository gets the same explanation, and correcting it is one edit.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ THE AGGREGATE `ci` JOB IS THE ONLY REQUIRED CHECK. Every other job feeds it through
|
|
10
|
+
* `needs`, so adding a job never means editing a branch ruleset. `repoShapeChecks()`
|
|
11
|
+
* returns exactly `['ci', 'secret scan']`, which is what `declareRepoPolicy` requires.
|
|
12
|
+
*/
|
|
13
|
+
import type { ExtraJob, RepoShape } from './shape.ts';
|
|
14
|
+
import { runsOn } from './shape.ts';
|
|
15
|
+
import { renderSteps } from './yaml.ts';
|
|
16
|
+
|
|
17
|
+
/** Bun the whole estate is pinned to. One line, one place. */
|
|
18
|
+
export const BUN_VERSION = '1.4.0';
|
|
19
|
+
/** actionlint the `workflow lint` job runs, and the mini's job image preloads. */
|
|
20
|
+
export const ACTIONLINT_VERSION = '1.7.12';
|
|
21
|
+
|
|
22
|
+
const CHECKOUT = 'actions/checkout@v7';
|
|
23
|
+
const SETUP_BUN = 'oven-sh/setup-bun@v2';
|
|
24
|
+
|
|
25
|
+
function runnerNote(shape: RepoShape): string {
|
|
26
|
+
if (shape.runner !== 'mini') {
|
|
27
|
+
return "# ⚠️ 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";
|
|
28
|
+
}
|
|
29
|
+
return [
|
|
30
|
+
'# ⚠️ RUNS ON THE MINI (`[self-hosted, homeflare-mini]`): GitHub-hosted runners are refused for',
|
|
31
|
+
'# this account (billing lock, 2026-09-22), so every job here runs in a one-job Linux arm64',
|
|
32
|
+
"# container started by homeflare-mini's src/ci-runner (docs/ci-runner.md there). Each action",
|
|
33
|
+
`# below was checked for linux/arm64. The job image preloads bun ${
|
|
34
|
+
BUN_VERSION
|
|
35
|
+
} where setup-bun`,
|
|
36
|
+
'# looks, gh, actionlint, gitleaks, typos and shfmt; no node on PATH.',
|
|
37
|
+
'',
|
|
38
|
+
].join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const HEADER = `# Pull requests: is this code correct?
|
|
42
|
+
#
|
|
43
|
+
# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
|
|
44
|
+
# Its input is this repository's \`repo-shape.ts\`. Change that, then:
|
|
45
|
+
# bun run repo-shape:refresh
|
|
46
|
+
# A hand edit is reverted by the next refresh and fails \`bun run check\` before that.
|
|
47
|
+
# A file this repository must own outright is declared as an \`except({...})\` with a
|
|
48
|
+
# reason, which stops the check from comparing it — see @homeflare/config/repo-shape.
|
|
49
|
+
#
|
|
50
|
+
# ★ SPLIT BY CONCERN, NOT ONE BIG JOB — AND NOT ONE JOB PER COMMAND EITHER: lanes that
|
|
51
|
+
# share a prologue are steps inside \`check\`, so the install is paid once.
|
|
52
|
+
# ⛔ EVERY ACTION IS FIRST-PARTY OR THE VENDOR'S OWN, PINNED TO A MAJOR TAG.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const TRIGGER = `name: ci
|
|
56
|
+
|
|
57
|
+
on:
|
|
58
|
+
# ⛔ NO \`push: branches: [main]\`. Every commit reaches main through a pull request whose
|
|
59
|
+
# \`ci\` had to be green — the branch ruleset requires it and carries no bypass actors —
|
|
60
|
+
# so a second run on the squash commit recomputed an answer it already had. Measured
|
|
61
|
+
# 2026-09-15..22 across the estate: 613 of 619 main-push runs had a head_sha identical
|
|
62
|
+
# to the merge_commit_sha of an already-green PR. That duplication was ~41% of the Mac
|
|
63
|
+
# mini's entire CI load.
|
|
64
|
+
# ⚠️ WHAT THIS GIVES UP, SAID OUT LOUD: the ~4% of merges whose base DID move between the
|
|
65
|
+
# PR run and the squash no longer get a post-merge re-test. That run gated nothing — it
|
|
66
|
+
# reported after main already had the commit — and the next PR, which branches from the
|
|
67
|
+
# merged main, is what actually catches a semantic conflict.
|
|
68
|
+
pull_request:
|
|
69
|
+
|
|
70
|
+
permissions:
|
|
71
|
+
contents: read
|
|
72
|
+
|
|
73
|
+
concurrency:
|
|
74
|
+
group: ci-\${{ github.ref }}
|
|
75
|
+
cancel-in-progress: true
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const CHECK_NOTE = ` # ── One job, one install ────────────────────────────────────────────────────
|
|
79
|
+
# ★ WAS 3 SEPARATE JOBS: lint and format, types, tests. They shared an identical
|
|
80
|
+
# prologue — checkout, setup-bun, \`bun install --frozen-lockfile\` — and the install,
|
|
81
|
+
# not the check, was the cost: measured on the mini 2026-09-22, install p50 51s /
|
|
82
|
+
# p90 81s against 1-3s for the command the install existed to enable. 3 jobs meant 3
|
|
83
|
+
# installs and 3 containers to compute one answer, and on a 3-slot pool that was one
|
|
84
|
+
# pull request asking for every slot to answer a single question.
|
|
85
|
+
# ⛔ ONE STEP: \`bun run check\`, THE REPOSITORY'S OWN GATE, NOT A COPY OF ITS LANES.
|
|
86
|
+
# The house rule is that CI runs the same command a person runs, and a workflow that
|
|
87
|
+
# re-lists \`lint\`, \`types\`, \`test\` is a second copy of that command which can quietly
|
|
88
|
+
# check LESS than the local gate. Measured 2026-09-22: \`bun run check\` in homeflare-kit
|
|
89
|
+
# is \`lint && types && build && test\`, and its tests/dist.test.ts SKIPS ITSELF when
|
|
90
|
+
# dist/ is absent — so a workflow running lint/types/test without the build would drop
|
|
91
|
+
# that test silently and still report green. homeflare-alerts' check runs
|
|
92
|
+
# \`check:types\` and \`build:web\`; homeflare-subnet-calc's delegates to \`verify\`. All
|
|
93
|
+
# fourteen repositories have a \`check\` script and every one is local-only — no network,
|
|
94
|
+
# no deploy. Checked, not assumed.
|
|
95
|
+
# ★ THE SPLIT OF RESPONSIBILITY: this renderer owns the PLUMBING — triggers, permissions,
|
|
96
|
+
# concurrency, runner, action versions, the aggregate gate — and the repository owns
|
|
97
|
+
# WHAT ITS GATE RUNS, in package.json, where a change to it shows up in that
|
|
98
|
+
# repository's own diff rather than in a workflow nobody reads.
|
|
99
|
+
# ⚠️ THE TRADE, STATED: a red X says \`check\` rather than naming the lane. \`bun run check\`
|
|
100
|
+
# short-circuits on the first failure and its output names the lane, which is the same
|
|
101
|
+
# signal a person gets locally.`;
|
|
102
|
+
|
|
103
|
+
const WORKFLOWS_NOTE = ` # ★ \`workflow lint\` STAYS ITS OWN JOB on purpose: it needs no \`bun install\` at all
|
|
104
|
+
# (actionlint is preloaded on the mini's job image — measured 5s end to end), so
|
|
105
|
+
# folding it into \`check\` would make a YAML-only change pay the install for nothing.`;
|
|
106
|
+
|
|
107
|
+
const ACTIONLINT_NOTE = ` # ⛔ THERE IS NO FIRST-PARTY actionlint ACTION, and the npm package by that name is
|
|
108
|
+
# an unrelated wasm port with no \`bin\`. The vendor's own documented CI path is
|
|
109
|
+
# \`download-actionlint.bash\` — but that script does NOT verify a checksum (read at
|
|
110
|
+
# the v${ACTIONLINT_VERSION} tag, 2026-09-16: \`curl -L "$url" | tar xvz\`, no sha256sum anywhere).
|
|
111
|
+
# rhysd's releases DO publish a \`_checksums.txt\` per version; this verifies against
|
|
112
|
+
# that instead of trusting the tarball on receipt.
|
|
113
|
+
# ⚠️ ARCH FROM THE RUNNER, NOT HARD-CODED: the mini's runners are arm64, and an amd64
|
|
114
|
+
# binary fails with "exec format error" (the checksum file lists both).`;
|
|
115
|
+
|
|
116
|
+
const INSTALL_ACTIONLINT = `set -euo pipefail
|
|
117
|
+
version=${ACTIONLINT_VERSION}
|
|
118
|
+
if command -v actionlint >/dev/null 2>&1 &&
|
|
119
|
+
[ "$(actionlint -version 2>/dev/null | sed -n 1p)" = "\${version}" ]; then
|
|
120
|
+
cp "$(command -v actionlint)" ./actionlint
|
|
121
|
+
exit 0
|
|
122
|
+
fi
|
|
123
|
+
case "$(uname -m)" in
|
|
124
|
+
x86_64) arch=amd64 ;;
|
|
125
|
+
aarch64 | arm64) arch=arm64 ;;
|
|
126
|
+
*) echo "unsupported architecture $(uname -m)" >&2; exit 1 ;;
|
|
127
|
+
esac
|
|
128
|
+
file="actionlint_\${version}_linux_\${arch}.tar.gz"
|
|
129
|
+
base="https://github.com/rhysd/actionlint/releases/download/v\${version}"
|
|
130
|
+
curl -sSfLO "\${base}/\${file}"
|
|
131
|
+
curl -sSfLO "\${base}/actionlint_\${version}_checksums.txt"
|
|
132
|
+
grep " \${file}\\$" "actionlint_\${version}_checksums.txt" | sha256sum -c -
|
|
133
|
+
tar xzf "\${file}" actionlint`;
|
|
134
|
+
|
|
135
|
+
const AGGREGATE_NOTE = ` # ── The one check a branch rule can require ─────────────────────────────────
|
|
136
|
+
# ★ A single required check that depends on all of them. Without this, adding a job
|
|
137
|
+
# means editing the branch ruleset too, and forgetting to means the new job is
|
|
138
|
+
# advisory without anyone noticing.
|
|
139
|
+
# ⛔ FAILS ON ANYTHING OTHER THAN success, INCLUDING skipped. A job result has exactly
|
|
140
|
+
# four values: success, failure, cancelled, skipped. Checking only the first two lets
|
|
141
|
+
# a skipped job — a bad \`if:\` condition, a misconfigured dependency, a runner picking
|
|
142
|
+
# up nothing — report this aggregate green with a required job never having run.`;
|
|
143
|
+
|
|
144
|
+
const VERIFY = `if [ "\${{ contains(needs.*.result, 'failure') }}" = "true" ] || \\
|
|
145
|
+
[ "\${{ contains(needs.*.result, 'cancelled') }}" = "true" ] || \\
|
|
146
|
+
[ "\${{ contains(needs.*.result, 'skipped') }}" = "true" ]; then
|
|
147
|
+
echo "one or more required jobs did not succeed: \${{ toJSON(needs.*.result) }}" >&2
|
|
148
|
+
exit 1
|
|
149
|
+
fi`;
|
|
150
|
+
|
|
151
|
+
function prologue(): string {
|
|
152
|
+
return renderSteps(
|
|
153
|
+
[
|
|
154
|
+
{ uses: CHECKOUT },
|
|
155
|
+
{ uses: SETUP_BUN, with: { 'bun-version': BUN_VERSION } },
|
|
156
|
+
{ run: 'bun install --frozen-lockfile' },
|
|
157
|
+
],
|
|
158
|
+
3,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderExtraJob(job: ExtraJob, on: string): string {
|
|
163
|
+
const needs =
|
|
164
|
+
(job.needs ?? []).length === 0 ? '' : ` needs: [${(job.needs ?? []).join(', ')}]\n`;
|
|
165
|
+
const steps =
|
|
166
|
+
job.bun === false
|
|
167
|
+
? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3)
|
|
168
|
+
: [prologue(), renderSteps(job.steps, 3)].join('\n');
|
|
169
|
+
// ★ The stated reason is rendered into the file. A job nobody can explain is a job
|
|
170
|
+
// nobody dares delete, so the explanation travels with it.
|
|
171
|
+
return ` # ★ NOT PART OF THE STANDARD SHAPE — ${job.reason}
|
|
172
|
+
${job.id}:
|
|
173
|
+
name: ${job.name}
|
|
174
|
+
${needs} runs-on: ${on}
|
|
175
|
+
steps:
|
|
176
|
+
${steps}
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The whole `ci.yml` for a shape. */
|
|
181
|
+
export function renderCi(shape: RepoShape): string {
|
|
182
|
+
const on = runsOn(shape.runner);
|
|
183
|
+
const extras = shape.extraJobs ?? [];
|
|
184
|
+
const needs = ['check', 'workflows', ...extras.map((job) => job.id)];
|
|
185
|
+
|
|
186
|
+
return `${HEADER}${runnerNote(shape)}${TRIGGER}
|
|
187
|
+
jobs:
|
|
188
|
+
${CHECK_NOTE}
|
|
189
|
+
check:
|
|
190
|
+
name: check
|
|
191
|
+
runs-on: ${on}
|
|
192
|
+
steps:
|
|
193
|
+
${prologue()}
|
|
194
|
+
${renderSteps([{ run: 'bun run check' }], 3)}
|
|
195
|
+
|
|
196
|
+
${WORKFLOWS_NOTE}
|
|
197
|
+
workflows:
|
|
198
|
+
name: workflow lint
|
|
199
|
+
runs-on: ${on}
|
|
200
|
+
steps:
|
|
201
|
+
${renderSteps([{ uses: CHECKOUT }], 3)}
|
|
202
|
+
${ACTIONLINT_NOTE}
|
|
203
|
+
${renderSteps([{ name: 'Install actionlint (checksum-verified)', run: INSTALL_ACTIONLINT }], 3)}
|
|
204
|
+
${renderSteps([{ name: 'Lint workflows', run: './actionlint -color' }], 3)}
|
|
205
|
+
|
|
206
|
+
${extras.map((job) => `${renderExtraJob(job, on)}\n`).join('')}${AGGREGATE_NOTE}
|
|
207
|
+
ci:
|
|
208
|
+
name: ci
|
|
209
|
+
if: always()
|
|
210
|
+
needs: [${needs.join(', ')}]
|
|
211
|
+
runs-on: ${on}
|
|
212
|
+
steps:
|
|
213
|
+
${renderSteps([{ name: 'Verify every job succeeded', run: VERIFY }], 3)}
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three smaller rendered files: actionlint's config, the changeset config, and
|
|
3
|
+
* Dependabot.
|
|
4
|
+
*
|
|
5
|
+
* ★ EACH ONE WAS MEASURED BEFORE IT WAS RENDERED (2026-09-22, across 13 repositories):
|
|
6
|
+
* · `.changeset/config.json` — byte-identical apart from the repository name in 11 of
|
|
7
|
+
* 13. The three that differed did so by accident: two pinned an older `$schema`
|
|
8
|
+
* (3.1.4 against 4.0.1) and one used `@changesets/cli/changelog` instead of the
|
|
9
|
+
* GitHub changelog everyone else had, which silently drops PR links from releases.
|
|
10
|
+
* · `.github/actionlint.yaml` — 12 of 13 had it, with three wordings of one comment.
|
|
11
|
+
* The one without it is the one repository still on `ubuntu-latest`, which is the
|
|
12
|
+
* only case where it is genuinely not needed. That is an input, not an exception.
|
|
13
|
+
* · `.github/dependabot.yml` — 1 of 14. Twelve repositories take no dependency or
|
|
14
|
+
* Action updates at all, and nothing said so. Rendering it is the fix.
|
|
15
|
+
*/
|
|
16
|
+
import type { RepoShape } from './shape.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ⚠️ PINNED, NOT `latest`. `unpkg.com/@changesets/config@latest/schema.json` would change
|
|
20
|
+
* what editors validate against without a commit, and the two repositories that drifted
|
|
21
|
+
* here drifted by being pinned to different versions — not by being pinned.
|
|
22
|
+
*/
|
|
23
|
+
const CHANGESET_SCHEMA = '4.0.1';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `.changeset/config.json`.
|
|
27
|
+
*
|
|
28
|
+
* ⛔ `privatePackages` IS WRITTEN ONLY FOR A REPOSITORY THAT DOES NOT PUBLISH, AND BOTH
|
|
29
|
+
* OF ITS FIELDS ARE REQUIRED. `@changesets/cli` silently versions nothing when
|
|
30
|
+
* `version` is absent, so a private repository without this key opens a Version
|
|
31
|
+
* Packages PR that changes no version and tags no release — a release pipeline that
|
|
32
|
+
* reports success and ships nothing.
|
|
33
|
+
*/
|
|
34
|
+
export function renderChangesetConfig(shape: RepoShape): string {
|
|
35
|
+
const config = {
|
|
36
|
+
$schema: `https://unpkg.com/@changesets/config@${CHANGESET_SCHEMA}/schema.json`,
|
|
37
|
+
// ★ `@changesets/changelog-github` over the built-in: it writes the pull request and
|
|
38
|
+
// author into CHANGELOG.md, which is the difference between a changelog you can
|
|
39
|
+
// audit and a list of sentences.
|
|
40
|
+
changelog: ['@changesets/changelog-github', { repo: `${shape.owner}/${shape.repository}` }],
|
|
41
|
+
commit: false,
|
|
42
|
+
fixed: [] as string[],
|
|
43
|
+
linked: [] as string[],
|
|
44
|
+
access: shape.publishes ? 'public' : 'restricted',
|
|
45
|
+
baseBranch: 'main',
|
|
46
|
+
updateInternalDependencies: 'patch',
|
|
47
|
+
ignore: [] as string[],
|
|
48
|
+
...(shape.publishes ? {} : { privatePackages: { version: true, tag: false } }),
|
|
49
|
+
};
|
|
50
|
+
return `${JSON.stringify(config, undefined, 2)}\n`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `.github/actionlint.yaml` — rendered only for a self-hosted runner.
|
|
55
|
+
*
|
|
56
|
+
* ★ DECLARED, NOT SUPPRESSED. actionlint's `runner-label` rule stays on, so a typo'd
|
|
57
|
+
* label (`homeflare-mnii`) still fails the `workflow lint` job rather than silently
|
|
58
|
+
* queueing a job no runner ever claims. Measured 2026-09-22 with actionlint 1.7.12: an
|
|
59
|
+
* undeclared `homeflare-mini` is an error, not a warning, so this file is the reason
|
|
60
|
+
* twelve repositories are green rather than a nicety.
|
|
61
|
+
*/
|
|
62
|
+
export function renderActionlintConfig(shape: RepoShape): string | undefined {
|
|
63
|
+
if (shape.runner !== 'mini') return undefined;
|
|
64
|
+
return `# actionlint's list of self-hosted runner labels (its \`runner-label\` check).
|
|
65
|
+
#
|
|
66
|
+
# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
|
|
67
|
+
# Refresh with \`bun run repo-shape:refresh\`.
|
|
68
|
+
#
|
|
69
|
+
# ★ DECLARED, NOT SUPPRESSED: the rule stays on, so a typo'd label (\`homeflare-mnii\`)
|
|
70
|
+
# still fails the \`workflow lint\` job rather than queueing a job no runner claims.
|
|
71
|
+
# \`homeflare-mini\` is the interim runner on the Mac mini (homeflare-mini's
|
|
72
|
+
# docs/ci-runner.md); setting this repository's shape to \`runner: 'github'\` removes
|
|
73
|
+
# this file with the \`runs-on\` that needed it.
|
|
74
|
+
self-hosted-runner:
|
|
75
|
+
labels:
|
|
76
|
+
- homeflare-mini
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `.github/dependabot.yml`. */
|
|
81
|
+
export function renderDependabot(shape: RepoShape): string {
|
|
82
|
+
// ⚠️ `directories`, not `directory`, for bun: a workspace keeps a dependency in the
|
|
83
|
+
// package that declares it, so pointing only at `/` leaves every `packages/*`
|
|
84
|
+
// manifest unwatched — and the symptom is silence, not an error.
|
|
85
|
+
const bunDirs = shape.publishes ? ['/', '/packages/*'] : ['/'];
|
|
86
|
+
return `# Dependabot for ${shape.repository}.
|
|
87
|
+
#
|
|
88
|
+
# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
|
|
89
|
+
# Refresh with \`bun run repo-shape:refresh\`.
|
|
90
|
+
#
|
|
91
|
+
# ⛔ THIS FILE LIVES AT .github/dependabot.yml, NOT IN .github/workflows/. Dependabot is a
|
|
92
|
+
# platform feature, not an Action — a config placed among the workflows is silently
|
|
93
|
+
# ignored, and the symptom is simply that no pull requests ever arrive. Measured
|
|
94
|
+
# 2026-09-22: 13 of 14 HomeFlare repositories had no dependabot config at all, so their
|
|
95
|
+
# Actions and their toolchain went stale invisibly, which is exactly how nothing fails.
|
|
96
|
+
#
|
|
97
|
+
# ★ WHY GROUPED RATHER THAN ONE PR PER DEPENDENCY. The default opens a pull request per
|
|
98
|
+
# outdated package; that is a trickle nobody reviews properly. Each group below is a set
|
|
99
|
+
# that is either safe to take together or needs deciding together.
|
|
100
|
+
version: 2
|
|
101
|
+
|
|
102
|
+
updates:
|
|
103
|
+
# ── The toolchain (bun.lock) ────────────────────────────────────────────────
|
|
104
|
+
- package-ecosystem: bun
|
|
105
|
+
directories:
|
|
106
|
+
${bunDirs.map((dir) => ` - ${dir}`).join('\n')}
|
|
107
|
+
schedule:
|
|
108
|
+
interval: weekly
|
|
109
|
+
day: monday
|
|
110
|
+
time: '09:00'
|
|
111
|
+
timezone: America/New_York
|
|
112
|
+
open-pull-requests-limit: 5
|
|
113
|
+
commit-message:
|
|
114
|
+
prefix: 'chore'
|
|
115
|
+
include: scope
|
|
116
|
+
labels: [dependencies]
|
|
117
|
+
groups:
|
|
118
|
+
# oxfmt and oxlint move together and only affect style. Minor and patch bumps are
|
|
119
|
+
# noise unless they fail CI, which is what CI is for.
|
|
120
|
+
lint-and-format:
|
|
121
|
+
patterns: ['oxfmt', 'oxlint']
|
|
122
|
+
update-types: [minor, patch]
|
|
123
|
+
|
|
124
|
+
# ⚠️ MAJORS EXCLUDED DELIBERATELY. TypeScript majors change what typechecks;
|
|
125
|
+
# changesets majors have renamed inputs and dropped compatibility (the action's v2
|
|
126
|
+
# did both). These want reading, not merging on green.
|
|
127
|
+
build-tooling:
|
|
128
|
+
patterns: ['typescript', '@changesets/*', '@types/bun']
|
|
129
|
+
update-types: [minor, patch]
|
|
130
|
+
|
|
131
|
+
# ── The workflows themselves ────────────────────────────────────────────────
|
|
132
|
+
# ★ Actions go stale invisibly: nothing fails, they just keep running old code. The
|
|
133
|
+
# estate's first workflows pinned checkout@v5 (current: v7) and changesets/action@v1
|
|
134
|
+
# (current: v2, with every input renamed) — measured 2026-09-15.
|
|
135
|
+
- package-ecosystem: github-actions
|
|
136
|
+
directory: /
|
|
137
|
+
schedule:
|
|
138
|
+
interval: weekly
|
|
139
|
+
day: monday
|
|
140
|
+
time: '09:00'
|
|
141
|
+
timezone: America/New_York
|
|
142
|
+
open-pull-requests-limit: 5
|
|
143
|
+
commit-message:
|
|
144
|
+
prefix: 'ci'
|
|
145
|
+
labels: [dependencies, github-actions]
|
|
146
|
+
groups:
|
|
147
|
+
actions:
|
|
148
|
+
patterns: ['*']
|
|
149
|
+
update-types: [minor, patch]
|
|
150
|
+
`;
|
|
151
|
+
}
|