@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.
- package/README.md +5 -40
- package/bin/hooks.ts +8 -4
- package/dist/hooks/activate.d.ts +7 -0
- package/dist/hooks/activate.d.ts.map +1 -0
- package/dist/hooks/gates.d.ts +7 -14
- package/dist/hooks/gates.d.ts.map +1 -1
- package/dist/hooks/install.d.ts +7 -1
- package/dist/hooks/install.d.ts.map +1 -1
- package/dist/hooks/push-plan.d.ts +59 -0
- package/dist/hooks/push-plan.d.ts.map +1 -0
- package/dist/hooks/push-range.d.ts +51 -0
- package/dist/hooks/push-range.d.ts.map +1 -0
- package/dist/hooks/report.d.ts +29 -2
- package/dist/hooks/report.d.ts.map +1 -1
- package/dist/hooks/secrets.d.ts +2 -0
- package/dist/hooks/secrets.d.ts.map +1 -0
- package/dist/hooks.d.ts +30 -6
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +339 -31
- package/dist/hooks.js.map +11 -7
- package/dist/repo-shape/automerge.d.ts +17 -0
- package/dist/repo-shape/automerge.d.ts.map +1 -0
- package/dist/repo-shape/ci.d.ts.map +1 -1
- package/dist/repo-shape/companions.d.ts +2 -6
- package/dist/repo-shape/companions.d.ts.map +1 -1
- package/dist/repo-shape/dependabot.d.ts +35 -0
- package/dist/repo-shape/dependabot.d.ts.map +1 -0
- package/dist/repo-shape/guards.d.ts +25 -0
- package/dist/repo-shape/guards.d.ts.map +1 -0
- package/dist/repo-shape/render.d.ts.map +1 -1
- package/dist/repo-shape/shape.d.ts +40 -2
- package/dist/repo-shape/shape.d.ts.map +1 -1
- package/dist/repo-shape.d.ts +5 -2
- package/dist/repo-shape.d.ts.map +1 -1
- package/dist/repo-shape.js +269 -105
- package/dist/repo-shape.js.map +10 -7
- package/dist/versions.js +68 -31
- package/dist/versions.js.map +6 -5
- package/docs/hooks.md +99 -0
- package/docs/repo-shape-dependabot.md +126 -0
- package/docs/repo-shape-inputs.md +83 -0
- package/docs/repo-shape.md +5 -23
- package/package.json +1 -1
- package/src/hooks/activate.ts +71 -0
- package/src/hooks/gates.ts +87 -28
- package/src/hooks/install.ts +39 -20
- package/src/hooks/push-plan.ts +167 -0
- package/src/hooks/push-range.ts +177 -0
- package/src/hooks/report.ts +31 -6
- package/src/hooks/secrets.ts +42 -0
- package/src/hooks.ts +30 -14
- package/src/repo-shape/automerge.ts +144 -0
- package/src/repo-shape/ci.ts +35 -16
- package/src/repo-shape/companions.ts +2 -77
- package/src/repo-shape/dependabot.ts +136 -0
- package/src/repo-shape/guards.ts +68 -0
- package/src/repo-shape/render.ts +5 -1
- package/src/repo-shape/shape.ts +51 -35
- 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
|
+
}
|
package/src/hooks/report.ts
CHANGED
|
@@ -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
|
|
54
|
-
await proc.exited;
|
|
55
|
-
|
|
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
|
-
*
|
|
64
|
-
* the
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
34
|
+
return ['pre-commit', 'pre-push', 'install', 'activate'].includes(value);
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
* Run one hook,
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/repo-shape/ci.ts
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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()
|