@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,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drift check: re-render, compare, and say what to run.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHAT MAKES THIS DIFFERENT FROM A LINT RULE. It does not describe good workflows; it
|
|
5
|
+
* asserts that this repository's committed files ARE the rendered ones. A hand edit
|
|
6
|
+
* fails here before it can spread, which is the mechanism — not the convention — that
|
|
7
|
+
* keeps fourteen repositories the same.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ IT REPORTS, IT DOES NOT REPAIR, exactly like `checkProject` in this package. A check
|
|
10
|
+
* that silently rewrote the working tree would erase the evidence of what someone
|
|
11
|
+
* changed, and the one case worth catching — a deliberate edit that should have been an
|
|
12
|
+
* exception — is the case it would erase. `refresh.ts` is the separate, explicit writer.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ EVERY COMPARISON IS EXACT TEXT, NOT STRUCTURE. `checkProject` compares parsed values
|
|
15
|
+
* because a project may reformat its copy of a preset. Here the file IS generated, so
|
|
16
|
+
* any difference at all — including a reflowed comment — means the file did not come
|
|
17
|
+
* out of the renderer, and reformatting it would defeat the point of generating it.
|
|
18
|
+
* The one exception is line endings, normalised so a CRLF checkout is not "drift".
|
|
19
|
+
*/
|
|
20
|
+
import { type RenderedRepo, renderRepoShape } from './render.ts';
|
|
21
|
+
import type { RenderedPath, RepoShape, RepoShapeException } from './shape.ts';
|
|
22
|
+
|
|
23
|
+
/** One thing to fix, in the imperative — the same `Problem` shape `./check` reports. */
|
|
24
|
+
export type Problem = string;
|
|
25
|
+
|
|
26
|
+
/** The command that makes a difference go away. Printed with every drift problem. */
|
|
27
|
+
export const REFRESH_COMMAND = 'bun run repo-shape:refresh';
|
|
28
|
+
|
|
29
|
+
function normalize(text: string): string {
|
|
30
|
+
return text.replaceAll('\r\n', '\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readIfPresent(path: string): Promise<string | undefined> {
|
|
34
|
+
const file = Bun.file(path);
|
|
35
|
+
return (await file.exists()) ? normalize(await file.text()) : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ⛔ AN EXCEPTION FOR A FILE THE SHAPE DOES NOT RENDER IS ITSELF A PROBLEM. Otherwise a
|
|
40
|
+
* repository that moved from the mini to GitHub-hosted runners would keep a stale
|
|
41
|
+
* `.github/actionlint.yaml` exception forever, and the exception list — the one place
|
|
42
|
+
* that is supposed to hold every deviation — would be lying about one of them.
|
|
43
|
+
*/
|
|
44
|
+
function staleExceptions(
|
|
45
|
+
rendered: RenderedRepo,
|
|
46
|
+
exceptions: readonly RepoShapeException[],
|
|
47
|
+
): Problem[] {
|
|
48
|
+
return exceptions
|
|
49
|
+
.filter((exception) => rendered.files[exception.file] === undefined)
|
|
50
|
+
.map(
|
|
51
|
+
(exception) =>
|
|
52
|
+
`${exception.file}: excepted (since ${exception.since}) but this shape renders no such file — drop the exception`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ⚠️ A DUPLICATE EXCEPTION HIDES A REASON. Two `except()` entries for one file means one
|
|
58
|
+
* of the two reasons is never read, and the one that loses is arbitrary. Refuse both.
|
|
59
|
+
*/
|
|
60
|
+
function duplicateExceptions(exceptions: readonly RepoShapeException[]): Problem[] {
|
|
61
|
+
const seen = new Set<RenderedPath>();
|
|
62
|
+
const duplicated = new Set<RenderedPath>();
|
|
63
|
+
for (const exception of exceptions) {
|
|
64
|
+
if (seen.has(exception.file)) duplicated.add(exception.file);
|
|
65
|
+
seen.add(exception.file);
|
|
66
|
+
}
|
|
67
|
+
return [...duplicated].map(
|
|
68
|
+
(file) => `${file}: declared as an exception more than once — keep one, with one reason`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface DriftReport {
|
|
73
|
+
/** Empty when the committed files are the rendered ones. */
|
|
74
|
+
readonly problems: readonly Problem[];
|
|
75
|
+
/** Paths whose committed text differs from the render, excluding excepted files. */
|
|
76
|
+
readonly drifted: readonly string[];
|
|
77
|
+
/** Paths the repository declared it owns, with the reason it gave. */
|
|
78
|
+
readonly excepted: readonly RepoShapeException[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compare a repository's committed tooling files against its declared shape.
|
|
83
|
+
*
|
|
84
|
+
* const report = await driftInRepoShape(process.cwd(), shape);
|
|
85
|
+
* expect(report.problems).toEqual([]);
|
|
86
|
+
*
|
|
87
|
+
* A declared exception passes. An undeclared difference fails, and so does a missing file.
|
|
88
|
+
*/
|
|
89
|
+
export async function driftInRepoShape(projectDir: string, shape: RepoShape): Promise<DriftReport> {
|
|
90
|
+
const rendered = renderRepoShape(shape);
|
|
91
|
+
const exceptions = shape.exceptions ?? [];
|
|
92
|
+
const excepted = new Map(exceptions.map((exception) => [exception.file as string, exception]));
|
|
93
|
+
const problems: Problem[] = [
|
|
94
|
+
...duplicateExceptions(exceptions),
|
|
95
|
+
...staleExceptions(rendered, exceptions),
|
|
96
|
+
];
|
|
97
|
+
const drifted: string[] = [];
|
|
98
|
+
|
|
99
|
+
for (const [path, expected] of Object.entries(rendered.files)) {
|
|
100
|
+
if (excepted.has(path)) continue;
|
|
101
|
+
const actual = await readIfPresent(`${projectDir}/${path}`);
|
|
102
|
+
if (actual === undefined) {
|
|
103
|
+
problems.push(`${path}: missing — run \`${REFRESH_COMMAND}\``);
|
|
104
|
+
drifted.push(path);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (actual !== normalize(expected)) {
|
|
108
|
+
drifted.push(path);
|
|
109
|
+
problems.push(
|
|
110
|
+
`${path}: differs from what @homeflare/config renders for this shape. ` +
|
|
111
|
+
`Run \`${REFRESH_COMMAND}\` to take the standard, ` +
|
|
112
|
+
`or declare it with except({ file: '${path}', reason: '…', since: '…' }) in repo-shape.ts.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { drifted, excepted: exceptions, problems };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Every rendered path that is NOT compared, with why. For a report, not for a gate.
|
|
122
|
+
* ★ Printed by `refresh --check` so a passing run still names what it did not check.
|
|
123
|
+
* An exception that stops being visible is an exception that stops being reconsidered.
|
|
124
|
+
*/
|
|
125
|
+
export function exceptionSummary(shape: RepoShape): readonly string[] {
|
|
126
|
+
return (shape.exceptions ?? []).map(
|
|
127
|
+
(exception) =>
|
|
128
|
+
`${exception.file}: not checked — ${exception.reason} (since ${exception.since})`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The writer, and the CLI a repository wires to `repo-shape:refresh`.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ SEPARATE FROM THE CHECK, ON PURPOSE. `drift.ts` never writes and this never gates;
|
|
5
|
+
* a tool that did both would repair the working tree during CI and report green on a
|
|
6
|
+
* repository whose committed files are still wrong.
|
|
7
|
+
*
|
|
8
|
+
* ★ IT ONLY WRITES WHAT CHANGED. An unchanged file keeps its mtime, so a refresh in a
|
|
9
|
+
* watch-mode session does not restart anything, and `git status` after a no-op refresh
|
|
10
|
+
* is empty rather than "14 files touched".
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ IT NEVER WRITES AN EXCEPTED FILE. Refreshing a file the repository declared it owns
|
|
13
|
+
* would overwrite the deviation the reason was written for — the one destructive thing
|
|
14
|
+
* this could do, and the one it must not.
|
|
15
|
+
*/
|
|
16
|
+
import { REFRESH_COMMAND, driftInRepoShape, exceptionSummary } from './drift.ts';
|
|
17
|
+
import { renderRepoShape } from './render.ts';
|
|
18
|
+
import { type RepoShape, isExcepted } from './shape.ts';
|
|
19
|
+
import type { RenderedPath } from './shape.ts';
|
|
20
|
+
|
|
21
|
+
export interface RefreshResult {
|
|
22
|
+
/** Paths written because their content changed or they were absent. */
|
|
23
|
+
readonly written: readonly string[];
|
|
24
|
+
/** Paths already correct. */
|
|
25
|
+
readonly unchanged: readonly string[];
|
|
26
|
+
/** Paths skipped because the shape declares an exception for them. */
|
|
27
|
+
readonly skipped: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Write a repository's rendered files into `projectDir`. */
|
|
31
|
+
export async function refreshRepoShape(
|
|
32
|
+
projectDir: string,
|
|
33
|
+
shape: RepoShape,
|
|
34
|
+
): Promise<RefreshResult> {
|
|
35
|
+
const rendered = renderRepoShape(shape);
|
|
36
|
+
const written: string[] = [];
|
|
37
|
+
const unchanged: string[] = [];
|
|
38
|
+
const skipped: string[] = [];
|
|
39
|
+
|
|
40
|
+
for (const [path, contents] of Object.entries(rendered.files)) {
|
|
41
|
+
if (isExcepted(shape, path as RenderedPath)) {
|
|
42
|
+
skipped.push(path);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const target = `${projectDir}/${path}`;
|
|
46
|
+
const file = Bun.file(target);
|
|
47
|
+
if ((await file.exists()) && (await file.text()) === contents) {
|
|
48
|
+
unchanged.push(path);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
await Bun.write(target, contents);
|
|
52
|
+
written.push(path);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { skipped, unchanged, written };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ★ `Bun.write(Bun.stdout, …)` RATHER THAN `console.log`. Two reasons, and the lint rule
|
|
60
|
+
* is the lesser: a CLI's output is a stream it owns, and writing to it explicitly is
|
|
61
|
+
* what lets `repoShapeCli` be called in a test and in a `repo-shape.ts` without either
|
|
62
|
+
* one inheriting a global. `console` in `src/` is also off-limits in the house preset —
|
|
63
|
+
* allowed under `tests/**` and `scripts/**`, and this is neither.
|
|
64
|
+
*/
|
|
65
|
+
async function say(
|
|
66
|
+
stream: typeof Bun.stdout | typeof Bun.stderr,
|
|
67
|
+
lines: readonly string[],
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
if (lines.length === 0) return;
|
|
70
|
+
await Bun.write(stream, `${lines.join('\n')}\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `repo-shape:refresh` entry point. `--check` reports drift and exits non-zero
|
|
75
|
+
* instead of writing, which is what a repository puts in CI when it does not want the
|
|
76
|
+
* check inside `bun test`.
|
|
77
|
+
*/
|
|
78
|
+
export async function repoShapeCli(
|
|
79
|
+
projectDir: string,
|
|
80
|
+
shape: RepoShape,
|
|
81
|
+
argv: readonly string[],
|
|
82
|
+
): Promise<number> {
|
|
83
|
+
if (argv.includes('--check')) {
|
|
84
|
+
const report = await driftInRepoShape(projectDir, shape);
|
|
85
|
+
// ★ The exceptions are printed on a PASSING run too. An exception that stops being
|
|
86
|
+
// visible is an exception that stops being reconsidered.
|
|
87
|
+
await say(
|
|
88
|
+
Bun.stdout,
|
|
89
|
+
exceptionSummary(shape).map((note) => `· ${note}`),
|
|
90
|
+
);
|
|
91
|
+
if (report.problems.length === 0) {
|
|
92
|
+
const count = report.excepted.length;
|
|
93
|
+
await say(Bun.stdout, [
|
|
94
|
+
`repo shape: in step with @homeflare/config (${count} declared exception(s))`,
|
|
95
|
+
]);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
await say(Bun.stderr, [
|
|
99
|
+
...report.problems.map((problem) => `✗ ${problem}`),
|
|
100
|
+
'',
|
|
101
|
+
`Run \`${REFRESH_COMMAND}\`, or declare the deviation with a reason.`,
|
|
102
|
+
]);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const result = await refreshRepoShape(projectDir, shape);
|
|
107
|
+
await say(Bun.stdout, [
|
|
108
|
+
...result.written.map((path) => `wrote ${path}`),
|
|
109
|
+
...result.unchanged.map((path) => `ok ${path}`),
|
|
110
|
+
...result.skipped.map((path) => `excepted ${path}`),
|
|
111
|
+
]);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `renderRepoShape` — one declaration, both halves of a standard repository.
|
|
3
|
+
*
|
|
4
|
+
* ★ THIS IS THE POINT OF THE WHOLE MODULE. A repository declares its shape once and gets
|
|
5
|
+
* its FILES (here) and its GitHub SETTINGS (through `policy`, which is the options
|
|
6
|
+
* object `@homeflare/alchemy`'s `declareRepoPolicy` takes). Before this, the workflow
|
|
7
|
+
* that produces a status check and the ruleset that requires that status check were
|
|
8
|
+
* written in two files by hand, and nothing connected them: renaming the aggregate job
|
|
9
|
+
* would leave a required check that never reports, and a pull request would wait on it
|
|
10
|
+
* forever with auto-merge armed. `checks` below is derived from the rendered workflows,
|
|
11
|
+
* so the two cannot disagree.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ NO IMPORT CROSSES BETWEEN THE PACKAGES, IN EITHER DIRECTION. `policy` is a plain
|
|
14
|
+
* object that is structurally assignable to `RepoPolicyOptions`; `@homeflare/alchemy`
|
|
15
|
+
* does not depend on `@homeflare/config` and this does not depend on Alchemy or Effect.
|
|
16
|
+
* That matters because `@homeflare/config` is installed in 13 of 14 repositories and
|
|
17
|
+
* `@homeflare/alchemy` in 4 — the drift check has to run everywhere, and it must not
|
|
18
|
+
* drag a provider library into a repository that has no stack.
|
|
19
|
+
* ⚠️ A structural seam is checked only where both sides are present: the assignability
|
|
20
|
+
* test lives in `packages/alchemy/tests/repo-shape-policy.test.ts`, which is the one
|
|
21
|
+
* place that imports both.
|
|
22
|
+
*/
|
|
23
|
+
import { renderCi } from './ci.ts';
|
|
24
|
+
import { renderActionlintConfig, renderChangesetConfig, renderDependabot } from './companions.ts';
|
|
25
|
+
import { renderSecurity } from './security.ts';
|
|
26
|
+
import type { RenderedPath, RepoShape } from './shape.ts';
|
|
27
|
+
|
|
28
|
+
/** The status-check contexts a rendered repository reports. */
|
|
29
|
+
export interface RepoShapePolicy {
|
|
30
|
+
readonly owner: string;
|
|
31
|
+
readonly repository: string;
|
|
32
|
+
/**
|
|
33
|
+
* ⛔ EXACTLY THE AGGREGATES, NEVER THE LEAF JOBS. `ci` is `if: always()` and fails when
|
|
34
|
+
* any job it needs did not succeed, so requiring it requires all of them. Requiring a
|
|
35
|
+
* leaf job instead would mean a ruleset edit every time a job is added, and a context
|
|
36
|
+
* that stops reporting leaves every pull request pending rather than failing.
|
|
37
|
+
*/
|
|
38
|
+
readonly checks: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RenderedRepo {
|
|
42
|
+
/** Every rendered file, by its path relative to the repository root. */
|
|
43
|
+
readonly files: Readonly<Record<string, string>>;
|
|
44
|
+
/**
|
|
45
|
+
* The options `declareRepoPolicy(id, { ...rendered.policy, settings })` takes.
|
|
46
|
+
* Squash-only, auto-merge, delete-branch-on-merge and the `main` ruleset come from
|
|
47
|
+
* there; the check names come from here.
|
|
48
|
+
*/
|
|
49
|
+
readonly policy: RepoShapePolicy;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render a repository's tooling files and the policy that matches them.
|
|
54
|
+
*
|
|
55
|
+
* const rendered = renderRepoShape(shape);
|
|
56
|
+
* rendered.files['.github/workflows/ci.yml'] // the file
|
|
57
|
+
* rendered.policy.checks // ['ci', 'secret scan']
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ EXCEPTED FILES ARE STILL RENDERED. `files` is what the standard says this repository
|
|
60
|
+
* should have; `drift.ts` is what decides which of them are compared. Keeping them here
|
|
61
|
+
* means `--show` can print the standard version of an excepted file, which is how
|
|
62
|
+
* anyone judges whether the exception is still worth its reason.
|
|
63
|
+
*/
|
|
64
|
+
export function renderRepoShape(shape: RepoShape): RenderedRepo {
|
|
65
|
+
const files: Record<string, string> = {
|
|
66
|
+
'.changeset/config.json': renderChangesetConfig(shape),
|
|
67
|
+
'.github/dependabot.yml': renderDependabot(shape),
|
|
68
|
+
'.github/workflows/ci.yml': renderCi(shape),
|
|
69
|
+
'.github/workflows/security.yml': renderSecurity(shape),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const actionlint = renderActionlintConfig(shape);
|
|
73
|
+
if (actionlint !== undefined) files['.github/actionlint.yaml'] = actionlint;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
files,
|
|
77
|
+
policy: { checks: ['ci', 'secret scan'], owner: shape.owner, repository: shape.repository },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every path this renderer can emit, whatever a given shape asks for. */
|
|
82
|
+
export const RENDERED_PATHS: readonly RenderedPath[] = [
|
|
83
|
+
'.changeset/config.json',
|
|
84
|
+
'.github/actionlint.yaml',
|
|
85
|
+
'.github/dependabot.yml',
|
|
86
|
+
'.github/workflows/ci.yml',
|
|
87
|
+
'.github/workflows/security.yml',
|
|
88
|
+
];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.github/workflows/security.yml`, rendered.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS FILE IS WHY THE RENDERER EXISTS. Measured across 13 repositories on 2026-09-22,
|
|
5
|
+
* every one of these was a per-repo accident rather than a decision:
|
|
6
|
+
* · `push: branches: [main]` still present in 5, removed in 8;
|
|
7
|
+
* · the `concurrency:` block missing entirely in 5 — so a superseded scan ran to
|
|
8
|
+
* completion holding one of the mini's 3 slots for a result nobody would read;
|
|
9
|
+
* · `pull-requests: read` missing in 2, which makes every pull-request scan fail 403
|
|
10
|
+
* before it inspects a single line;
|
|
11
|
+
* · `gitleaks/gitleaks-action` pinned at `@v2` in 5 and `@v3` in 8, after GitHub
|
|
12
|
+
* removed the Node 20 runtime v2 needs (2026-09-16);
|
|
13
|
+
* · the weekly cron at three different minutes.
|
|
14
|
+
* One renderer makes all thirteen the same file, and the next such fix is one commit.
|
|
15
|
+
*/
|
|
16
|
+
import type { RepoShape } from './shape.ts';
|
|
17
|
+
import { runsOn } from './shape.ts';
|
|
18
|
+
import { renderSteps } from './yaml.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ⚠️ Off the hour deliberately — `:00` is when every scheduled workflow on GitHub fires
|
|
22
|
+
* at once, and queue time is the result. One minute for the whole estate: a repository
|
|
23
|
+
* does NOT get its own, because "which minute is this repo on" is a question with no
|
|
24
|
+
* useful answer and 13 repos produced 3 different ones by accident.
|
|
25
|
+
*/
|
|
26
|
+
const CRON = '41 6 * * 1';
|
|
27
|
+
|
|
28
|
+
const HEADER = `# Secret scanning, on its own schedule and its own check.
|
|
29
|
+
#
|
|
30
|
+
# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
|
|
31
|
+
# Its input is this repository's \`repo-shape.ts\`; refresh with \`bun run repo-shape:refresh\`.
|
|
32
|
+
#
|
|
33
|
+
# ★ SEPARATE FROM ci.yml BY CONCERN. A secret scan answers a different question from "is
|
|
34
|
+
# this code correct", it runs on a schedule as well as on pull requests (a rule added
|
|
35
|
+
# tomorrow can find a secret committed today), and it needs full history, which the CI
|
|
36
|
+
# jobs deliberately do not fetch.
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
const TRIGGER = `name: security
|
|
40
|
+
|
|
41
|
+
on:
|
|
42
|
+
# ⛔ NO \`push: branches: [main]\`, for the reason ci.yml gives: main only moves by
|
|
43
|
+
# squashing a pull request this same workflow already scanned, so the post-merge scan
|
|
44
|
+
# re-read commits it had just cleared. The weekly cron is what covers "a rule added
|
|
45
|
+
# tomorrow finds a secret committed today" — and it is the one that fetches full history.
|
|
46
|
+
pull_request:
|
|
47
|
+
schedule:
|
|
48
|
+
- cron: '${CRON}'
|
|
49
|
+
|
|
50
|
+
permissions:
|
|
51
|
+
contents: read
|
|
52
|
+
# 🔴 gitleaks-action lists pull-request commits through GitHub's API before scanning,
|
|
53
|
+
# and posts inline annotations. Without this, \`pull_request\` runs fail 403 before
|
|
54
|
+
# inspecting any code. Two of thirteen repositories were missing it (2026-09-22).
|
|
55
|
+
pull-requests: read
|
|
56
|
+
|
|
57
|
+
# ⛔ cancel-in-progress IS CONDITIONAL, NOT \`true\`. The scheduled run's ref is
|
|
58
|
+
# refs/heads/main; a plain \`true\` would let anything sharing that group kill the weekly
|
|
59
|
+
# full-history scan. Only pull-request runs are ever cancelled.
|
|
60
|
+
concurrency:
|
|
61
|
+
group: security-\${{ github.ref }}
|
|
62
|
+
cancel-in-progress: \${{ github.event_name == 'pull_request' }}
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const FETCH_NOTE = ` with:
|
|
66
|
+
# ⛔ FULL HISTORY. A shallow clone scans only the tip, which misses the case that
|
|
67
|
+
# matters most: a secret added and then removed in a later commit. It is still
|
|
68
|
+
# in the history, still fetchable, and still compromised.
|
|
69
|
+
fetch-depth: 0`;
|
|
70
|
+
|
|
71
|
+
const GITLEAKS_NOTE = ` # ⛔ v3, NOT v2. GitHub removed the Node 20 runtime from hosted runners on 2026-09-16
|
|
72
|
+
# (gitleaks/gitleaks-action's own v3.0.0 migration notes, verified 2026-09-17), so
|
|
73
|
+
# v2 (\`runs: node20\`) fails outright now, with no opt-out flag. v3 changes only the
|
|
74
|
+
# runtime to node24 — inputs, outputs and behaviour are unchanged.
|
|
75
|
+
# ★ arm64-SAFE ON THE MINI, verified rather than assumed: v3's src/gitleaks.js builds
|
|
76
|
+
# its download URL from \`process.arch\`, so the runner asks for
|
|
77
|
+
# gitleaks_<version>_linux_arm64.tar.gz, an asset its default release publishes
|
|
78
|
+
# (both read 2026-09-22). Nothing here is hard-coded to x64.
|
|
79
|
+
# ★ No \`GITLEAKS_LICENSE\`: that env var is required only for GitHub Organization
|
|
80
|
+
# accounts, and \`taslabs-net\` is a personal User account (2026-09-17,
|
|
81
|
+
# \`gh api users/taslabs-net --jq .type\` -> \`User\`).`;
|
|
82
|
+
|
|
83
|
+
/** The whole `security.yml` for a shape. */
|
|
84
|
+
export function renderSecurity(shape: RepoShape): string {
|
|
85
|
+
const on = runsOn(shape.runner);
|
|
86
|
+
// ⚠️ A GitHub ACTIONS EXPRESSION, NOT A TEMPLATE LITERAL. `${{ … }}` is interpolated by
|
|
87
|
+
// the Actions runner at job time and must reach the file verbatim, so it is a regular
|
|
88
|
+
// string on purpose — `no-template-curly-in-string` is warning about exactly the
|
|
89
|
+
// confusion this comment resolves, and it is right to ask.
|
|
90
|
+
// oxlint-disable-next-line no-template-curly-in-string
|
|
91
|
+
const token = '${{ secrets.GITHUB_TOKEN }}';
|
|
92
|
+
const scheduled =
|
|
93
|
+
shape.runner === 'mini'
|
|
94
|
+
? `\n # ⛔ THE SCHEDULED RUN GOES TO THE MINI TOO: if the runner daemon is down this queues\n # rather than failing, so a missed weekly scan is silent. homeflare-mini's\n # \`ci-runner\` vmalert group is what notices that, not this workflow.`
|
|
95
|
+
: '';
|
|
96
|
+
|
|
97
|
+
return `${HEADER}${TRIGGER}
|
|
98
|
+
jobs:
|
|
99
|
+
secrets:
|
|
100
|
+
name: secret scan${scheduled}
|
|
101
|
+
runs-on: ${on}
|
|
102
|
+
steps:
|
|
103
|
+
${renderSteps([{ uses: 'actions/checkout@v7' }], 3)}
|
|
104
|
+
${FETCH_NOTE}
|
|
105
|
+
|
|
106
|
+
${GITLEAKS_NOTE}
|
|
107
|
+
${renderSteps([{ uses: 'gitleaks/gitleaks-action@v3', env: { GITHUB_TOKEN: token } }], 3)}
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RepoShape` — one HomeFlare repository's tooling, as a value.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY THIS TYPE EXISTS. Measured across the estate on 2026-09-22: 13 repositories each
|
|
5
|
+
* carry a hand-written `.github/workflows/ci.yml`, `security.yml`, `.github/actionlint.yaml`
|
|
6
|
+
* and `.changeset/config.json`. The changeset configs were byte-identical apart from the
|
|
7
|
+
* repository name in 11 of 13. The security workflows were not: five still carried
|
|
8
|
+
* `push: branches: [main]` that the other eight had removed, five were missing the
|
|
9
|
+
* `concurrency:` block entirely, two were missing `pull-requests: read` (whose absence
|
|
10
|
+
* makes every pull-request scan fail 403), the gitleaks action was pinned at `@v2` in
|
|
11
|
+
* five and `@v3` in eight, and the weekly cron minute took three different values. Not
|
|
12
|
+
* one of those differences was a decision. They are what happens when the same edit is
|
|
13
|
+
* applied by hand thirteen times.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ SO: A DIFFERENCE IS EITHER AN INPUT OR AN EXCEPTION. Nothing else. A repository that
|
|
16
|
+
* needs something the standard does not give it either widens this type — and every
|
|
17
|
+
* repository gets the widening — or declares an exception with a stated reason. There
|
|
18
|
+
* is no third option where a file is quietly edited, because `drift.ts` fails on it.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ THE REASON IS ENFORCED BY THE COMPILER, NOT BY REVIEW. `except()` and `extraJob()`
|
|
21
|
+
* below refuse an empty reason and refuse a reason read out of a variable, so the text
|
|
22
|
+
* has to be written at the exception. See `Stated`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Where a repository's jobs run. */
|
|
26
|
+
export type RepoRunner =
|
|
27
|
+
/**
|
|
28
|
+
* `[self-hosted, homeflare-mini]` — the interim Linux arm64 runner on the Mac mini.
|
|
29
|
+
* ⚠️ GitHub-hosted runners are refused for this account (billing lock, 2026-09-22).
|
|
30
|
+
*/
|
|
31
|
+
| 'mini'
|
|
32
|
+
/** `ubuntu-latest`. Public repositories, where GitHub-hosted minutes are free. */
|
|
33
|
+
| 'github';
|
|
34
|
+
|
|
35
|
+
/** One step in a rendered job. Either a `uses:` or a `run:`, never both. */
|
|
36
|
+
export type JobStep = {
|
|
37
|
+
readonly name?: string;
|
|
38
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
39
|
+
} & (
|
|
40
|
+
| {
|
|
41
|
+
readonly uses: string;
|
|
42
|
+
readonly with?: Readonly<Record<string, string | number>>;
|
|
43
|
+
readonly run?: never;
|
|
44
|
+
}
|
|
45
|
+
| { readonly run: string; readonly uses?: never; readonly with?: never }
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ⛔ A REASON MUST BE WRITTEN, NOT COMPUTED. `'' extends R` is true for the empty literal
|
|
50
|
+
* AND for the wide `string` type, so both collapse to `never` and fail to typecheck:
|
|
51
|
+
* `reason: ''` is refused, and so is `reason: someVariable`. Only a string literal
|
|
52
|
+
* written at the call site survives — which is the whole point, because a reason
|
|
53
|
+
* assembled at runtime is a reason nobody reads in the diff.
|
|
54
|
+
* Verified against TypeScript 7.0.2 on 2026-09-22; `tests/repo-shape-reason.test.ts`
|
|
55
|
+
* compiles the refusals and asserts they still fail.
|
|
56
|
+
*/
|
|
57
|
+
export type Stated<R extends string> = '' extends R ? never : R;
|
|
58
|
+
|
|
59
|
+
/** Paths this package renders. A deviation names one of these, not an arbitrary file. */
|
|
60
|
+
export type RenderedPath =
|
|
61
|
+
| '.changeset/config.json'
|
|
62
|
+
| '.github/actionlint.yaml'
|
|
63
|
+
| '.github/dependabot.yml'
|
|
64
|
+
| '.github/workflows/ci.yml'
|
|
65
|
+
| '.github/workflows/security.yml';
|
|
66
|
+
|
|
67
|
+
export interface RepoShapeException {
|
|
68
|
+
/** The rendered file this repository does not take from the renderer. */
|
|
69
|
+
readonly file: RenderedPath;
|
|
70
|
+
/** Why — written at the exception, in a sentence a stranger can act on. */
|
|
71
|
+
readonly reason: string;
|
|
72
|
+
/** `YYYY-MM-DD` the exception was taken, so a stale one is visible. */
|
|
73
|
+
readonly since: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ExceptionInput {
|
|
77
|
+
readonly file: RenderedPath;
|
|
78
|
+
readonly reason: string;
|
|
79
|
+
readonly since: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Declare that this repository keeps its own copy of a rendered file.
|
|
84
|
+
*
|
|
85
|
+
* except({
|
|
86
|
+
* file: '.github/workflows/ci.yml',
|
|
87
|
+
* reason: 'Payload needs Node >=24.15, which the rendered job does not install',
|
|
88
|
+
* since: '2026-09-22',
|
|
89
|
+
* })
|
|
90
|
+
*
|
|
91
|
+
* ⛔ THE DRIFT CHECK STOPS CHECKING THAT FILE. That is the trade: an exception buys the
|
|
92
|
+
* freedom to hand-edit one file and pays for it by losing the guarantee on that file.
|
|
93
|
+
* Prefer widening `RepoShape` — then every repository benefits and nothing is lost.
|
|
94
|
+
*/
|
|
95
|
+
export function except<const E extends ExceptionInput>(
|
|
96
|
+
exception: E & { readonly reason: Stated<E['reason']> },
|
|
97
|
+
): RepoShapeException {
|
|
98
|
+
return {
|
|
99
|
+
file: exception.file,
|
|
100
|
+
reason: requireSentence('reason', exception.reason),
|
|
101
|
+
since: requireIsoDate(exception.since),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ExtraJob {
|
|
106
|
+
/** The job key in `jobs:`, e.g. `package`. Lower-case, dashes allowed. */
|
|
107
|
+
readonly id: string;
|
|
108
|
+
/**
|
|
109
|
+
* The job's `name:`, which is also the status-check context. It joins the `ci`
|
|
110
|
+
* aggregate's `needs`, so the branch ruleset still requires exactly one check.
|
|
111
|
+
*/
|
|
112
|
+
readonly name: string;
|
|
113
|
+
/** Why this repository has a job the other twelve do not. */
|
|
114
|
+
readonly reason: string;
|
|
115
|
+
/** Steps after checkout. The prologue (checkout, bun, install) is rendered for you. */
|
|
116
|
+
readonly steps: readonly JobStep[];
|
|
117
|
+
/** Job ids this one waits for. Default: none, so it runs beside `check`. */
|
|
118
|
+
readonly needs?: readonly string[];
|
|
119
|
+
/** `false` skips the rendered bun prologue — for a job that needs another toolchain. */
|
|
120
|
+
readonly bun?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ExtraJobInput extends Omit<ExtraJob, 'needs' | 'bun'> {
|
|
124
|
+
readonly needs?: readonly string[];
|
|
125
|
+
readonly bun?: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Declare a job this repository needs and the standard does not have.
|
|
130
|
+
* ⛔ Same rule as `except`: the reason is a written literal or it does not compile.
|
|
131
|
+
*/
|
|
132
|
+
export function extraJob<const J extends ExtraJobInput>(
|
|
133
|
+
job: J & { readonly reason: Stated<J['reason']> },
|
|
134
|
+
): ExtraJob {
|
|
135
|
+
if (job.steps.length === 0) {
|
|
136
|
+
throw new Error(`repo-shape: extra job "${job.id}" has no steps`);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
bun: job.bun ?? true,
|
|
140
|
+
id: requireJobId(job.id),
|
|
141
|
+
// ⚠️ A DISPLAY NAME IS NOT A SENTENCE. `build` and `consumer smoke test` are both
|
|
142
|
+
// correct job names; only the REASON has to be prose, because only the reason is
|
|
143
|
+
// there for a reader rather than for the checks list.
|
|
144
|
+
name: requireNonEmpty('name', job.name),
|
|
145
|
+
needs: [...(job.needs ?? [])],
|
|
146
|
+
reason: requireSentence('reason', job.reason),
|
|
147
|
+
steps: [...job.steps],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface RepoShape {
|
|
152
|
+
/** Repository owner — a user or organization login. */
|
|
153
|
+
readonly owner: string;
|
|
154
|
+
/** Repository name, which is also the checkout directory name. */
|
|
155
|
+
readonly repository: string;
|
|
156
|
+
/** Where its jobs run. */
|
|
157
|
+
readonly runner: RepoRunner;
|
|
158
|
+
/**
|
|
159
|
+
* `true` when the repository publishes an npm tarball. It decides `access` in the
|
|
160
|
+
* changeset config and whether `privatePackages` is written at all — the two keys
|
|
161
|
+
* that differ between `homeflare-kit` and every other repository.
|
|
162
|
+
*/
|
|
163
|
+
readonly publishes: boolean;
|
|
164
|
+
/** Jobs beyond `check` and `workflows`. Each carries its own stated reason. */
|
|
165
|
+
readonly extraJobs?: readonly ExtraJob[];
|
|
166
|
+
/** Rendered files this repository keeps its own copy of, each with a reason. */
|
|
167
|
+
readonly exceptions?: readonly RepoShapeException[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const SENTENCE = 12;
|
|
171
|
+
|
|
172
|
+
function requireNonEmpty(field: string, value: string): string {
|
|
173
|
+
const trimmed = value.trim();
|
|
174
|
+
if (trimmed.length === 0) throw new Error(`repo-shape: ${field} must not be blank`);
|
|
175
|
+
return trimmed;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requireSentence(field: string, value: string): string {
|
|
179
|
+
const trimmed = value.trim();
|
|
180
|
+
// ⚠️ THE TYPE CANNOT CATCH `reason: ' '`. `' '` is a non-empty literal, so `Stated`
|
|
181
|
+
// lets it through and only this does not. Type and guard cover different halves.
|
|
182
|
+
if (trimmed.length < SENTENCE) {
|
|
183
|
+
throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);
|
|
184
|
+
}
|
|
185
|
+
return trimmed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function requireIsoDate(value: string): string {
|
|
189
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
190
|
+
throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function requireJobId(value: string): string {
|
|
196
|
+
// ⛔ The id becomes a YAML key and a `needs:` entry. Anything else renders a workflow
|
|
197
|
+
// GitHub rejects at parse time, which reports as "workflow file issue" with no line.
|
|
198
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(value)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `runs-on:` for a runner. */
|
|
207
|
+
export function runsOn(runner: RepoRunner): string {
|
|
208
|
+
return runner === 'mini' ? '[self-hosted, homeflare-mini]' : 'ubuntu-latest';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Whether a rendered path is excepted by this shape. */
|
|
212
|
+
export function isExcepted(shape: RepoShape, file: RenderedPath): boolean {
|
|
213
|
+
return (shape.exceptions ?? []).some((exception) => exception.file === file);
|
|
214
|
+
}
|