@homeflare/config 0.12.0 → 0.13.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.
@@ -0,0 +1,75 @@
1
+ # Retired rendered files — the fossil, and how it is cleared
2
+
3
+ `renderRepoShape` only emits the files a shape asks for today. `refreshRepoShape` only
4
+ writes what it renders. Put those two together and a file the renderer stops emitting is
5
+ never deleted by a refresh — it just stops being updated, silently, in every repository
6
+ that had already taken it. `RETIRED_FILES` in `src/repo-shape/retired.ts` is how that stops
7
+ being permanent.
8
+
9
+ ## MEASURED 2026-09-24: the gap that motivated this
10
+
11
+ `@homeflare/config` 0.12.0 ([kit PR 199](https://github.com/taslabs-net/homeflare-kit/pull/199))
12
+ stopped rendering `.github/workflows/dependabot-automerge.yml` — `taslabs-net/homeflare-bumper`
13
+ carries a kit release into a consumer now, over `workflow_dispatch`, so the workflow that used
14
+ to arm auto-merge on Dependabot's `homeflare` group had nothing left to do.
15
+
16
+ Every repository that had already refreshed to 0.12.0 before this module existed
17
+ (`homeflare-wiki` bump PR 19, `homeflare-mini` bump PR 47) kept the dead file. Nothing said
18
+ so: `driftInRepoShape` only ever compared the paths the **current** shape renders, and a
19
+ retired path is not one of those, so it was invisible to the one check whose whole job is
20
+ to say when a committed file and the renderer disagree.
21
+
22
+ ## The two halves
23
+
24
+ **`driftInRepoShape` (`repo-shape check`) reports a retired path that is merely present.**
25
+ It does not read the file to decide anything beyond "is it there" — proving the file is
26
+ provably ours is `refreshRepoShape`'s decision to make, not the read-only check's. So
27
+ `bun run repo-shape:refresh` fails on a retired fossil exactly the way it fails on ordinary
28
+ drift:
29
+
30
+ ```
31
+ ✗ .github/workflows/dependabot-automerge.yml: retired in @homeflare/config@0.12.0
32
+ (kit PR 199 retired the Dependabot @homeflare group; taslabs-net/homeflare-bumper carries
33
+ a kit release into each consumer now, over the kit's own release workflow) but still
34
+ present — run `bun run repo-shape:refresh` to remove it
35
+ ```
36
+
37
+ **`refreshRepoShape` deletes a retired path only when it can prove it rendered it.** The
38
+ proof is the generated-file header: every renderer in `src/repo-shape/` writes
39
+ `🤖 RENDERED BY @homeflare/config` into the file it emits, and `wasRenderedByUs` is nothing
40
+ more than a check for that line. A retired path whose content carries it is deleted. A
41
+ retired path whose content does not — a hand-written replacement, a fork of the old
42
+ rendered file kept on purpose, or coincidental content that landed at the same name — is
43
+ left exactly as it is, and reported back as `refused`, never `removed`.
44
+
45
+ ⚠️ **A refused file still fails `repo-shape check`.** The check does not know or care
46
+ whether the file is provably ours; it only knows the retired path is present. There is no
47
+ `except()` for a retired path — a repository that genuinely wants to keep a hand-written
48
+ file at that exact name has to remove it or rename it itself; `repo-shape` will not delete
49
+ someone else's file to make its own check pass.
50
+
51
+ ## Why the header, not a stored copy of the old rendered text
52
+
53
+ The retired `dependabot-automerge.yml` varied by `shape.runner` and by repository name —
54
+ there is no single byte-for-byte "the rendered form" to compare against across the estate's
55
+ history, and a renderer that has been deleted cannot be asked to re-render its old output.
56
+ The header line is the one thing every variant, in every repository, always carried.
57
+
58
+ ## Retiring a path, going forward
59
+
60
+ Add an entry to `RETIRED_FILES` in `src/repo-shape/retired.ts`: the path, the
61
+ `@homeflare/config` version whose release stops rendering it, and a written reason — same
62
+ rule as `except()`, enforced the same way. Remove the path from `render.ts` in the same
63
+ changeset. A path can never move back into `RenderedPath`; `repo-shape-retired.test.ts`
64
+ asserts the two lists stay disjoint, because a path in both would have the renderer writing
65
+ a file this module is also trying to delete, every single refresh.
66
+
67
+ ## How this reaches a consumer
68
+
69
+ A patch release of `@homeflare/config` ships the new `RETIRED_FILES` entry.
70
+ `taslabs-net/homeflare-bumper` opens the bump pull request in each consumer the way it
71
+ always does. Its own `bun run repo-shape:refresh` — the same step every bump PR already
72
+ needs, [documented here](./repo-shape.md#bumping-homeflareconfig-will-go-red-before-it-goes-green) — now also deletes
73
+ the fossil, so the PR's diff touches `.github/workflows/`. **The bumper holds a PR that
74
+ touches that directory for Tim rather than auto-merging it**, which is the correct,
75
+ existing behavior for a workflow-file change — not something this module has to arrange.
@@ -173,6 +173,17 @@ The alternative — a check that tolerated an older render — is a check that t
173
173
  drift, which is the thing this exists to stop. A loud, one-command failure is the better
174
174
  half of that trade, but it is a trade.
175
175
 
176
+ ## A file the renderer stops emitting is deleted, not left behind
177
+
178
+ `renderRepoShape` only emits what a shape asks for today, and `refreshRepoShape` only
179
+ writes what it renders — so on its own, a file the renderer retires would never get
180
+ deleted by a refresh; it would just stop being updated, in every repository that already
181
+ had it. `RETIRED_FILES` in `src/repo-shape/retired.ts` closes that gap: `repo-shape check`
182
+ flags a retired path that is still present, and a refresh deletes it — but only when the
183
+ file provably carries this package's generated-file header, never a hand-written file that
184
+ happens to share the name. [repo-shape-retired.md](repo-shape-retired.md) has the mechanism
185
+ and the measurement that found the first fossil.
186
+
176
187
  ## What this does not render yet
177
188
 
178
189
  `.github/workflows/release.yml`. Thirteen copies, 166–197 lines each, thirteen distinct
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/config",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Shared tsconfig, oxlint, oxfmt, and non-npm release helpers for HomeFlare projects.",
5
5
  "license": "MIT",
6
6
  "author": "Timothy Schneider",
@@ -74,6 +74,21 @@ permissions:
74
74
  concurrency:
75
75
  group: ci-\${{ github.ref }}
76
76
  cancel-in-progress: true
77
+
78
+ # ⛔ SECOND GUARD, NOT THE FIRST ONE. The estate's primary Alchemy telemetry opt-out is the
79
+ # persisted \`~/.alchemy/telemetry-disabled\` file (Tim, 2026-09-26), which a CI runner never
80
+ # has — a self-hosted job starts a fresh container per run and a GitHub-hosted one is a fresh
81
+ # VM every time, so neither has read the file even if this estate's account somehow wrote it
82
+ # there. \`DO_NOT_TRACK\` is the opt-out alchemy's telemetry layer actually reads
83
+ # (\`!!process.env.DO_NOT_TRACK\`, alchemy \`src/Telemetry/Attributes.ts:126-129\`); this env
84
+ # applies to every job and step in this workflow. No repository's \`check\` invokes the Alchemy
85
+ # CLI today, measured 2026-09-26 (grepped every ci/security/release workflow for
86
+ # \`alchemy\`/\`bin/cli.js\`, zero hits), but alchemy's own \`Test\` helpers wire \`TelemetryLive\`
87
+ # into top-level \`deploy\`/\`destroy\` (\`src/Test/Core.ts:470,482\`), so a future kit test built on
88
+ # those (today's kit tests use only \`test.provider\` scratch stacks, which don't) would send
89
+ # telemetry from \`bun test\` without this guard — this closes that gap in advance.
90
+ env:
91
+ DO_NOT_TRACK: '1'
77
92
  `;
78
93
 
79
94
  const CHECK_NOTE = ` # ── One job, one install ────────────────────────────────────────────────────
@@ -18,6 +18,7 @@
18
18
  * The one exception is line endings, normalised so a CRLF checkout is not "drift".
19
19
  */
20
20
  import { type RenderedRepo, renderRepoShape } from './render.ts';
21
+ import { RETIRED_FILES, retiredFileProblem } from './retired.ts';
21
22
  import type { RenderedPath, RepoShape, RepoShapeException } from './shape.ts';
22
23
 
23
24
  /** One thing to fix, in the imperative — the same `Problem` shape `./check` reports. */
@@ -69,8 +70,24 @@ function duplicateExceptions(exceptions: readonly RepoShapeException[]): Problem
69
70
  );
70
71
  }
71
72
 
73
+ /**
74
+ * ★ A RETIRED PATH IS REPORTED WHETHER OR NOT IT IS PROVABLY OURS. This check only reads
75
+ * the file to see if it is THERE — `wasRenderedByUs` is `refreshRepoShape`'s call to
76
+ * make, because only the writer should decide whether to delete. Reporting here is what
77
+ * makes a fossil visible even in a repository that only ever runs `--check` in CI.
78
+ */
79
+ async function retiredFilesPresent(projectDir: string): Promise<Problem[]> {
80
+ const problems: Problem[] = [];
81
+ for (const file of RETIRED_FILES) {
82
+ if ((await readIfPresent(`${projectDir}/${file.path}`)) !== undefined) {
83
+ problems.push(retiredFileProblem(file));
84
+ }
85
+ }
86
+ return problems;
87
+ }
88
+
72
89
  export interface DriftReport {
73
- /** Empty when the committed files are the rendered ones. */
90
+ /** Empty when the committed files are the rendered ones and no retired path lingers. */
74
91
  readonly problems: readonly Problem[];
75
92
  /** Paths whose committed text differs from the render, excluding excepted files. */
76
93
  readonly drifted: readonly string[];
@@ -93,6 +110,7 @@ export async function driftInRepoShape(projectDir: string, shape: RepoShape): Pr
93
110
  const problems: Problem[] = [
94
111
  ...duplicateExceptions(exceptions),
95
112
  ...staleExceptions(rendered, exceptions),
113
+ ...(await retiredFilesPresent(projectDir)),
96
114
  ];
97
115
  const drifted: string[] = [];
98
116
 
@@ -36,6 +36,17 @@ export function requireIsoDate(value: string): string {
36
36
  return value;
37
37
  }
38
38
 
39
+ /**
40
+ * A retired file names the `@homeflare/config` release that stopped rendering it, so
41
+ * anyone reading `retired.ts` can find the changeset that made the call.
42
+ */
43
+ export function requireSemver(value: string): string {
44
+ if (!/^\d+\.\d+\.\d+$/.test(value)) {
45
+ throw new Error(`repo-shape: retiredIn must be a released x.y.z, got ${JSON.stringify(value)}`);
46
+ }
47
+ return value;
48
+ }
49
+
39
50
  /**
40
51
  * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`
41
52
  * and resolves the newest 24.x; a fractional or negative value renders YAML the action
@@ -12,9 +12,17 @@
12
12
  * ⚠️ IT NEVER WRITES AN EXCEPTED FILE. Refreshing a file the repository declared it owns
13
13
  * would overwrite the deviation the reason was written for — the one destructive thing
14
14
  * this could do, and the one it must not.
15
+ *
16
+ * ⚠️ IT DELETES A RETIRED FILE ONLY WHEN IT CAN PROVE IT RENDERED IT. `RETIRED_FILES`
17
+ * (`retired.ts`) names every path this package used to render; `wasRenderedByUs` is the
18
+ * proof — the file still carries the generated-file header. A retired path present
19
+ * without that header is left alone and reported as refused, on the same reasoning as
20
+ * never overwriting an excepted file: this writer only ever removes what it is sure is
21
+ * its own.
15
22
  */
16
23
  import { REFRESH_COMMAND, driftInRepoShape, exceptionSummary } from './drift.ts';
17
24
  import { renderRepoShape } from './render.ts';
25
+ import { RETIRED_FILES, wasRenderedByUs } from './retired.ts';
18
26
  import { type RepoShape, isExcepted } from './shape.ts';
19
27
  import type { RenderedPath } from './shape.ts';
20
28
 
@@ -25,9 +33,13 @@ export interface RefreshResult {
25
33
  readonly unchanged: readonly string[];
26
34
  /** Paths skipped because the shape declares an exception for them. */
27
35
  readonly skipped: readonly string[];
36
+ /** Retired paths deleted because they still carried the generated-file header. */
37
+ readonly removed: readonly string[];
38
+ /** Retired paths left alone because they did not — provably hand-written, not ours. */
39
+ readonly refused: readonly string[];
28
40
  }
29
41
 
30
- /** Write a repository's rendered files into `projectDir`. */
42
+ /** Write a repository's rendered files into `projectDir`, and clear its rendered fossils. */
31
43
  export async function refreshRepoShape(
32
44
  projectDir: string,
33
45
  shape: RepoShape,
@@ -52,7 +64,36 @@ export async function refreshRepoShape(
52
64
  written.push(path);
53
65
  }
54
66
 
55
- return { skipped, unchanged, written };
67
+ const { refused, removed } = await clearRetiredFiles(projectDir);
68
+
69
+ return { refused, removed, skipped, unchanged, written };
70
+ }
71
+
72
+ /**
73
+ * Delete every retired path this package can prove it rendered; leave every other one, and
74
+ * say so. A path absent from `projectDir` is neither removed nor refused — there is
75
+ * nothing there to have an opinion about.
76
+ */
77
+ async function clearRetiredFiles(
78
+ projectDir: string,
79
+ ): Promise<{ removed: string[]; refused: string[] }> {
80
+ const removed: string[] = [];
81
+ const refused: string[] = [];
82
+
83
+ for (const retiredFile of RETIRED_FILES) {
84
+ const target = `${projectDir}/${retiredFile.path}`;
85
+ const file = Bun.file(target);
86
+ if (!(await file.exists())) continue;
87
+
88
+ if (wasRenderedByUs(await file.text())) {
89
+ await file.delete();
90
+ removed.push(retiredFile.path);
91
+ } else {
92
+ refused.push(retiredFile.path);
93
+ }
94
+ }
95
+
96
+ return { refused, removed };
56
97
  }
57
98
 
58
99
  /**
@@ -108,6 +149,18 @@ export async function repoShapeCli(
108
149
  ...result.written.map((path) => `wrote ${path}`),
109
150
  ...result.unchanged.map((path) => `ok ${path}`),
110
151
  ...result.skipped.map((path) => `excepted ${path}`),
152
+ ...result.removed.map((path) => `removed ${path} (retired; carried our header)`),
111
153
  ]);
154
+ // ★ ON ITS OWN LINE, TO STDERR: a refused retired file is the one outcome here that
155
+ // still needs a person. `--check` (above) keeps failing on it — it reports any retired
156
+ // path that is present, proof or not — so this is not the only place it is said, but
157
+ // it is the only place that says WHY refresh did not just fix it.
158
+ await say(
159
+ Bun.stderr,
160
+ result.refused.map(
161
+ (path) =>
162
+ `refused ${path} — present but not provably ours; not deleted. See docs/repo-shape-retired.md.`,
163
+ ),
164
+ );
112
165
  return 0;
113
166
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Rendered paths this package stopped rendering — the "no fossils" list.
3
+ *
4
+ * ★ WHY THIS EXISTS. `renderRepoShape` only emits the files a shape asks for today; it
5
+ * never says what it used to emit, and `refreshRepoShape` only writes what it renders —
6
+ * so a file retired from the renderer was never deleted by a refresh. It just stopped
7
+ * being updated, silently, forever, in every repository that had already taken it.
8
+ *
9
+ * MEASURED 2026-09-24: `@homeflare/config` 0.12.0 (kit PR 199) stopped rendering
10
+ * `.github/workflows/dependabot-automerge.yml`. Every consumer that had refreshed to
11
+ * 0.12.0 before this module existed (homeflare-wiki bump PR 19, homeflare-mini bump
12
+ * PR 47) kept the dead file, and `driftInRepoShape` never said so — it only compares
13
+ * paths the CURRENT shape renders, and a retired path is not one of those.
14
+ *
15
+ * ⛔ RETIRING A PATH IS A ONE-WAY DOOR, NOT A RENAME. Once a path is here it can never
16
+ * become a `RenderedPath` again: `render.ts` would start writing a file this module is
17
+ * also trying to delete, and every refresh would fight itself. Give the replacement a
18
+ * new path instead. `repo-shape-retired.test.ts` asserts the two lists never overlap.
19
+ *
20
+ * ⚠️ DELETION IS GATED ON PROOF, NOT ON THE PATH ALONE. A repository can have a
21
+ * hand-written file sitting at the exact path a retired renderer used to own — a fork of
22
+ * the old rendered file, kept on purpose, or unrelated content that just landed there.
23
+ * `wasRenderedByUs` is the proof: every renderer in this directory writes the
24
+ * `🤖 RENDERED BY @homeflare/config` line into its header (grep the directory for it),
25
+ * and that line is the one thing a hand-written file has no reason to contain.
26
+ * `refreshRepoShape` deletes a retired path only when the line is present; otherwise it
27
+ * refuses and reports the path, the same way it refuses to overwrite an excepted file.
28
+ */
29
+ import { requireSemver, requireSentence } from './guards.ts';
30
+
31
+ /**
32
+ * A path `@homeflare/config` no longer renders, but may still find committed in a
33
+ * repository that refreshed before the retirement. Extend this union at the same time a
34
+ * path is added to `RETIRED_FILES` below — never reuse one already there.
35
+ */
36
+ export type RetiredPath = '.github/workflows/dependabot-automerge.yml';
37
+
38
+ export interface RetiredFile {
39
+ /** The path this package rendered, once. */
40
+ readonly path: RetiredPath;
41
+ /** The `@homeflare/config` version whose release stopped rendering it. */
42
+ readonly retiredIn: string;
43
+ /** Why, and which kit change did it — written at the retirement, like `except()`'s reason. */
44
+ readonly reason: string;
45
+ }
46
+
47
+ function retire(file: RetiredFile): RetiredFile {
48
+ return {
49
+ path: file.path,
50
+ reason: requireSentence('retired reason', file.reason),
51
+ retiredIn: requireSemver(file.retiredIn),
52
+ };
53
+ }
54
+
55
+ /** Every path `@homeflare/config` used to render, oldest retirement first. */
56
+ export const RETIRED_FILES: readonly RetiredFile[] = [
57
+ retire({
58
+ path: '.github/workflows/dependabot-automerge.yml',
59
+ reason:
60
+ 'kit PR 199 retired the Dependabot @homeflare group; taslabs-net/homeflare-bumper ' +
61
+ "carries a kit release into each consumer now, over the kit's own release workflow",
62
+ retiredIn: '0.12.0',
63
+ }),
64
+ ];
65
+
66
+ /**
67
+ * The substring every generated file's header has carried since this package's first
68
+ * renderer. Kept independent of the four current renderers' own copies of this text
69
+ * (`ci.ts`, `security.ts`, `dependabot.ts`, `companions.ts`) on purpose: a future wording
70
+ * change to the live header must not stop this module recognising a file rendered under
71
+ * the old one.
72
+ */
73
+ export const GENERATED_FILE_MARKER = '🤖 RENDERED BY @homeflare/config';
74
+
75
+ /**
76
+ * Whether `content` is provably a file this package once wrote.
77
+ *
78
+ * ⚠️ THE MARKER HAS TO START A LINE OF ITS OWN, NOT MERELY OCCUR SOMEWHERE IN THE TEXT.
79
+ * Every real header carries it as a whole comment line — `# 🤖 RENDERED BY
80
+ * @homeflare/config — DO NOT EDIT…`, third line in every renderer in this directory, so
81
+ * this does not require it to be the FIRST line — but a bare `content.includes(...)`
82
+ * would also match a hand-written file that merely *talks about* the marker, e.g. a
83
+ * comment reading "this file used to be 🤖 RENDERED BY @homeflare/config before it was
84
+ * retired; keeping it by hand now" — adversarial review, 2026-09-24, caught this exact
85
+ * case before it shipped. Requiring the marker at the start of a trimmed line is what a
86
+ * sentence built around it, rather than a header line consisting of it, cannot satisfy.
87
+ * `repo-shape-retired.test.ts` asserts the mid-sentence form stays refused.
88
+ *
89
+ * ⚠️ STILL A PREFIX CHECK ON THAT LINE, NOT A FULL-LINE EXACT MATCH. The retired renderer
90
+ * varied its trailing header prose by `shape.runner` and by repository name, and a
91
+ * future wording change to what follows the marker on a live renderer's header must not
92
+ * stop this recognising a file rendered under the old wording — only the marker itself,
93
+ * `🤖 RENDERED BY @homeflare/config`, has been constant across every renderer this
94
+ * package has ever shipped.
95
+ */
96
+ export function wasRenderedByUs(content: string): boolean {
97
+ return content
98
+ .split('\n')
99
+ .some((line) => line.trimStart().startsWith(`# ${GENERATED_FILE_MARKER}`));
100
+ }
101
+
102
+ /**
103
+ * The message `driftInRepoShape` reports for a retired path that is still present, and
104
+ * `repoShapeCli --check` prints verbatim. Names the fix command either way — whether that
105
+ * command can actually remove the file depends on `wasRenderedByUs`, which only
106
+ * `refreshRepoShape` (holding the file's contents) can decide.
107
+ */
108
+ export function retiredFileProblem(file: RetiredFile): string {
109
+ return (
110
+ `${file.path}: retired in @homeflare/config@${file.retiredIn} (${file.reason}) ` +
111
+ 'but still present — run `bun run repo-shape:refresh` to remove it'
112
+ );
113
+ }
package/src/repo-shape.ts CHANGED
@@ -54,6 +54,14 @@ export {
54
54
  RENDERED_PATHS,
55
55
  renderRepoShape,
56
56
  } from './repo-shape/render.ts';
57
+ export {
58
+ type RetiredFile,
59
+ type RetiredPath,
60
+ GENERATED_FILE_MARKER,
61
+ RETIRED_FILES,
62
+ retiredFileProblem,
63
+ wasRenderedByUs,
64
+ } from './repo-shape/retired.ts';
57
65
  export { renderSecurity } from './repo-shape/security.ts';
58
66
  export {
59
67
  type ExtraJob,