@homeflare/config 0.9.0 → 0.11.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 (59) hide show
  1. package/README.md +5 -40
  2. package/bin/hooks.ts +8 -4
  3. package/dist/hooks/activate.d.ts +7 -0
  4. package/dist/hooks/activate.d.ts.map +1 -0
  5. package/dist/hooks/gates.d.ts +7 -14
  6. package/dist/hooks/gates.d.ts.map +1 -1
  7. package/dist/hooks/install.d.ts +7 -1
  8. package/dist/hooks/install.d.ts.map +1 -1
  9. package/dist/hooks/push-plan.d.ts +59 -0
  10. package/dist/hooks/push-plan.d.ts.map +1 -0
  11. package/dist/hooks/push-range.d.ts +51 -0
  12. package/dist/hooks/push-range.d.ts.map +1 -0
  13. package/dist/hooks/report.d.ts +29 -2
  14. package/dist/hooks/report.d.ts.map +1 -1
  15. package/dist/hooks/secrets.d.ts +2 -0
  16. package/dist/hooks/secrets.d.ts.map +1 -0
  17. package/dist/hooks.d.ts +30 -6
  18. package/dist/hooks.d.ts.map +1 -1
  19. package/dist/hooks.js +339 -31
  20. package/dist/hooks.js.map +11 -7
  21. package/dist/repo-shape/automerge.d.ts +17 -0
  22. package/dist/repo-shape/automerge.d.ts.map +1 -0
  23. package/dist/repo-shape/ci.d.ts.map +1 -1
  24. package/dist/repo-shape/companions.d.ts +2 -6
  25. package/dist/repo-shape/companions.d.ts.map +1 -1
  26. package/dist/repo-shape/dependabot.d.ts +35 -0
  27. package/dist/repo-shape/dependabot.d.ts.map +1 -0
  28. package/dist/repo-shape/guards.d.ts +25 -0
  29. package/dist/repo-shape/guards.d.ts.map +1 -0
  30. package/dist/repo-shape/render.d.ts.map +1 -1
  31. package/dist/repo-shape/shape.d.ts +40 -2
  32. package/dist/repo-shape/shape.d.ts.map +1 -1
  33. package/dist/repo-shape.d.ts +5 -2
  34. package/dist/repo-shape.d.ts.map +1 -1
  35. package/dist/repo-shape.js +269 -105
  36. package/dist/repo-shape.js.map +10 -7
  37. package/dist/versions.js +68 -31
  38. package/dist/versions.js.map +6 -5
  39. package/docs/hooks.md +99 -0
  40. package/docs/repo-shape-dependabot.md +126 -0
  41. package/docs/repo-shape-inputs.md +83 -0
  42. package/docs/repo-shape.md +5 -23
  43. package/package.json +1 -1
  44. package/src/hooks/activate.ts +71 -0
  45. package/src/hooks/gates.ts +87 -28
  46. package/src/hooks/install.ts +39 -20
  47. package/src/hooks/push-plan.ts +167 -0
  48. package/src/hooks/push-range.ts +177 -0
  49. package/src/hooks/report.ts +31 -6
  50. package/src/hooks/secrets.ts +42 -0
  51. package/src/hooks.ts +30 -14
  52. package/src/repo-shape/automerge.ts +144 -0
  53. package/src/repo-shape/ci.ts +35 -16
  54. package/src/repo-shape/companions.ts +2 -77
  55. package/src/repo-shape/dependabot.ts +136 -0
  56. package/src/repo-shape/guards.ts +68 -0
  57. package/src/repo-shape/render.ts +5 -1
  58. package/src/repo-shape/shape.ts +51 -35
  59. package/src/repo-shape.ts +8 -4
@@ -0,0 +1,177 @@
1
+ /**
2
+ * What a push changes: the base it is measured from, and the files between that base and
3
+ * the commit being pushed.
4
+ *
5
+ * ★ THE BASE COMES FROM GIT, NOT FROM A GUESS. A pre-push hook reads
6
+ * `<local ref> <local sha> <remote ref> <remote sha>` on stdin. The remote sha is exactly
7
+ * "what the remote has now", so a second push to a branch checks only what is new.
8
+ * 🔴 A BRANCH-NAME BASE IS VACUOUS ON THE BASE BRANCH. Measured in the house monorepo
9
+ * 2026-09-08: `turbo --affected` compares against `main`, so on `main` itself it selected
10
+ * zero packages and a real two-commit push ran zero tests. Taking the base from stdin
11
+ * cannot do that — the remote sha is never the commit being pushed.
12
+ * ⛔ AN UNKNOWN BASE WIDENS, IT NEVER NARROWS TO NOTHING. No merge base (a shallow clone, a
13
+ * rewritten history), no remote-tracking branch: every lane runs, tests in full. A gate
14
+ * whose base is unusable must do MORE work, never quietly run nothing.
15
+ */
16
+ import { probe } from './report.ts';
17
+
18
+ /** One line of git's pre-push stdin. */
19
+ export type PushRef = {
20
+ readonly localRef: string;
21
+ readonly localSha: string;
22
+ readonly remoteRef: string;
23
+ readonly remoteSha: string;
24
+ };
25
+
26
+ export type PushScope =
27
+ /** The push changes no file (a deletion, or a branch at its base): nothing to check. */
28
+ | { readonly kind: 'empty'; readonly why: string }
29
+ /** Measured from `base`: `changed` is every path that differs between it and the tip. */
30
+ | {
31
+ readonly kind: 'scoped';
32
+ readonly base: string;
33
+ readonly changed: readonly string[];
34
+ readonly why: string;
35
+ }
36
+ /** No usable base: run every lane, tests in full. */
37
+ | { readonly kind: 'unscoped'; readonly why: string }
38
+ /** The pushed commit is not what is checked out, so the working tree cannot vouch for it. */
39
+ | { readonly kind: 'elsewhere'; readonly why: string };
40
+
41
+ /** git's "no such ref" sentinel — 40 zeros (64 under SHA-256). */
42
+ const ZERO = /^0+$/;
43
+
44
+ export function parsePushRefs(stdin: string): readonly PushRef[] {
45
+ const refs: PushRef[] = [];
46
+ for (const line of stdin.split('\n')) {
47
+ const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
48
+ if (localRef && localSha && remoteRef && remoteSha) {
49
+ refs.push({ localRef, localSha, remoteRef, remoteSha });
50
+ }
51
+ }
52
+ return refs;
53
+ }
54
+
55
+ const short = (sha: string): string => sha.slice(0, 9);
56
+
57
+ async function ok(root: string, args: readonly string[]): Promise<boolean> {
58
+ return (await probe(['git', '-C', root, ...args])).code === 0;
59
+ }
60
+
61
+ async function out(root: string, args: readonly string[]): Promise<string | undefined> {
62
+ const result = await probe(['git', '-C', root, ...args]);
63
+ return result.code === 0 ? result.stdout.trim() : undefined;
64
+ }
65
+
66
+ /**
67
+ * The remote's default branch as a local ref: `<remote>/HEAD` when the clone recorded it,
68
+ * else `<remote>/main`, else `<remote>/master`.
69
+ * ⚠️ `refs/remotes/<remote>/HEAD` IS OFTEN ABSENT — `git clone` sets it, `git init` plus
70
+ * `git remote add` does not — so the fallbacks are the common path, not the rare one.
71
+ */
72
+ async function defaultBranch(root: string, remote: string): Promise<string | undefined> {
73
+ const head = await out(root, ['symbolic-ref', '--quiet', `refs/remotes/${remote}/HEAD`]);
74
+ if (head !== undefined && head !== '') return head;
75
+ for (const name of ['main', 'master']) {
76
+ const ref = `refs/remotes/${remote}/${name}`;
77
+ if (await ok(root, ['rev-parse', '--verify', '--quiet', ref])) return ref;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Where the pushed commit is measured from.
84
+ *
85
+ * ★ A FAST-FORWARD IS MEASURED FROM THE REMOTE SHA — only what this push adds.
86
+ * ⚠️ ANYTHING ELSE IS MEASURED FROM THE MERGE BASE WITH THE DEFAULT BRANCH: a new branch
87
+ * (zero remote sha), a remote sha this clone has not fetched, or a force-push after a
88
+ * rebase — where diffing against the old tip would drag in everything `main` gained since.
89
+ */
90
+ async function baseFor(
91
+ root: string,
92
+ remote: string,
93
+ ref: PushRef,
94
+ ): Promise<{ base: string; why: string } | { why: string }> {
95
+ const { localSha, remoteSha } = ref;
96
+ if (
97
+ !ZERO.test(remoteSha) &&
98
+ (await ok(root, ['cat-file', '-e', `${remoteSha}^{commit}`])) &&
99
+ (await ok(root, ['merge-base', '--is-ancestor', remoteSha, localSha]))
100
+ ) {
101
+ return { base: remoteSha, why: `since ${short(remoteSha)}, what ${remote} has now` };
102
+ }
103
+ const branch = await defaultBranch(root, remote);
104
+ if (branch === undefined) return { why: `no ${remote}/main or ${remote}/master to measure from` };
105
+ const mergeBase = await out(root, ['merge-base', localSha, branch]);
106
+ if (mergeBase === undefined || mergeBase === '') {
107
+ return { why: `no merge base with ${branch} (a shallow clone, or unrelated history)` };
108
+ }
109
+ const name = branch.replace(/^refs\/remotes\//, '');
110
+ return { base: mergeBase, why: `since ${short(mergeBase)}, where this branch left ${name}` };
111
+ }
112
+
113
+ /**
114
+ * Resolve the push git described on stdin. With no stdin (a manual run) the push is
115
+ * `HEAD` to a new branch — measured from the merge base with the default branch.
116
+ * 🔴 THE LANES RUN ON THE WORKING TREE, SO ONLY A PUSH OF `HEAD` CAN BE CHECKED HERE. Found
117
+ * in review 2026-09-23 and reproduced: `git push origin broken` from a clean `main`
118
+ * measured the right files, then ran `bun test --changed` against `main`'s tree, found
119
+ * nothing, and printed "passed". A ref that is not checked out now comes back
120
+ * `elsewhere`, which the caller reports as NOT CHECKED — never as a pass. (The old
121
+ * whole-`check` hook had the same blind spot; it just ran unrelated tests while in it.)
122
+ * ⚠️ WITH SEVERAL REFS, THE ONE AT `HEAD` IS MEASURED and the rest are named as unchecked.
123
+ */
124
+ export async function pushScope(
125
+ root: string,
126
+ remote: string,
127
+ refs: readonly PushRef[],
128
+ ): Promise<PushScope> {
129
+ const pushed = refs.filter((ref) => !ZERO.test(ref.localSha));
130
+ if (refs.length > 0 && pushed.length === 0) {
131
+ return { kind: 'empty', why: 'this push only deletes refs' };
132
+ }
133
+ const head = await out(root, ['rev-parse', 'HEAD']);
134
+ const atHead = pushed.find((ref) => ref.localSha === head);
135
+ const others = pushed.filter((ref) => ref !== atHead).map((ref) => ref.localRef);
136
+ if (pushed.length > 0 && atHead === undefined) {
137
+ return {
138
+ kind: 'elsewhere',
139
+ why: `pushing ${others.join(', ')}, but the checkout is at ${short(head ?? '?')}`,
140
+ };
141
+ }
142
+ const ref = atHead ?? {
143
+ localRef: 'HEAD',
144
+ localSha: head ?? 'HEAD',
145
+ remoteRef: '',
146
+ remoteSha: '0'.repeat(40),
147
+ };
148
+ const found = await baseFor(root, remote, ref);
149
+ const also = others.length > 0 ? `; ${others.join(', ')} not checked here` : '';
150
+ if (!('base' in found)) return { kind: 'unscoped', why: `${found.why}${also}` };
151
+
152
+ const diff = await probe([
153
+ 'git',
154
+ '-C',
155
+ root,
156
+ 'diff',
157
+ '--name-only',
158
+ '-z',
159
+ found.base,
160
+ ref.localSha,
161
+ ]);
162
+ if (diff.code !== 0) return { kind: 'unscoped', why: `git diff ${short(found.base)} failed` };
163
+ const changed = diff.stdout.split('\0').filter((path) => path !== '');
164
+ if (changed.length === 0) return { kind: 'empty', why: `no file differs ${found.why}${also}` };
165
+ return { kind: 'scoped', base: found.base, changed, why: `${found.why}${also}` };
166
+ }
167
+
168
+ /**
169
+ * A path that changes what EVERY test runs on: a manifest, a lockfile, Bun's config, a
170
+ * tsconfig. Bun's `--changed` follows imports and none of these is imported — measured
171
+ * 2026-09-23, editing package.json selected 0 of 246 test files — so a push touching one
172
+ * runs the tests in full rather than none of them.
173
+ */
174
+ export function changesEverything(path: string): boolean {
175
+ const name = path.split('/').pop() ?? '';
176
+ return /^(package\.json|bun\.lockb?|bunfig\.toml|tsconfig.*\.json)$/.test(name);
177
+ }
@@ -28,7 +28,7 @@ const BYPASS: Record<Hook, string> = {
28
28
  * ⛔ So the hook strips them rather than trusting fourteen test suites to remember. The
29
29
  * gate must see the repository through `cwd`, the way CI does.
30
30
  */
31
- function withoutGitEnv(): Record<string, string | undefined> {
31
+ export function withoutGitEnv(): Record<string, string | undefined> {
32
32
  return Object.fromEntries(
33
33
  Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
34
34
  );
@@ -49,10 +49,34 @@ export async function run(cmd: readonly string[], isolated = false): Promise<num
49
49
 
50
50
  /** Capture a command's stdout. Used for git plumbing only. */
51
51
  export async function capture(cmd: readonly string[]): Promise<string> {
52
+ return (await probe(cmd)).stdout;
53
+ }
54
+
55
+ /** Capture a command's stdout AND its exit code — git plumbing that answers by status. */
56
+ export async function probe(cmd: readonly string[]): Promise<{ code: number; stdout: string }> {
52
57
  const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'ignore' });
53
- const text = await new Response(proc.stdout).text();
54
- await proc.exited;
55
- return text;
58
+ const stdout = await new Response(proc.stdout).text();
59
+ return { code: await proc.exited, stdout };
60
+ }
61
+
62
+ /**
63
+ * Run one pre-push lane through `sh -c` at `root`, as `bun run` would run a script line.
64
+ *
65
+ * ⚠️ `node_modules/.bin` FIRST ON PATH, because a lane opened up from a script is no longer
66
+ * run by `bun run`, which is what used to put it there — `vitest` or `oxfmt` in an expanded
67
+ * script would otherwise be "command not found" on a machine without a global copy.
68
+ * 🔴 AND WITHOUT THE HOOK'S `GIT_*` — see `withoutGitEnv`.
69
+ */
70
+ export async function runLane(command: string, root: string): Promise<number> {
71
+ const env = withoutGitEnv();
72
+ env['PATH'] = `${root}/node_modules/.bin:${env['PATH'] ?? ''}`;
73
+ const proc = Bun.spawn(['sh', '-c', command], {
74
+ cwd: root,
75
+ env,
76
+ stdout: 'inherit',
77
+ stderr: 'inherit',
78
+ });
79
+ return await proc.exited;
56
80
  }
57
81
 
58
82
  /**
@@ -60,8 +84,9 @@ export async function capture(cmd: readonly string[]): Promise<string> {
60
84
  *
61
85
  * ⚠️ NOT `bunx` BY DEFAULT. On a cache miss `bunx` downloads from the registry, and a
62
86
  * git hook that reaches the network mid-commit is a hang waiting for a flaky link.
63
- * husky puts `node_modules/.bin` on PATH, but this runs outside husky in tests, so
64
- * the local binary is named outright when it exists and `bunx` is only the fallback.
87
+ * Git runs the hook with the caller's PATH, which has no `node_modules/.bin` (husky
88
+ * used to add it; the hooks no longer run through husky), so the local binary is named
89
+ * outright when it exists and `bunx` is only the fallback.
65
90
  */
66
91
  export function tool(root: string, name: string): readonly string[] {
67
92
  const local = `${root}/node_modules/.bin/${name}`;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Refuse to commit anything that looks like a credential.
3
+ *
4
+ * ⛔ FIRST GATE, ALWAYS. A secret that reaches a public repository is compromised the moment
5
+ * it is pushed — rotating it is the only remedy, and rewriting history does not help
6
+ * because the object is already fetched and mirrored. Every other check can be fixed
7
+ * after the fact; this one cannot. Moved here from homeflare-kit's own
8
+ * scripts/hooks/secrets.ts (2026-09-23) so every repository commits under it, not one.
9
+ * ★ gitleaks IS THE TOOL, not a hand-rolled regex list. It ships hundreds of maintained rules
10
+ * and an entropy engine, and it reads the repository's own `.gitleaks.toml` when there is
11
+ * one; a homegrown pattern set covers the token shapes its author thought of.
12
+ * ⚠️ IT IS A GO BINARY, NOT AN npm PACKAGE — nothing in package.json can install it, so a
13
+ * missing binary is explained rather than surfacing as "command not found".
14
+ * ⚠️ `git --staged`, NOT `protect`. Measured 2026-09-15 on gitleaks 8.30.1: `protect` still
15
+ * runs, but the documented surface is `gitleaks git` / `dir` / `stdin`.
16
+ */
17
+ import { fail, ok, run } from './report.ts';
18
+
19
+ const INSTALL = 'brew install gitleaks — Linux: https://github.com/gitleaks/gitleaks/releases';
20
+
21
+ export async function scanStagedSecrets(): Promise<void> {
22
+ // ⛔ NOT A SILENT SKIP. A secret scan that quietly does nothing is worse than none: it
23
+ // reads, in a log and in a reviewer's head, as coverage that does not exist.
24
+ if (Bun.which('gitleaks') === null) {
25
+ fail(
26
+ 'pre-commit',
27
+ 'gitleaks is not installed, so the staged changes were NOT scanned',
28
+ INSTALL,
29
+ );
30
+ }
31
+ // `--redact`, so a real finding never prints the secret into a scrollback, a CI log, or an
32
+ // agent's context.
33
+ const code = await run(['gitleaks', 'git', '--staged', '--redact', '--no-banner', '.']);
34
+ if (code !== 0) {
35
+ fail(
36
+ 'pre-commit',
37
+ 'gitleaks found a secret in the staged changes',
38
+ 'remove it, then ROTATE it — assume anything committed is already compromised',
39
+ );
40
+ }
41
+ ok('pre-commit: gitleaks found no secret in the staged changes');
42
+ }
package/src/hooks.ts CHANGED
@@ -6,43 +6,59 @@
6
6
  * notices, and two repos disagree about what a commit must satisfy. Here the repo
7
7
  * commits a delegating wrapper and the behaviour ships with the package, so changing
8
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".
9
+ * ★ TRACKED `.husky/`, RUN BY GIT THROUGH `core.hooksPath` — NOT husky's `.husky/_`. Since
10
+ * 2026-09-23 the mechanism is the one that reaches every worktree; activate.ts has the
11
+ * measurement. The files keep the directory name the estate already uses.
12
+ * ★ PRE-PUSH IS SCOPED TO THE PUSH (push-range.ts, push-plan.ts): the repository's own
13
+ * `check`, with `bun test` narrowed to the tests the pushed files can reach and the build
14
+ * and smoke test left to CI. Many agents push to many repositories at once; a pre-push
15
+ * that re-ran every suite was the one people learned to skip.
14
16
  *
15
17
  * Usage from a hook file — see `HUSKY_HOOK`:
16
18
  *
17
19
  * bun node_modules/@homeflare/config/bin/hooks.ts pre-commit
18
20
  */
21
+ import { activateHooks } from './hooks/activate.ts';
19
22
  import { preCommit, prePush } from './hooks/gates.ts';
20
- import { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks } from './hooks/install.ts';
23
+ import { HOOK_NAMES, HUSKY_HOOK, PREPARE, installHooks, problemsInHooks } from './hooks/install.ts';
24
+ import { type Lane, planLanes } from './hooks/push-plan.ts';
21
25
  import { type Hook, note, ok } from './hooks/report.ts';
22
26
 
23
- export { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks };
24
- export type { Hook };
27
+ export { HOOK_NAMES, HUSKY_HOOK, PREPARE, activateHooks, installHooks, planLanes, problemsInHooks };
28
+ export type { Hook, Lane };
25
29
 
26
30
  /** The commands `bin/hooks.ts` accepts. */
27
- export type Command = Hook | 'install';
31
+ export type Command = Hook | 'install' | 'activate';
28
32
 
29
33
  export function isCommand(value: string): value is Command {
30
- return value === 'pre-commit' || value === 'pre-push' || value === 'install';
34
+ return ['pre-commit', 'pre-push', 'install', 'activate'].includes(value);
31
35
  }
32
36
 
33
37
  /**
34
- * Run one hook, or install the wrappers.
38
+ * Run one hook, write the wrappers, or activate them.
35
39
  *
40
+ * `args` are git's own hook arguments (for `pre-push`: the remote name and URL) and
41
+ * `stdin` is what git wrote to the hook (for `pre-push`: the refs being pushed).
36
42
  * ⛔ Never exits non-zero for a reason the caller cannot act on: an unknown command is a
37
43
  * programming error in the wrapper and is reported as such, not as a failed commit.
38
44
  */
39
- export async function runCommand(command: Command, root: string): Promise<void> {
45
+ export async function runCommand(
46
+ command: Command,
47
+ root: string,
48
+ args: readonly string[] = [],
49
+ stdin = '',
50
+ ): Promise<void> {
40
51
  if (command === 'install') {
41
52
  const written = await installHooks(root);
42
53
  ok(`wrote ${written.join(', ')} — commit them`);
43
- note('they do nothing until `bun install` runs `prepare` (husky)');
54
+ note(`then set "prepare": "${PREPARE}" so every install activates them`);
55
+ return;
56
+ }
57
+ if (command === 'activate') {
58
+ const result = await activateHooks(root);
59
+ (result.active ? ok : note)(`homeflare hooks: ${result.message}`);
44
60
  return;
45
61
  }
46
62
  if (command === 'pre-commit') return await preCommit(root);
47
- return await prePush(root);
63
+ return await prePush(root, args, stdin);
48
64
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `.github/workflows/dependabot-automerge.yml`, rendered.
3
+ *
4
+ * ★ THE OTHER HALF OF THE `homeflare` GROUP. Dependabot opens the bump; this arms GitHub's
5
+ * own auto-merge on it; the branch ruleset's required checks (`ci`, `secret scan`)
6
+ * decide whether it ever merges. Nothing here merges anything itself, and a merge
7
+ * deploys nothing — every stack in the estate is deployed by hand.
8
+ *
9
+ * ⛔ `gh pr merge --auto` IS NOT ALWAYS "ARM". When the pull request is already CLEAN,
10
+ * UNSTABLE or HAS_HOOKS it merges AT ONCE instead (cli/cli `pkg/cmd/pr/merge/merge.go`,
11
+ * `isImmediatelyMergeable`, read at v2.101.0 — the version the mini's job image ships).
12
+ * UNSTABLE is "mergeable, with a non-passing status", which is every pull request on a
13
+ * branch whose rules require no check: before `check` has run, and even after it failed.
14
+ * So the step reads the base branch's active rules first and refuses — a red check, never
15
+ * a merge — when none requires a status check. Measured 2026-09-22: homeflare-builds (its
16
+ * ruleset not deployed yet) and homeflare-desktop have none.
17
+ *
18
+ * ⛔ FIRST-PARTY ONLY: A `run:` STEP AND THE GitHub CLI, NO `uses:` AT ALL. GitHub's own
19
+ * example ("Automating Dependabot with GitHub Actions") identifies the update with
20
+ * `dependabot/fetch-metadata`, which the same page marks as "not certified by GitHub".
21
+ * The group is identified by the branch Dependabot gives it instead, whose format is
22
+ * dependabot-core's (`branch_namer/dependency_group_strategy.rb`, read at v0.397.0):
23
+ * `<prefix>/<package manager>/<directory>/<group>-<10 hex MD5 digest>`, with the root
24
+ * directory collapsing to nothing.
25
+ */
26
+ import { HOMEFLARE_GROUP } from './dependabot.ts';
27
+ import type { RepoShape } from './shape.ts';
28
+ import { runsOn } from './shape.ts';
29
+ import { renderSteps } from './yaml.ts';
30
+
31
+ /**
32
+ * The branch prefix a job-level `if:` can test with `startsWith`. GitHub's expression
33
+ * language has no regex, so this is the cheap filter that keeps every other pull request
34
+ * from taking a runner slot; `GROUP_BRANCH` is the exact test inside the step.
35
+ */
36
+ export const GROUP_BRANCH_PREFIX: string = `dependabot/bun/${HOMEFLARE_GROUP}-`;
37
+
38
+ /**
39
+ * ⛔ EXACT, AND FAILS CLOSED. A solo update of `@homeflare/config` is
40
+ * `dependabot/bun/homeflare/config-0.9.0` and a package named `homeflare-x` would be
41
+ * `dependabot/bun/homeflare-x-1.2.3`; neither matches. If Dependabot ever changes its
42
+ * format, the symptom is a bump that waits for a person — never one merged by mistake.
43
+ */
44
+ export const GROUP_BRANCH: string = `^${GROUP_BRANCH_PREFIX}[0-9a-f]{10}$`;
45
+
46
+ const HEADER = `# Arms auto-merge on Dependabot's @homeflare/* group, and on nothing else.
47
+ #
48
+ # 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
49
+ # Its input is this repository's \`repo-shape.ts\`; refresh with \`bun run repo-shape:refresh\`.
50
+ #
51
+ # ★ HOW A KIT RELEASE ARRIVES: the \`${HOMEFLARE_GROUP}\` group in .github/dependabot.yml opens one
52
+ # pull request; this arms GitHub's auto-merge on it; the ruleset's required checks decide.
53
+ # Nothing here merges anything itself, and merging deploys nothing.
54
+ # ⛔ NO THIRD-PARTY ACTION. GitHub's own example uses dependabot/fetch-metadata, which its
55
+ # docs mark "not certified by GitHub"; the group is recognised by its branch name instead.
56
+ # ⛔ NO REQUIRED CHECK ON THE BASE BRANCH, NO ARMING: there \`gh pr merge --auto\` would merge
57
+ # at once, unchecked. The job fails instead, so the pull request waits for a person.
58
+ # ⚠️ A RENDERER CHANGE STILL NEEDS A PERSON. When a kit release changes what @homeflare/config
59
+ # renders, the bump fails \`check\` (the drift test) and never goes green. Run
60
+ # \`bun run repo-shape:refresh\` on Dependabot's branch and push. ⛔ This workflow does not do
61
+ # it for you: a push made with GITHUB_TOKEN starts no workflow run, so a refreshed commit
62
+ # would never get the required checks and the pull request would wait forever.
63
+ `;
64
+
65
+ const TRIGGER = `name: dependabot auto-merge
66
+
67
+ on:
68
+ pull_request:
69
+
70
+ # ⛔ NOTHING AT THE TOP; the one job asks for exactly what \`gh pr merge --auto\` needs.
71
+ permissions: {}
72
+
73
+ concurrency:
74
+ group: automerge-\${{ github.ref }}
75
+ cancel-in-progress: true
76
+ `;
77
+
78
+ const JOB_NOTE = ` # ⛔ BOTH LOGINS, NOT EITHER. \`user.login\` is who opened the pull request; \`github.actor\` is
79
+ # who started this run. A person pushing to Dependabot's branch changes the actor, so
80
+ # their commit never arms anything; only Dependabot's own rebases do.`;
81
+
82
+ const PERMISSIONS_NOTE = ` # ★ WHAT GitHub'S OWN EXAMPLE GRANTS FOR THIS STEP, AND NO MORE. A run Dependabot starts gets
83
+ # a read-only GITHUB_TOKEN unless the workflow raises it ("Troubleshooting Dependabot on
84
+ # GitHub Actions" → "Changing GITHUB_TOKEN permissions").`;
85
+
86
+ const ARM = `set -euo pipefail
87
+ if [[ ! "$HEAD_REF" =~ ${GROUP_BRANCH} ]]; then
88
+ echo "::notice::$HEAD_REF is not the ${HOMEFLARE_GROUP} group's branch; auto-merge not armed"
89
+ exit 0
90
+ fi
91
+ # ⛔ gh merges a CLEAN or UNSTABLE pull request at once rather than arming it. Only a branch
92
+ # rule that requires a status check keeps it BLOCKED until the checks have passed.
93
+ required=$(gh api "repos/$GITHUB_REPOSITORY/rules/branches/$BASE_REF" \\
94
+ --jq '[.[] | select(.type == "required_status_checks")] | length')
95
+ if [ "$required" = 0 ]; then
96
+ echo "::error::$BASE_REF requires no status check, so gh would merge at once; auto-merge not armed"
97
+ exit 1
98
+ fi
99
+ # ★ --squash: the house repositories allow squash merges only (declareRepoPolicy).
100
+ gh pr merge --auto --squash "$PR_URL"`;
101
+
102
+ /** The whole `dependabot-automerge.yml` for a shape. */
103
+ export function renderAutomerge(shape: RepoShape): string {
104
+ // ⚠️ GitHub ACTIONS EXPRESSIONS, NOT TEMPLATE LITERALS: the runner interpolates `${{ … }}`
105
+ // at job time, so they must reach the file verbatim (see security.ts for the same note).
106
+ // oxlint-disable-next-line no-template-curly-in-string
107
+ const headRef = '${{ github.head_ref }}';
108
+ // oxlint-disable-next-line no-template-curly-in-string
109
+ const baseRef = '${{ github.base_ref }}';
110
+ // oxlint-disable-next-line no-template-curly-in-string
111
+ const prUrl = '${{ github.event.pull_request.html_url }}';
112
+ // oxlint-disable-next-line no-template-curly-in-string
113
+ const token = '${{ secrets.GITHUB_TOKEN }}';
114
+ const condition = [
115
+ "github.event.pull_request.user.login == 'dependabot[bot]'",
116
+ "github.actor == 'dependabot[bot]'",
117
+ `startsWith(github.head_ref, '${GROUP_BRANCH_PREFIX}')`,
118
+ ].join(' && ');
119
+
120
+ return `${HEADER}
121
+ ${TRIGGER}
122
+ jobs:
123
+ ${JOB_NOTE}
124
+ arm:
125
+ name: arm auto-merge
126
+ if: ${condition}
127
+ runs-on: ${runsOn(shape.runner)}
128
+ ${PERMISSIONS_NOTE}
129
+ permissions:
130
+ contents: write
131
+ pull-requests: write
132
+ steps:
133
+ ${renderSteps(
134
+ [
135
+ {
136
+ env: { BASE_REF: baseRef, GH_TOKEN: token, HEAD_REF: headRef, PR_URL: prUrl },
137
+ name: 'Arm auto-merge on the homeflare group',
138
+ run: ARM,
139
+ },
140
+ ],
141
+ 3,
142
+ )}
143
+ `;
144
+ }
@@ -10,8 +10,8 @@
10
10
  * `needs`, so adding a job never means editing a branch ruleset. `repoShapeChecks()`
11
11
  * returns exactly `['ci', 'secret scan']`, which is what `declareRepoPolicy` requires.
12
12
  */
13
- import type { ExtraJob, RepoShape } from './shape.ts';
14
- import { runsOn } from './shape.ts';
13
+ import type { ExtraJob, JobStep, RepoShape } from './shape.ts';
14
+ import { nodeMajor, runsOn } from './shape.ts';
15
15
  import { renderSteps } from './yaml.ts';
16
16
 
17
17
  /** Bun the whole estate is pinned to. One line, one place. */
@@ -21,6 +21,7 @@ export const ACTIONLINT_VERSION = '1.7.12';
21
21
 
22
22
  const CHECKOUT = 'actions/checkout@v7';
23
23
  const SETUP_BUN = 'oven-sh/setup-bun@v2';
24
+ const SETUP_NODE = 'actions/setup-node@v6';
24
25
 
25
26
  function runnerNote(shape: RepoShape): string {
26
27
  if (shape.runner !== 'mini') {
@@ -148,31 +149,49 @@ const VERIFY = `if [ "\${{ contains(needs.*.result, 'failure') }}" = "true" ] ||
148
149
  exit 1
149
150
  fi`;
150
151
 
151
- 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
- );
152
+ const NODE_NOTE = ` # ⛔ REAL NODE, NOT BUN'S SHIM, AND ONLY WHERE THE GATE NEEDS IT. The mini's job
153
+ # image has no node on PATH (ubuntu-latest always did), so a repository whose own
154
+ # \`check\` spawns \`node\` — or whose framework demands a Node runtime — fails with
155
+ # \`Executable not found in $PATH: "node"\` without this. Declared as \`node:\` in
156
+ # repo-shape.ts, so it is one input rather than a hand-edited block per repository.
157
+ # ⚠️ NO PACKAGE-MANAGER CACHE: bun does the installing, so priming npm's cache costs
158
+ # time and caches nothing anything here reads.`;
159
+
160
+ function prologue(shape: RepoShape): string {
161
+ const node = nodeMajor(shape);
162
+ const bun: JobStep[] = [
163
+ { uses: SETUP_BUN, with: { 'bun-version': BUN_VERSION } },
164
+ { run: 'bun install --frozen-lockfile' },
165
+ ];
166
+ if (node === undefined) return renderSteps([{ uses: CHECKOUT }, ...bun], 3);
167
+ return [
168
+ renderSteps([{ uses: CHECKOUT }], 3),
169
+ NODE_NOTE,
170
+ renderSteps(
171
+ [{ uses: SETUP_NODE, with: { 'node-version': node, 'package-manager-cache': 'false' } }],
172
+ 3,
173
+ ),
174
+ renderSteps(bun, 3),
175
+ ].join('\n');
160
176
  }
161
177
 
162
- function renderExtraJob(job: ExtraJob, on: string): string {
178
+ function renderExtraJob(job: ExtraJob, shape: RepoShape, on: string): string {
163
179
  const needs =
164
180
  (job.needs ?? []).length === 0 ? '' : ` needs: [${(job.needs ?? []).join(', ')}]\n`;
181
+ // ★ A TIMEOUT IS THE JOB'S, NOT THE SHAPE'S. Only a job that starts something with its
182
+ // own wait needs one, and it is rendered where a reader looks for it.
183
+ const timeout = job.timeout === undefined ? '' : ` timeout-minutes: ${job.timeout}\n`;
165
184
  const steps =
166
185
  job.bun === false
167
186
  ? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3)
168
- : [prologue(), renderSteps(job.steps, 3)].join('\n');
187
+ : [prologue(shape), renderSteps(job.steps, 3)].join('\n');
169
188
  // ★ The stated reason is rendered into the file. A job nobody can explain is a job
170
189
  // nobody dares delete, so the explanation travels with it.
171
190
  return ` # ★ NOT PART OF THE STANDARD SHAPE — ${job.reason}
172
191
  ${job.id}:
173
192
  name: ${job.name}
174
193
  ${needs} runs-on: ${on}
175
- steps:
194
+ ${timeout} steps:
176
195
  ${steps}
177
196
  `;
178
197
  }
@@ -190,7 +209,7 @@ ${CHECK_NOTE}
190
209
  name: check
191
210
  runs-on: ${on}
192
211
  steps:
193
- ${prologue()}
212
+ ${prologue(shape)}
194
213
  ${renderSteps([{ run: 'bun run check' }], 3)}
195
214
 
196
215
  ${WORKFLOWS_NOTE}
@@ -203,7 +222,7 @@ ${ACTIONLINT_NOTE}
203
222
  ${renderSteps([{ name: 'Install actionlint (checksum-verified)', run: INSTALL_ACTIONLINT }], 3)}
204
223
  ${renderSteps([{ name: 'Lint workflows', run: './actionlint -color' }], 3)}
205
224
 
206
- ${extras.map((job) => `${renderExtraJob(job, on)}\n`).join('')}${AGGREGATE_NOTE}
225
+ ${extras.map((job) => `${renderExtraJob(job, shape, on)}\n`).join('')}${AGGREGATE_NOTE}
207
226
  ci:
208
227
  name: ci
209
228
  if: always()