@homeflare/config 0.3.0 → 0.5.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 CHANGED
@@ -14,7 +14,8 @@ bun add -D @homeflare/config
14
14
  | `tsconfig.base.json` | `extends` — owned code, full strictness |
15
15
  | `tsconfig.app.json` | `extends` — Worker apps (source-publishing deps) |
16
16
  | `tsconfig.lib.json` | `extends` — packages that publish types |
17
- | `oxlintrc.json` | `extends` — 36 rules, 6 plugins |
17
+ | `oxlintrc.json` | `extends` — libraries |
18
+ | `oxlintrc.app.json` | `extends` — Worker / TanStack / Alchemy apps |
18
19
  | `oxfmtrc.json` | copy to `.oxfmtrc.json` |
19
20
  | `bunfig.toml` | copy to `bunfig.toml` |
20
21
 
@@ -50,18 +51,28 @@ own. Override the flags back on in a project that does not import source-publish
50
51
  { "extends": ["./node_modules/@homeflare/config/oxlintrc.json"] }
51
52
  ```
52
53
 
54
+ A Worker, TanStack Start, or Alchemy app:
55
+
56
+ ```json
57
+ { "extends": ["./node_modules/@homeflare/config/oxlintrc.app.json"] }
58
+ ```
59
+
53
60
  ⚠️ oxlint's `extends` takes **file paths**, not package names — there is no
54
61
  `eslint-config-*` style resolution, so the path into `node_modules` is written out.
55
62
 
56
- 36 rules across `typescript`, `unicorn`, `oxc`, `import`, `react` and `jsx-a11y`. The ones
57
- worth knowing:
63
+ The library preset is 36 rules across `typescript`, `unicorn`, `oxc`, `import`, `react`
64
+ and `jsx-a11y`. The app preset extends it and turns off the collisions `--deny-warnings`
65
+ hits on correct Worker/app code (measured 2026-09-16):
66
+
67
+ - `no-console` off under `src/` — `console` _is_ Workers Logs.
68
+ - `import/no-default-export` off — Alchemy, `Worker`, and TanStack entry files export
69
+ default.
70
+ - `jsx-a11y/control-has-associated-label` off — Kumo initials triggers are labelled by
71
+ the design system, not a `htmlFor`.
72
+ - `typescript/no-non-null-assertion` off in tests.
58
73
 
59
- - `no-console: error` — ⚠️ except where `console` _is_ the transport, as in a Worker
60
- writing JSON for Workers Logs. Override it per file, not globally.
61
- - `typescript/no-non-null-assertion` — `!` hides exactly what `noUncheckedIndexedAccess`
62
- is trying to surface.
63
- - `react/exhaustive-deps`, `jsx-a11y/click-events-have-key-events` — only bite in React
64
- code; a Worker never sees them.
74
+ ⛔ Do not copy the library preset into an app and then disable those one by one. That is
75
+ how every app grows a private oxlint.
65
76
 
66
77
  ## oxfmt and bunfig
67
78
 
@@ -73,8 +84,12 @@ cp node_modules/@homeflare/config/bunfig.toml bunfig.toml
73
84
  ⚠️ `.oxfmtrc.json` needs the **leading dot**. Without it oxfmt silently uses its defaults,
74
85
  and the symptom is a formatter that rewrites your quotes.
75
86
 
76
- ⛔ Neither format supports `extends`, so these two are copies. That means they can drift —
77
- which is what `@homeflare/config/check` exists to catch.
87
+ ⛔ oxfmt has no `extends`. Extra `ignorePatterns` are a **merge**: generated OpenAPI,
88
+ `vendor/`, `**/generated/**`, `**/*.gen.ts` stay out of the formatter. ⛔ Identity
89
+ comparison is the wrong gate — it rewrote 154 files to `singleQuote` and formatted
90
+ vendor OpenAPI (measured 2026-09-16). Dropping a house ignore is still drift.
91
+
92
+ `bunfig.toml` is still an exact copy.
78
93
 
79
94
  ## Keeping a project honest
80
95
 
@@ -86,8 +101,46 @@ if (problems.length > 0) throw new Error(problems.join('\n'));
86
101
  ```
87
102
 
88
103
  ★ Run it from a test. It asserts a project still extends the shared tsconfig and oxlint
89
- presets, and that its copied `.oxfmtrc.json` and `bunfig.toml` still match — so a project
90
- that quietly diverges fails its own suite rather than drifting for six months.
104
+ presets, that `.oxfmtrc.json` keeps house style plus at least the house ignores, and that
105
+ `bunfig.toml` still matches.
106
+
107
+ ## App releases (no npm)
108
+
109
+ A HomeFlare app that does **not** publish a tarball still versions itself with Changesets
110
+ and cuts a GitHub Release. It does not call `npm publish`.
111
+
112
+ ```ts
113
+ import { runAppRelease } from '@homeflare/config/release';
114
+
115
+ await runAppRelease(process.cwd());
116
+ ```
117
+
118
+ `shouldRelease` proceeds only when `CHANGELOG.md` has `## <version>` (proof
119
+ `changeset version` ran) AND no git tag `<name>@<version>` exists yet. A custom
120
+ `publish-script` on changesets/action otherwise tags every changeset-less push to `main`
121
+ (measured 2026-09-16/17; changesets/action#9).
122
+
123
+ ```ts
124
+ import { runRequireReleaseConfig } from '@homeflare/config/require-release-config';
125
+
126
+ await runRequireReleaseConfig();
127
+ ```
128
+
129
+ ⛔ A private `package.json` without `privatePackages.version: true` makes
130
+ `changeset version` silently no-op. The guard fails that combination before the version
131
+ command consumes the changeset file.
132
+
133
+ ⛔ A leftover `pnpm-workspace.yaml` that lists only nested packages hides the root.
134
+ Measured 2026-09-17 on `homeflare-secrets`: `changeset version` exited 1 ("package
135
+ homeflare-secrets which is not in the workspace") and no Version Packages PR opened.
136
+ The workspace file must include `.` if it exists at all.
137
+
138
+ ⛔ Both helpers take the **app** cwd / paths. Defaulting from `import.meta.url` after
139
+ publish would inspect `@homeflare/config` itself.
140
+
141
+ Alchemy still owns `GitHub.Repository` (visibility, `deleteBranchOnMerge`, `hasWiki`) and
142
+ `Cloudflare.state()`. The kit `main` ruleset is `scripts/apply-main-ruleset.ts`, not an
143
+ Alchemy resource — see `docs/github-hygiene.md`.
91
144
 
92
145
  ## License
93
146
 
@@ -0,0 +1,5 @@
1
+ export declare function workspacePackageNames(cwd: string): Promise<readonly string[]>;
2
+ /** Package names in a changeset file's YAML frontmatter. */
3
+ export declare function packagesNamedInChangeset(text: string): readonly string[];
4
+ export declare function problemsFromPendingChangesets(cwd: string): Promise<readonly string[]>;
5
+ //# sourceMappingURL=changeset-workspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changeset-workspace.d.ts","sourceRoot":"","sources":["../src/changeset-workspace.ts"],"names":[],"mappings":"AA0BA,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAcnF;AAkBD,4DAA4D;AAC5D,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAMxE;AAED,wBAAsB,6BAA6B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CA2B3F"}
package/dist/check.d.ts CHANGED
@@ -1,20 +1,3 @@
1
- /**
2
- * Assert that a project still stands on the shared config.
3
- *
4
- * ★ WHY THIS EXISTS. Two of the five presets — `.oxfmtrc.json` and `bunfig.toml` — have no
5
- * `extends` mechanism, so adopting them means COPYING them. A copy drifts silently: the
6
- * project keeps building, the rules quietly diverge, and nobody finds out until two
7
- * repos disagree about how the same code should look.
8
- *
9
- * ⛔ IT REPORTS, IT DOES NOT REPAIR. A checker that rewrites a project's config would
10
- * overwrite a deliberate local exception, and this cannot tell the difference between
11
- * drift and a decision. It names what differs; a human decides which way to move.
12
- *
13
- * ⚠️ Every file it reads is parsed natively — `Bun.file().json()`, `Bun.TOML.parse`. No
14
- * parsing dependency reaches a consumer.
15
- * ⛔ NOT JSONC. Bun's `.json()` rejects comments (measured 2026-09-15), so a project
16
- * whose tsconfig carries them is reported as unreadable rather than silently skipped.
17
- */
18
1
  /** One thing a project should fix, in the imperative. */
19
2
  export type Problem = string;
20
3
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,yDAAyD;AACzD,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAkH7B;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CASlF"}
1
+ {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAmBA,yDAAyD;AACzD,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAyI7B;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CASlF"}
package/dist/check.js CHANGED
@@ -1,4 +1,22 @@
1
1
  // @bun
2
+ // src/oxfmt.ts
3
+ var STYLE_KEYS = ["printWidth", "useTabs", "singleQuote", "semi"];
4
+ function problemsInOxfmt(preset, theirs) {
5
+ const problems = [];
6
+ for (const key of STYLE_KEYS) {
7
+ if (theirs[key] !== preset[key]) {
8
+ problems.push(`.oxfmtrc.json: ${key} differs from @homeflare/config \u2014 re-copy it, or say why it differs`);
9
+ }
10
+ }
11
+ const local = new Set(theirs.ignorePatterns ?? []);
12
+ for (const pattern of preset.ignorePatterns ?? []) {
13
+ if (!local.has(pattern)) {
14
+ problems.push(`.oxfmtrc.json: missing house ignore ${pattern} \u2014 generated files would be rewritten`);
15
+ }
16
+ }
17
+ return problems;
18
+ }
19
+
2
20
  // src/check.ts
3
21
  function stripComments(text) {
4
22
  let out = "";
@@ -74,11 +92,27 @@ async function checkCopy(projectDir, file, parse, problems) {
74
92
  problems.push(`${file}: differs from @homeflare/config \u2014 re-copy it, or say why it differs`);
75
93
  }
76
94
  }
95
+ async function checkOxfmt(projectDir, problems) {
96
+ const mine = Bun.file(`${projectDir}/.oxfmtrc.json`);
97
+ if (!await mine.exists()) {
98
+ problems.push(".oxfmtrc.json: missing \u2014 copy it from @homeflare/config");
99
+ return;
100
+ }
101
+ let theirs;
102
+ try {
103
+ theirs = JSON.parse(await mine.text());
104
+ } catch {
105
+ problems.push(".oxfmtrc.json: unparseable");
106
+ return;
107
+ }
108
+ const preset = JSON.parse(await Bun.file(`${presetDir()}oxfmtrc.json`).text());
109
+ problems.push(...problemsInOxfmt(preset, theirs));
110
+ }
77
111
  async function checkProject(projectDir) {
78
112
  const problems = [];
79
113
  await checkExtends(projectDir, "tsconfig.json", "@homeflare/config/tsconfig", problems);
80
114
  await checkExtends(projectDir, ".oxlintrc.json", "@homeflare/config/oxlintrc", problems);
81
- await checkCopy(projectDir, ".oxfmtrc.json", (t) => JSON.parse(t), problems);
115
+ await checkOxfmt(projectDir, problems);
82
116
  await checkCopy(projectDir, "bunfig.toml", (t) => Bun.TOML.parse(t), problems);
83
117
  return problems;
84
118
  }
@@ -86,5 +120,5 @@ export {
86
120
  checkProject
87
121
  };
88
122
 
89
- //# debugId=7F6869C495728F5464756E2164756E21
123
+ //# debugId=550B779804E201AD64756E2164756E21
90
124
  //# sourceMappingURL=check.js.map
package/dist/check.js.map CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/check.ts"],
3
+ "sources": ["../src/oxfmt.ts", "../src/check.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * Assert that a project still stands on the shared config.\n *\n * ★ WHY THIS EXISTS. Two of the five presets — `.oxfmtrc.json` and `bunfig.toml` — have no\n * `extends` mechanism, so adopting them means COPYING them. A copy drifts silently: the\n * project keeps building, the rules quietly diverge, and nobody finds out until two\n * repos disagree about how the same code should look.\n *\n * ⛔ IT REPORTS, IT DOES NOT REPAIR. A checker that rewrites a project's config would\n * overwrite a deliberate local exception, and this cannot tell the difference between\n * drift and a decision. It names what differs; a human decides which way to move.\n *\n * ⚠️ Every file it reads is parsed natively — `Bun.file().json()`, `Bun.TOML.parse`. No\n * parsing dependency reaches a consumer.\n * ⛔ NOT JSONC. Bun's `.json()` rejects comments (measured 2026-09-15), so a project\n * whose tsconfig carries them is reported as unreadable rather than silently skipped.\n */\n\n/** One thing a project should fix, in the imperative. */\nexport type Problem = string;\n\ntype Json = Record<string, unknown>;\n\n/**\n * ⚠️ A tsconfig LEGITIMATELY carries comments — that is where the reasoning for a strict\n * flag lives, and stripping them to satisfy a parser would be the wrong trade. Bun's\n * `.json()` rejects them (measured 2026-09-15), so comments are removed before parsing\n * rather than treated as a fault.\n * 🔴 A NAIVE REGEX CORRUPTS THE FILE IT IS READING. Measured while writing this: a\n * `(^|[^:\"])//.*$` strip mangled `\"./node_modules/oxlint/...\"` — the `//` inside a\n * STRING — turning valid JSON into a parse error reported as \"JSONC not supported\".\n * So this walks the text and tracks whether it is inside a string, which is the\n * smallest correct thing; a real JSONC parser would be a dependency every consumer\n * inherits for one check.\n */\nfunction stripComments(text: string): string {\n let out = '';\n let inString = false;\n let escaped = false;\n\n for (let i = 0; i < text.length; i += 1) {\n const ch = text[i] ?? '';\n const next = text[i + 1] ?? '';\n\n if (inString) {\n out += ch;\n if (escaped) escaped = false;\n else if (ch === '\\\\') escaped = true;\n else if (ch === '\"') inString = false;\n continue;\n }\n\n if (ch === '\"') {\n inString = true;\n out += ch;\n } else if (ch === '/' && next === '/') {\n while (i < text.length && text[i] !== '\\n') i += 1;\n out += '\\n';\n } else if (ch === '/' && next === '*') {\n i += 2;\n while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;\n i += 1;\n } else {\n out += ch;\n }\n }\n\n return out;\n}\n\nasync function readJson(path: string): Promise<Json | undefined> {\n const file = Bun.file(path);\n if (!(await file.exists())) return undefined;\n try {\n return JSON.parse(stripComments(await file.text())) as Json;\n } catch {\n return undefined;\n }\n}\n\n/** Where this package's own presets live, however it was installed. */\nfunction presetDir(): string {\n return new URL('../', import.meta.url).pathname;\n}\n\nasync function checkExtends(\n projectDir: string,\n file: string,\n needle: string,\n problems: Problem[],\n): Promise<void> {\n const config = await readJson(`${projectDir}/${file}`);\n if (config === undefined) {\n problems.push(`${file}: missing or unparseable (JSONC comments are not supported)`);\n return;\n }\n\n const extended = JSON.stringify(config['extends'] ?? '');\n\n // ⚠️ A WORKSPACE PATH COUNTS. Inside this repo the presets are referenced as\n // `./packages/config/...` rather than by package name, and reporting that as drift\n // would make the check fail in the one repo that defines the config.\n const workspacePath = needle.replace('@homeflare/config/', 'packages/config/');\n if (!extended.includes(needle) && !extended.includes(workspacePath)) {\n problems.push(`${file}: does not extend ${needle} — it currently extends ${extended}`);\n }\n}\n\n/**\n * Compare a copied file against the preset, ignoring formatting.\n * ⚠️ Structural comparison, not text: a project that reformats its copy has not drifted,\n * and reporting that would train people to ignore this check.\n */\nasync function checkCopy(\n projectDir: string,\n file: string,\n parse: (text: string) => unknown,\n problems: Problem[],\n): Promise<void> {\n const mine = Bun.file(`${projectDir}/${file}`);\n if (!(await mine.exists())) {\n problems.push(`${file}: missing — copy it from @homeflare/config`);\n return;\n }\n\n const preset = parse(await Bun.file(`${presetDir()}${file.replace(/^\\./, '')}`).text());\n const theirs = parse(await mine.text());\n\n if (JSON.stringify(theirs) !== JSON.stringify(preset)) {\n problems.push(`${file}: differs from @homeflare/config — re-copy it, or say why it differs`);\n }\n}\n\n/**\n * Check a project directory. Returns an empty array when everything is in step.\n *\n * const problems = await checkProject(process.cwd());\n * expect(problems).toEqual([]);\n */\nexport async function checkProject(projectDir: string): Promise<readonly Problem[]> {\n const problems: Problem[] = [];\n\n await checkExtends(projectDir, 'tsconfig.json', '@homeflare/config/tsconfig', problems);\n await checkExtends(projectDir, '.oxlintrc.json', '@homeflare/config/oxlintrc', problems);\n await checkCopy(projectDir, '.oxfmtrc.json', (t) => JSON.parse(t) as unknown, problems);\n await checkCopy(projectDir, 'bunfig.toml', (t) => Bun.TOML.parse(t), problems);\n\n return problems;\n}\n"
5
+ "/**\n * House oxfmt is a COPY — the format has no `extends`.\n *\n * ⛔ IDENTITY WAS THE WRONG GATE. Measured 2026-09-16: a Worker/TanStack app that\n * copied the preset verbatim would format vendor OpenAPI YAML, generated trees,\n * TanStack routeTree files and openapi.json, and rewrite 154 files to singleQuote.\n * Generated OpenAPI is an artefact; a quote flip there is a false diff, not style.\n *\n * ★ Extra `ignorePatterns` are therefore allowed. House style keys are not. Dropping\n * a house ignore is still drift — that is how generated files get rewritten.\n */\n\nexport type OxfmtConfig = {\n readonly printWidth?: number;\n readonly useTabs?: boolean;\n readonly singleQuote?: boolean;\n readonly semi?: boolean;\n readonly ignorePatterns?: readonly string[];\n readonly [key: string]: unknown;\n};\n\nconst STYLE_KEYS = ['printWidth', 'useTabs', 'singleQuote', 'semi'] as const;\n\n/**\n * Problems in a project's `.oxfmtrc.json` against the house preset.\n * Empty means the copy still carries house style, and at least the house ignores.\n */\nexport function problemsInOxfmt(preset: OxfmtConfig, theirs: OxfmtConfig): readonly string[] {\n const problems: string[] = [];\n\n for (const key of STYLE_KEYS) {\n if (theirs[key] !== preset[key]) {\n problems.push(\n `.oxfmtrc.json: ${key} differs from @homeflare/config — re-copy it, or say why it differs`,\n );\n }\n }\n\n const local = new Set(theirs.ignorePatterns ?? []);\n for (const pattern of preset.ignorePatterns ?? []) {\n if (!local.has(pattern)) {\n problems.push(\n `.oxfmtrc.json: missing house ignore ${pattern} — generated files would be rewritten`,\n );\n }\n }\n\n return problems;\n}\n",
6
+ "/**\n * Assert that a project still stands on the shared config.\n *\n * ★ WHY THIS EXISTS. Two of the five presets — `.oxfmtrc.json` and `bunfig.toml` — have no\n * `extends` mechanism, so adopting them means COPYING them. A copy drifts silently: the\n * project keeps building, the rules quietly diverge, and nobody finds out until two\n * repos disagree about how the same code should look.\n *\n * ⛔ IT REPORTS, IT DOES NOT REPAIR. A checker that rewrites a project's config would\n * overwrite a deliberate local exception, and this cannot tell the difference between\n * drift and a decision. It names what differs; a human decides which way to move.\n *\n * ⚠️ Every file it reads is parsed natively — `Bun.file().json()`, `Bun.TOML.parse`. No\n * parsing dependency reaches a consumer.\n * ⛔ NOT JSONC. Bun's `.json()` rejects comments (measured 2026-09-15), so a project\n * whose tsconfig carries them is reported as unreadable rather than silently skipped.\n */\nimport { type OxfmtConfig, problemsInOxfmt } from './oxfmt.ts';\n\n/** One thing a project should fix, in the imperative. */\nexport type Problem = string;\n\ntype Json = Record<string, unknown>;\n\n/**\n * ⚠️ A tsconfig LEGITIMATELY carries comments — that is where the reasoning for a strict\n * flag lives, and stripping them to satisfy a parser would be the wrong trade. Bun's\n * `.json()` rejects them (measured 2026-09-15), so comments are removed before parsing\n * rather than treated as a fault.\n * 🔴 A NAIVE REGEX CORRUPTS THE FILE IT IS READING. Measured while writing this: a\n * `(^|[^:\"])//.*$` strip mangled `\"./node_modules/oxlint/...\"` — the `//` inside a\n * STRING — turning valid JSON into a parse error reported as \"JSONC not supported\".\n * So this walks the text and tracks whether it is inside a string, which is the\n * smallest correct thing; a real JSONC parser would be a dependency every consumer\n * inherits for one check.\n */\nfunction stripComments(text: string): string {\n let out = '';\n let inString = false;\n let escaped = false;\n\n for (let i = 0; i < text.length; i += 1) {\n const ch = text[i] ?? '';\n const next = text[i + 1] ?? '';\n\n if (inString) {\n out += ch;\n if (escaped) escaped = false;\n else if (ch === '\\\\') escaped = true;\n else if (ch === '\"') inString = false;\n continue;\n }\n\n if (ch === '\"') {\n inString = true;\n out += ch;\n } else if (ch === '/' && next === '/') {\n while (i < text.length && text[i] !== '\\n') i += 1;\n out += '\\n';\n } else if (ch === '/' && next === '*') {\n i += 2;\n while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;\n i += 1;\n } else {\n out += ch;\n }\n }\n\n return out;\n}\n\nasync function readJson(path: string): Promise<Json | undefined> {\n const file = Bun.file(path);\n if (!(await file.exists())) return undefined;\n try {\n return JSON.parse(stripComments(await file.text())) as Json;\n } catch {\n return undefined;\n }\n}\n\n/** Where this package's own presets live, however it was installed. */\nfunction presetDir(): string {\n return new URL('../', import.meta.url).pathname;\n}\n\nasync function checkExtends(\n projectDir: string,\n file: string,\n needle: string,\n problems: Problem[],\n): Promise<void> {\n const config = await readJson(`${projectDir}/${file}`);\n if (config === undefined) {\n problems.push(`${file}: missing or unparseable (JSONC comments are not supported)`);\n return;\n }\n\n const extended = JSON.stringify(config['extends'] ?? '');\n\n // ⚠️ A WORKSPACE PATH COUNTS. Inside this repo the presets are referenced as\n // `./packages/config/...` rather than by package name, and reporting that as drift\n // would make the check fail in the one repo that defines the config.\n const workspacePath = needle.replace('@homeflare/config/', 'packages/config/');\n if (!extended.includes(needle) && !extended.includes(workspacePath)) {\n problems.push(`${file}: does not extend ${needle} — it currently extends ${extended}`);\n }\n}\n\n/**\n * Compare a copied file against the preset, ignoring formatting.\n * ⚠️ Structural comparison, not text: a project that reformats its copy has not drifted,\n * and reporting that would train people to ignore this check.\n */\nasync function checkCopy(\n projectDir: string,\n file: string,\n parse: (text: string) => unknown,\n problems: Problem[],\n): Promise<void> {\n const mine = Bun.file(`${projectDir}/${file}`);\n if (!(await mine.exists())) {\n problems.push(`${file}: missing — copy it from @homeflare/config`);\n return;\n }\n\n const preset = parse(await Bun.file(`${presetDir()}${file.replace(/^\\./, '')}`).text());\n const theirs = parse(await mine.text());\n\n if (JSON.stringify(theirs) !== JSON.stringify(preset)) {\n problems.push(`${file}: differs from @homeflare/config — re-copy it, or say why it differs`);\n }\n}\n\n/**\n * House oxfmt has no `extends`. Extra ignores are a merge; style keys are not.\n * ⛔ Identity comparison rewrote generated OpenAPI (measured 2026-09-16).\n */\nasync function checkOxfmt(projectDir: string, problems: Problem[]): Promise<void> {\n const mine = Bun.file(`${projectDir}/.oxfmtrc.json`);\n if (!(await mine.exists())) {\n problems.push('.oxfmtrc.json: missing — copy it from @homeflare/config');\n return;\n }\n\n let theirs: OxfmtConfig;\n try {\n theirs = JSON.parse(await mine.text()) as OxfmtConfig;\n } catch {\n problems.push('.oxfmtrc.json: unparseable');\n return;\n }\n\n const preset = JSON.parse(await Bun.file(`${presetDir()}oxfmtrc.json`).text()) as OxfmtConfig;\n problems.push(...problemsInOxfmt(preset, theirs));\n}\n\n/**\n * Check a project directory. Returns an empty array when everything is in step.\n *\n * const problems = await checkProject(process.cwd());\n * expect(problems).toEqual([]);\n */\nexport async function checkProject(projectDir: string): Promise<readonly Problem[]> {\n const problems: Problem[] = [];\n\n await checkExtends(projectDir, 'tsconfig.json', '@homeflare/config/tsconfig', problems);\n await checkExtends(projectDir, '.oxlintrc.json', '@homeflare/config/oxlintrc', problems);\n await checkOxfmt(projectDir, problems);\n await checkCopy(projectDir, 'bunfig.toml', (t) => Bun.TOML.parse(t), problems);\n\n return problems;\n}\n"
6
7
  ],
7
- "mappings": "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",
8
- "debugId": "7F6869C495728F5464756E2164756E21",
8
+ "mappings": "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",
9
+ "debugId": "550B779804E201AD64756E2164756E21",
9
10
  "names": []
10
11
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * House oxfmt is a COPY — the format has no `extends`.
3
+ *
4
+ * ⛔ IDENTITY WAS THE WRONG GATE. Measured 2026-09-16: a Worker/TanStack app that
5
+ * copied the preset verbatim would format vendor OpenAPI YAML, generated trees,
6
+ * TanStack routeTree files and openapi.json, and rewrite 154 files to singleQuote.
7
+ * Generated OpenAPI is an artefact; a quote flip there is a false diff, not style.
8
+ *
9
+ * ★ Extra `ignorePatterns` are therefore allowed. House style keys are not. Dropping
10
+ * a house ignore is still drift — that is how generated files get rewritten.
11
+ */
12
+ export type OxfmtConfig = {
13
+ readonly printWidth?: number;
14
+ readonly useTabs?: boolean;
15
+ readonly singleQuote?: boolean;
16
+ readonly semi?: boolean;
17
+ readonly ignorePatterns?: readonly string[];
18
+ readonly [key: string]: unknown;
19
+ };
20
+ /**
21
+ * Problems in a project's `.oxfmtrc.json` against the house preset.
22
+ * Empty means the copy still carries house style, and at least the house ignores.
23
+ */
24
+ export declare function problemsInOxfmt(preset: OxfmtConfig, theirs: OxfmtConfig): readonly string[];
25
+ //# sourceMappingURL=oxfmt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oxfmt.d.ts","sourceRoot":"","sources":["../src/oxfmt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5C,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC,CAAC;AAIF;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,SAAS,MAAM,EAAE,CAqB3F"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * No npm publish. Tag a GitHub Release — but ONLY when this version genuinely just
3
+ * came out of `changeset version`, never on an arbitrary changeset-less push to main.
4
+ *
5
+ * 🔴 MEASURED 2026-09-16/17 on homeflare-openbao, and documented upstream
6
+ * (changesets/action's own README, changesets/action#9): a custom publish-script runs
7
+ * on EVERY push to `main` that has zero pending changesets — not just the one push
8
+ * right after a Version Packages PR merges. The README says so directly: "a commit
9
+ * without any new changesets can always land on your base branch after a successful
10
+ * publish... you need to figure out on your own how to skip." Unguarded, this would
11
+ * tag `<name>@<version>` on every unrelated push to main once CHANGELOG.md exists.
12
+ *
13
+ * ★ THE CONDITION: proceed only if `CHANGELOG.md` has an entry for the current version
14
+ * (proof `changeset version` really ran and wrote it) AND no git tag `<name>@<version>`
15
+ * exists yet (proof this exact release was not already cut). Both checks are local —
16
+ * no network, no GitHub API — and both work from the checkout `release.yml` already
17
+ * has (`fetch-depth: 0`).
18
+ *
19
+ * ⛔ TELL changesets/action WHAT TO TAG. With a custom publish-script it learns what
20
+ * shipped only from `CHANGESETS_OUTPUT`. Without it, the action warns "GitHub releases
21
+ * and git tags cannot be created without this output" and creates neither.
22
+ */
23
+ type Pkg = {
24
+ readonly name: string;
25
+ readonly version: string;
26
+ };
27
+ /**
28
+ * Does CHANGELOG.md have a heading for this exact version?
29
+ * @changesets/changelog-github writes `## <version>` for a single, non-monorepo package.
30
+ */
31
+ export declare function changelogHasEntry(cwd: string, version: string): Promise<boolean>;
32
+ /** Does this exact tag already exist? Local `git tag -l`, no network. */
33
+ export declare function tagExists(cwd: string, tag: string): boolean;
34
+ export type ReleaseDecision = {
35
+ readonly ok: boolean;
36
+ readonly reason: string;
37
+ };
38
+ export declare function shouldRelease(cwd: string, pkg: Pkg): Promise<ReleaseDecision>;
39
+ /** One ndjson line changesets/action turns into a git tag and a GitHub Release. */
40
+ export declare function tagEvent(pkg: Pkg): {
41
+ type: 'git-tag';
42
+ tag: string;
43
+ packageName: string;
44
+ };
45
+ /**
46
+ * App-repo publish-script. Reads `package.json` at `cwd`, gates on `shouldRelease`,
47
+ * and writes the tag event to `$CHANGESETS_OUTPUT` when this version is new.
48
+ *
49
+ * ⛔ `cwd` is the APP, never this package. Defaulting from `import.meta.url` after
50
+ * publish would inspect `@homeflare/config` itself.
51
+ */
52
+ export declare function runAppRelease(cwd: string): Promise<ReleaseDecision>;
53
+ export {};
54
+ //# sourceMappingURL=release.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"release.d.ts","sourceRoot":"","sources":["../src/release.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,KAAK,GAAG,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/D;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMtF;AAED,yEAAyE;AACzE,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG3D;AAED,MAAM,MAAM,eAAe,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhF,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,eAAe,CAAC,CAanF;AAED,mFAAmF;AACnF,wBAAgB,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAExF;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAiBzE"}
@@ -0,0 +1,59 @@
1
+ // @bun
2
+ // src/release.ts
3
+ async function changelogHasEntry(cwd, version) {
4
+ const file = Bun.file(`${cwd}/CHANGELOG.md`);
5
+ if (!await file.exists())
6
+ return false;
7
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8
+ return new RegExp(`^## ${escaped}$`, "m").test(await file.text());
9
+ }
10
+ function tagExists(cwd, tag) {
11
+ const result = Bun.spawnSync(["git", "tag", "-l", tag], { cwd });
12
+ return result.stdout.toString().trim() === tag;
13
+ }
14
+ async function shouldRelease(cwd, pkg) {
15
+ const tag = `${pkg.name}@${pkg.version}`;
16
+ if (!await changelogHasEntry(cwd, pkg.version)) {
17
+ return {
18
+ ok: false,
19
+ reason: `CHANGELOG.md has no "## ${pkg.version}" entry \u2014 changeset version has not run for this version`
20
+ };
21
+ }
22
+ if (tagExists(cwd, tag)) {
23
+ return { ok: false, reason: `${tag} already exists \u2014 this version was already released` };
24
+ }
25
+ return { ok: true, reason: `CHANGELOG.md has the entry and ${tag} does not exist yet` };
26
+ }
27
+ function tagEvent(pkg) {
28
+ return { type: "git-tag", tag: `${pkg.name}@${pkg.version}`, packageName: pkg.name };
29
+ }
30
+ async function runAppRelease(cwd) {
31
+ const pkg = await Bun.file(`${cwd}/package.json`).json();
32
+ const decision = await shouldRelease(cwd, pkg);
33
+ if (!decision.ok) {
34
+ process.stdout.write(`skip: ${decision.reason}
35
+ `);
36
+ return decision;
37
+ }
38
+ const outputFile = process.env["CHANGESETS_OUTPUT"];
39
+ if (outputFile !== undefined) {
40
+ await Bun.write(outputFile, `${JSON.stringify(tagEvent(pkg))}
41
+ `);
42
+ }
43
+ process.stdout.write(`${pkg.name}@${pkg.version} \u2014 no npm publish; tagged (${decision.reason}).
44
+ `);
45
+ return decision;
46
+ }
47
+ if (import.meta.main) {
48
+ await runAppRelease(process.cwd());
49
+ }
50
+ export {
51
+ changelogHasEntry,
52
+ runAppRelease,
53
+ shouldRelease,
54
+ tagEvent,
55
+ tagExists
56
+ };
57
+
58
+ //# debugId=BB062728D267ADD364756E2164756E21
59
+ //# sourceMappingURL=release.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/release.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * No npm publish. Tag a GitHub Release — but ONLY when this version genuinely just\n * came out of `changeset version`, never on an arbitrary changeset-less push to main.\n *\n * 🔴 MEASURED 2026-09-16/17 on homeflare-openbao, and documented upstream\n * (changesets/action's own README, changesets/action#9): a custom publish-script runs\n * on EVERY push to `main` that has zero pending changesets — not just the one push\n * right after a Version Packages PR merges. The README says so directly: \"a commit\n * without any new changesets can always land on your base branch after a successful\n * publish... you need to figure out on your own how to skip.\" Unguarded, this would\n * tag `<name>@<version>` on every unrelated push to main once CHANGELOG.md exists.\n *\n * ★ THE CONDITION: proceed only if `CHANGELOG.md` has an entry for the current version\n * (proof `changeset version` really ran and wrote it) AND no git tag `<name>@<version>`\n * exists yet (proof this exact release was not already cut). Both checks are local —\n * no network, no GitHub API — and both work from the checkout `release.yml` already\n * has (`fetch-depth: 0`).\n *\n * ⛔ TELL changesets/action WHAT TO TAG. With a custom publish-script it learns what\n * shipped only from `CHANGESETS_OUTPUT`. Without it, the action warns \"GitHub releases\n * and git tags cannot be created without this output\" and creates neither.\n */\ntype Pkg = { readonly name: string; readonly version: string };\n\n/**\n * Does CHANGELOG.md have a heading for this exact version?\n * @changesets/changelog-github writes `## <version>` for a single, non-monorepo package.\n */\nexport async function changelogHasEntry(cwd: string, version: string): Promise<boolean> {\n const file = Bun.file(`${cwd}/CHANGELOG.md`);\n if (!(await file.exists())) return false;\n\n const escaped = version.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n return new RegExp(`^## ${escaped}$`, 'm').test(await file.text());\n}\n\n/** Does this exact tag already exist? Local `git tag -l`, no network. */\nexport function tagExists(cwd: string, tag: string): boolean {\n const result = Bun.spawnSync(['git', 'tag', '-l', tag], { cwd });\n return result.stdout.toString().trim() === tag;\n}\n\nexport type ReleaseDecision = { readonly ok: boolean; readonly reason: string };\n\nexport async function shouldRelease(cwd: string, pkg: Pkg): Promise<ReleaseDecision> {\n const tag = `${pkg.name}@${pkg.version}`;\n\n if (!(await changelogHasEntry(cwd, pkg.version))) {\n return {\n ok: false,\n reason: `CHANGELOG.md has no \"## ${pkg.version}\" entry — changeset version has not run for this version`,\n };\n }\n if (tagExists(cwd, tag)) {\n return { ok: false, reason: `${tag} already exists — this version was already released` };\n }\n return { ok: true, reason: `CHANGELOG.md has the entry and ${tag} does not exist yet` };\n}\n\n/** One ndjson line changesets/action turns into a git tag and a GitHub Release. */\nexport function tagEvent(pkg: Pkg): { type: 'git-tag'; tag: string; packageName: string } {\n return { type: 'git-tag', tag: `${pkg.name}@${pkg.version}`, packageName: pkg.name };\n}\n\n/**\n * App-repo publish-script. Reads `package.json` at `cwd`, gates on `shouldRelease`,\n * and writes the tag event to `$CHANGESETS_OUTPUT` when this version is new.\n *\n * ⛔ `cwd` is the APP, never this package. Defaulting from `import.meta.url` after\n * publish would inspect `@homeflare/config` itself.\n */\nexport async function runAppRelease(cwd: string): Promise<ReleaseDecision> {\n const pkg = (await Bun.file(`${cwd}/package.json`).json()) as Pkg;\n const decision = await shouldRelease(cwd, pkg);\n\n if (!decision.ok) {\n process.stdout.write(`skip: ${decision.reason}\\n`);\n return decision;\n }\n\n const outputFile = process.env['CHANGESETS_OUTPUT'];\n if (outputFile !== undefined) {\n await Bun.write(outputFile, `${JSON.stringify(tagEvent(pkg))}\\n`);\n }\n process.stdout.write(\n `${pkg.name}@${pkg.version} — no npm publish; tagged (${decision.reason}).\\n`,\n );\n return decision;\n}\n\nif (import.meta.main) {\n await runAppRelease(process.cwd());\n}\n"
6
+ ],
7
+ "mappings": ";;AA4BA,eAAsB,iBAAiB,CAAC,KAAa,SAAmC;AAAA,EACtF,MAAM,OAAO,IAAI,KAAK,GAAG,kBAAkB;AAAA,EAC3C,IAAI,CAAE,MAAM,KAAK,OAAO;AAAA,IAAI,OAAO;AAAA,EAEnC,MAAM,UAAU,QAAQ,QAAQ,uBAAuB,MAAM;AAAA,EAC7D,OAAO,IAAI,OAAO,OAAO,YAAY,GAAG,EAAE,KAAK,MAAM,KAAK,KAAK,CAAC;AAAA;AAI3D,SAAS,SAAS,CAAC,KAAa,KAAsB;AAAA,EAC3D,MAAM,SAAS,IAAI,UAAU,CAAC,OAAO,OAAO,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC;AAAA,EAC/D,OAAO,OAAO,OAAO,SAAS,EAAE,KAAK,MAAM;AAAA;AAK7C,eAAsB,aAAa,CAAC,KAAa,KAAoC;AAAA,EACnF,MAAM,MAAM,GAAG,IAAI,QAAQ,IAAI;AAAA,EAE/B,IAAI,CAAE,MAAM,kBAAkB,KAAK,IAAI,OAAO,GAAI;AAAA,IAChD,OAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ,2BAA2B,IAAI;AAAA,IACzC;AAAA,EACF;AAAA,EACA,IAAI,UAAU,KAAK,GAAG,GAAG;AAAA,IACvB,OAAO,EAAE,IAAI,OAAO,QAAQ,GAAG,8DAAyD;AAAA,EAC1F;AAAA,EACA,OAAO,EAAE,IAAI,MAAM,QAAQ,kCAAkC,yBAAyB;AAAA;AAIjF,SAAS,QAAQ,CAAC,KAAiE;AAAA,EACxF,OAAO,EAAE,MAAM,WAAW,KAAK,GAAG,IAAI,QAAQ,IAAI,WAAW,aAAa,IAAI,KAAK;AAAA;AAUrF,eAAsB,aAAa,CAAC,KAAuC;AAAA,EACzE,MAAM,MAAO,MAAM,IAAI,KAAK,GAAG,kBAAkB,EAAE,KAAK;AAAA,EACxD,MAAM,WAAW,MAAM,cAAc,KAAK,GAAG;AAAA,EAE7C,IAAI,CAAC,SAAS,IAAI;AAAA,IAChB,QAAQ,OAAO,MAAM,SAAS,SAAS;AAAA,CAAU;AAAA,IACjD,OAAO;AAAA,EACT;AAAA,EAEA,MAAM,aAAa,QAAQ,IAAI;AAAA,EAC/B,IAAI,eAAe,WAAW;AAAA,IAC5B,MAAM,IAAI,MAAM,YAAY,GAAG,KAAK,UAAU,SAAS,GAAG,CAAC;AAAA,CAAK;AAAA,EAClE;AAAA,EACA,QAAQ,OAAO,MACb,GAAG,IAAI,QAAQ,IAAI,0CAAqC,SAAS;AAAA,CACnE;AAAA,EACA,OAAO;AAAA;AAGT,IAAI,kBAAkB;AAAA,EACpB,MAAM,cAAc,QAAQ,IAAI,CAAC;AACnC;",
8
+ "debugId": "BB062728D267ADD364756E2164756E21",
9
+ "names": []
10
+ }
@@ -0,0 +1,11 @@
1
+ export type ReleaseConfigPaths = {
2
+ readonly pkgPath: string;
3
+ readonly changesetConfigPath: string;
4
+ };
5
+ /** Resolve the two files a version command must see. Override in tests. */
6
+ export declare function releaseConfigPaths(cwd?: string): ReleaseConfigPaths;
7
+ /** One thing a project should fix, in the imperative. */
8
+ export declare function problemsInReleaseConfig(paths: ReleaseConfigPaths): Promise<string[]>;
9
+ /** Print and exit. App `version` scripts call this before `changeset version`. */
10
+ export declare function runRequireReleaseConfig(paths?: Partial<ReleaseConfigPaths>): Promise<void>;
11
+ //# sourceMappingURL=require-release-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-release-config.d.ts","sourceRoot":"","sources":["../src/require-release-config.ts"],"names":[],"mappings":"AA8BA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;CACtC,CAAC;AAEF,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,GAAG,GAAE,MAAsB,GAAG,kBAAkB,CAKlF;AAED,yDAAyD;AACzD,wBAAsB,uBAAuB,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsB1F;AAED,kFAAkF;AAClF,wBAAsB,uBAAuB,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBhG"}
@@ -0,0 +1,127 @@
1
+ // @bun
2
+ // src/changeset-workspace.ts
3
+ import { readdir } from "fs/promises";
4
+ function rootName(pkg) {
5
+ return typeof pkg.name === "string" ? [pkg.name] : [];
6
+ }
7
+ async function workspacePackageNames(cwd) {
8
+ const pnpm = Bun.file(`${cwd}/pnpm-workspace.yaml`);
9
+ if (await pnpm.exists()) {
10
+ const parsed = Bun.YAML.parse(await pnpm.text());
11
+ return namesFromPatterns(cwd, parsed.packages ?? []);
12
+ }
13
+ const pkg = await Bun.file(`${cwd}/package.json`).json();
14
+ if (pkg.workspaces !== undefined && pkg.workspaces.length > 0) {
15
+ return namesFromPatterns(cwd, pkg.workspaces);
16
+ }
17
+ return rootName(pkg);
18
+ }
19
+ async function namesFromPatterns(cwd, patterns) {
20
+ const names = [];
21
+ for (const pattern of patterns) {
22
+ if (pattern.includes("*"))
23
+ continue;
24
+ const pkgPath = pattern === "." ? `${cwd}/package.json` : `${cwd}/${pattern}/package.json`;
25
+ const file = Bun.file(pkgPath);
26
+ if (!await file.exists())
27
+ continue;
28
+ const name = (await file.json()).name;
29
+ if (typeof name === "string")
30
+ names.push(name);
31
+ }
32
+ return names;
33
+ }
34
+ function packagesNamedInChangeset(text) {
35
+ const match = /^---\n([\s\S]*?)\n---/m.exec(text);
36
+ if (match?.[1] === undefined)
37
+ return [];
38
+ const parsed = Bun.YAML.parse(match[1]);
39
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
40
+ return [];
41
+ return Object.keys(parsed);
42
+ }
43
+ async function problemsFromPendingChangesets(cwd) {
44
+ let files;
45
+ try {
46
+ files = (await readdir(`${cwd}/.changeset`)).filter((name) => name.endsWith(".md") && name !== "README.md");
47
+ } catch {
48
+ return [];
49
+ }
50
+ if (files.length === 0)
51
+ return [];
52
+ const workspace = new Set(await workspacePackageNames(cwd));
53
+ const problems = [];
54
+ for (const name of files) {
55
+ const rel = `.changeset/${name}`;
56
+ for (const pkg of packagesNamedInChangeset(await Bun.file(`${cwd}/${rel}`).text())) {
57
+ if (!workspace.has(pkg)) {
58
+ problems.push(`${rel} names "${pkg}" which is not in the workspace \u2014 changeset version ` + "exits 1 (measured 2026-09-17: leftover pnpm-workspace.yaml listed nested " + "packages and hid the root)");
59
+ }
60
+ }
61
+ }
62
+ return problems;
63
+ }
64
+
65
+ // src/require-release-config.ts
66
+ function releaseConfigPaths(cwd = process.cwd()) {
67
+ return {
68
+ pkgPath: `${cwd}/package.json`,
69
+ changesetConfigPath: `${cwd}/.changeset/config.json`
70
+ };
71
+ }
72
+ async function problemsInReleaseConfig(paths) {
73
+ const pkg = await Bun.file(paths.pkgPath).json();
74
+ const problems = [];
75
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) {
76
+ problems.push(`${paths.pkgPath} has no "version" \u2014 changeset version would silently no-op`);
77
+ }
78
+ if (pkg.private === true) {
79
+ const changesetConfig = await Bun.file(paths.changesetConfigPath).json();
80
+ if (changesetConfig.privatePackages?.version !== true) {
81
+ problems.push(`${paths.changesetConfigPath} has no privatePackages.version: true \u2014 a private package ` + 'is excluded from versioning entirely, which reads as "nothing to release"');
82
+ }
83
+ }
84
+ const cwd = paths.pkgPath.replace(/\/package\.json$/, "");
85
+ problems.push(...await problemsFromPendingChangesets(cwd));
86
+ return problems;
87
+ }
88
+ async function runRequireReleaseConfig(paths) {
89
+ const defaults = releaseConfigPaths();
90
+ const resolved = {
91
+ pkgPath: paths?.pkgPath ?? defaults.pkgPath,
92
+ changesetConfigPath: paths?.changesetConfigPath ?? defaults.changesetConfigPath
93
+ };
94
+ const problems = await problemsInReleaseConfig(resolved);
95
+ if (problems.length > 0) {
96
+ process.stderr.write(`
97
+ \u2717 changeset version would silently no-op:
98
+ `);
99
+ for (const problem of problems)
100
+ process.stderr.write(` - ${problem}
101
+ `);
102
+ process.stderr.write(`
103
+ Fix the file(s) above in a reviewed commit, then re-run this.
104
+
105
+ `);
106
+ process.exit(1);
107
+ }
108
+ const pkg = await Bun.file(resolved.pkgPath).json();
109
+ process.stdout.write(`ok: ${resolved.pkgPath} version is ${pkg.version}, privatePackages.version is set
110
+ `);
111
+ }
112
+ if (import.meta.main) {
113
+ const pkgPath = process.argv[2];
114
+ const changesetConfigPath = process.argv[3];
115
+ await runRequireReleaseConfig({
116
+ ...pkgPath === undefined ? {} : { pkgPath },
117
+ ...changesetConfigPath === undefined ? {} : { changesetConfigPath }
118
+ });
119
+ }
120
+ export {
121
+ problemsInReleaseConfig,
122
+ releaseConfigPaths,
123
+ runRequireReleaseConfig
124
+ };
125
+
126
+ //# debugId=5B77C8600B7D3E7B64756E2164756E21
127
+ //# sourceMappingURL=require-release-config.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/changeset-workspace.ts", "../src/require-release-config.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Pending changesets must name a package Changesets actually considers a workspace\n * member. Otherwise `changeset version` exits 1 and no Version Packages PR opens.\n *\n * 🔴 MEASURED 2026-09-17 on homeflare-secrets. A leftover `pnpm-workspace.yaml`\n * listed only `hfs` and `secret-vault`. `@changesets/cli` treats that file as the\n * workspace, so the root package (`homeflare-secrets` — the GitHub Release) was\n * \"not in the workspace\". The hygiene changeset named the root. Release on main\n * failed; no Version PR, no tag.\n *\n * ★ Root is a member only when the workspace file says so (`.` or a package.json\n * `workspaces` entry). Nested leftover packages keep their own package.json for\n * their own tools; they are not this repo's release.\n */\nimport { readdir } from 'node:fs/promises';\n\ntype WorkspaceFile = { readonly packages?: readonly string[] };\ntype RootPkg = {\n readonly name?: unknown;\n readonly workspaces?: readonly string[];\n};\n\nfunction rootName(pkg: RootPkg): readonly string[] {\n return typeof pkg.name === 'string' ? [pkg.name] : [];\n}\n\nexport async function workspacePackageNames(cwd: string): Promise<readonly string[]> {\n const pnpm = Bun.file(`${cwd}/pnpm-workspace.yaml`);\n if (await pnpm.exists()) {\n const parsed = Bun.YAML.parse(await pnpm.text()) as WorkspaceFile;\n return namesFromPatterns(cwd, parsed.packages ?? []);\n }\n\n const pkg = (await Bun.file(`${cwd}/package.json`).json()) as RootPkg;\n // ★ Array form only. The yarn `{ packages: [] }` object is unused in this estate,\n // and `Array.isArray` does not narrow that union under exactOptionalPropertyTypes.\n if (pkg.workspaces !== undefined && pkg.workspaces.length > 0) {\n return namesFromPatterns(cwd, pkg.workspaces);\n }\n return rootName(pkg);\n}\n\nasync function namesFromPatterns(cwd: string, patterns: readonly string[]): Promise<string[]> {\n const names: string[] = [];\n for (const pattern of patterns) {\n // ⛔ Full glob support is not the job. Changesets already expands globs; this\n // guard only needs `.` and literal directory members — the case that hid\n // the root. A `*` pattern is skipped rather than half-parsed.\n if (pattern.includes('*')) continue;\n const pkgPath = pattern === '.' ? `${cwd}/package.json` : `${cwd}/${pattern}/package.json`;\n const file = Bun.file(pkgPath);\n if (!(await file.exists())) continue;\n const name = ((await file.json()) as { readonly name?: unknown }).name;\n if (typeof name === 'string') names.push(name);\n }\n return names;\n}\n\n/** Package names in a changeset file's YAML frontmatter. */\nexport function packagesNamedInChangeset(text: string): readonly string[] {\n const match = /^---\\n([\\s\\S]*?)\\n---/m.exec(text);\n if (match?.[1] === undefined) return [];\n const parsed = Bun.YAML.parse(match[1]);\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return [];\n return Object.keys(parsed);\n}\n\nexport async function problemsFromPendingChangesets(cwd: string): Promise<readonly string[]> {\n let files: string[];\n try {\n files = (await readdir(`${cwd}/.changeset`)).filter(\n (name) => name.endsWith('.md') && name !== 'README.md',\n );\n } catch {\n // Missing `.changeset` is not a fault — nothing pending.\n return [];\n }\n if (files.length === 0) return [];\n\n const workspace = new Set(await workspacePackageNames(cwd));\n const problems: string[] = [];\n for (const name of files) {\n const rel = `.changeset/${name}`;\n for (const pkg of packagesNamedInChangeset(await Bun.file(`${cwd}/${rel}`).text())) {\n if (!workspace.has(pkg)) {\n problems.push(\n `${rel} names \"${pkg}\" which is not in the workspace — changeset version ` +\n 'exits 1 (measured 2026-09-17: leftover pnpm-workspace.yaml listed nested ' +\n 'packages and hid the root)',\n );\n }\n }\n }\n return problems;\n}\n",
6
+ "/**\n * Refuse to let `changeset version` run against a config that would silently no-op.\n *\n * 🔴 MEASURED 2026-09-16 on homeflare-openbao, TWO INDEPENDENT WAYS TO GET THE SAME\n * SILENT FAILURE. `@changesets/cli` 3.0.3 does neither of these things loudly:\n * 1. `package.json` with no \"version\" — `changeset version` consumes the pending\n * changeset file, writes no `CHANGELOG.md`, leaves `package.json` untouched.\n * 2. `\"private\": true` with no `privatePackages.version: true` in\n * `.changeset/config.json` — the package is excluded from versioning entirely;\n * `changeset status` reports zero packages to bump even with a real version and a\n * pending changeset file.\n * Either gap alone reproduces the same no-op. Both are required together.\n *\n * ★ This is regression protection against either field being dropped — a bad merge, a\n * hand-edit, a generator overwriting either file — not a bootstrap blocker.\n *\n * 🔴 A leftover `pnpm-workspace.yaml` that lists only nested packages hides the\n * root. `changeset version` then exits 1 (\"package X is not in the workspace\") —\n * measured 2026-09-17 on homeflare-secrets. `problemsFromPendingChangesets`\n * fails that before the version command consumes the file.\n *\n * ⛔ Path defaults are `process.cwd()`, NEVER `import.meta.url`. After publish this\n * file lives in `node_modules/@homeflare/config`; resolving next to it would check\n * the published package, not the app that invoked it.\n */\nimport { problemsFromPendingChangesets } from './changeset-workspace.ts';\n\ntype Pkg = { readonly version?: unknown; readonly private?: unknown };\ntype ChangesetConfig = { readonly privatePackages?: { readonly version?: unknown } };\n\nexport type ReleaseConfigPaths = {\n readonly pkgPath: string;\n readonly changesetConfigPath: string;\n};\n\n/** Resolve the two files a version command must see. Override in tests. */\nexport function releaseConfigPaths(cwd: string = process.cwd()): ReleaseConfigPaths {\n return {\n pkgPath: `${cwd}/package.json`,\n changesetConfigPath: `${cwd}/.changeset/config.json`,\n };\n}\n\n/** One thing a project should fix, in the imperative. */\nexport async function problemsInReleaseConfig(paths: ReleaseConfigPaths): Promise<string[]> {\n const pkg = (await Bun.file(paths.pkgPath).json()) as Pkg;\n const problems: string[] = [];\n\n if (typeof pkg.version !== 'string' || pkg.version.length === 0) {\n problems.push(`${paths.pkgPath} has no \"version\" — changeset version would silently no-op`);\n }\n\n if (pkg.private === true) {\n const changesetConfig = (await Bun.file(paths.changesetConfigPath).json()) as ChangesetConfig;\n if (changesetConfig.privatePackages?.version !== true) {\n problems.push(\n `${paths.changesetConfigPath} has no privatePackages.version: true — a private package ` +\n 'is excluded from versioning entirely, which reads as \"nothing to release\"',\n );\n }\n }\n\n const cwd = paths.pkgPath.replace(/\\/package\\.json$/, '');\n problems.push(...(await problemsFromPendingChangesets(cwd)));\n\n return problems;\n}\n\n/** Print and exit. App `version` scripts call this before `changeset version`. */\nexport async function runRequireReleaseConfig(paths?: Partial<ReleaseConfigPaths>): Promise<void> {\n // ⚠️ A spread of `{ pkgPath: undefined }` from `process.argv[2]` would overwrite the\n // cwd default. Only copy keys that are actually present.\n const defaults = releaseConfigPaths();\n const resolved: ReleaseConfigPaths = {\n pkgPath: paths?.pkgPath ?? defaults.pkgPath,\n changesetConfigPath: paths?.changesetConfigPath ?? defaults.changesetConfigPath,\n };\n const problems = await problemsInReleaseConfig(resolved);\n\n if (problems.length > 0) {\n process.stderr.write('\\n✗ changeset version would silently no-op:\\n');\n for (const problem of problems) process.stderr.write(` - ${problem}\\n`);\n process.stderr.write('\\n Fix the file(s) above in a reviewed commit, then re-run this.\\n\\n');\n process.exit(1);\n }\n\n const pkg = (await Bun.file(resolved.pkgPath).json()) as Pkg;\n process.stdout.write(\n `ok: ${resolved.pkgPath} version is ${pkg.version}, privatePackages.version is set\\n`,\n );\n}\n\nif (import.meta.main) {\n const pkgPath = process.argv[2];\n const changesetConfigPath = process.argv[3];\n await runRequireReleaseConfig({\n ...(pkgPath === undefined ? {} : { pkgPath }),\n ...(changesetConfigPath === undefined ? {} : { changesetConfigPath }),\n });\n}\n"
7
+ ],
8
+ "mappings": "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",
9
+ "debugId": "5B77C8600B7D3E7B64756E2164756E21",
10
+ "names": []
11
+ }
package/oxfmtrc.json CHANGED
@@ -3,5 +3,17 @@
3
3
  "useTabs": false,
4
4
  "singleQuote": true,
5
5
  "semi": true,
6
- "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/coverage/**"]
6
+ "ignorePatterns": [
7
+ "**/node_modules/**",
8
+ "**/dist/**",
9
+ "**/coverage/**",
10
+ "**/generated/**",
11
+ "**/*.gen.ts",
12
+ "**/vendor/**",
13
+ "**/*.openapi.yaml",
14
+ "**/*.openapi.yml",
15
+ "**/openapi.json",
16
+ "**/.wrangler/**",
17
+ "**/worker-configuration.d.ts"
18
+ ]
7
19
  }
@@ -0,0 +1,32 @@
1
+ {
2
+ "extends": ["./oxlintrc.json"],
3
+ "ignorePatterns": [
4
+ "dist/**",
5
+ "coverage/**",
6
+ "**/generated/**",
7
+ "**/*.gen.ts",
8
+ "**/vendor/**",
9
+ "**/*.openapi.yaml",
10
+ "**/*.openapi.yml",
11
+ "**/openapi.json"
12
+ ],
13
+ "rules": {
14
+ "import/no-default-export": "off",
15
+ "jsx-a11y/control-has-associated-label": "off"
16
+ },
17
+ "overrides": [
18
+ {
19
+ "files": ["src/**", "workers/**", "app/**"],
20
+ "rules": {
21
+ "no-console": "off"
22
+ }
23
+ },
24
+ {
25
+ "files": ["**/tests/**", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/scripts/**"],
26
+ "rules": {
27
+ "no-console": "off",
28
+ "typescript/no-non-null-assertion": "off"
29
+ }
30
+ }
31
+ ]
32
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homeflare/config",
3
- "version": "0.3.0",
4
- "description": "Shared tsconfig, oxlint and oxfmt configuration for HomeFlare projects.",
3
+ "version": "0.5.0",
4
+ "description": "Shared tsconfig, oxlint, oxfmt, and non-npm release helpers for HomeFlare projects.",
5
5
  "license": "MIT",
6
6
  "author": "Timothy Schneider",
7
7
  "repository": {
@@ -16,6 +16,7 @@
16
16
  "tsconfig.lib.json",
17
17
  "tsconfig.app.json",
18
18
  "oxlintrc.json",
19
+ "oxlintrc.app.json",
19
20
  "oxfmtrc.json",
20
21
  "bunfig.toml",
21
22
  "README.md",
@@ -27,12 +28,21 @@
27
28
  "./tsconfig.lib.json": "./tsconfig.lib.json",
28
29
  "./tsconfig.app.json": "./tsconfig.app.json",
29
30
  "./oxlintrc.json": "./oxlintrc.json",
31
+ "./oxlintrc.app.json": "./oxlintrc.app.json",
30
32
  "./oxfmtrc.json": "./oxfmtrc.json",
31
33
  "./bunfig.toml": "./bunfig.toml",
32
34
  "./check": {
33
35
  "types": "./dist/check.d.ts",
34
36
  "default": "./dist/check.js"
35
37
  },
38
+ "./release": {
39
+ "types": "./dist/release.d.ts",
40
+ "default": "./dist/release.js"
41
+ },
42
+ "./require-release-config": {
43
+ "types": "./dist/require-release-config.d.ts",
44
+ "default": "./dist/require-release-config.js"
45
+ },
36
46
  "./package.json": "./package.json"
37
47
  },
38
48
  "publishConfig": {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Pending changesets must name a package Changesets actually considers a workspace
3
+ * member. Otherwise `changeset version` exits 1 and no Version Packages PR opens.
4
+ *
5
+ * 🔴 MEASURED 2026-09-17 on homeflare-secrets. A leftover `pnpm-workspace.yaml`
6
+ * listed only `hfs` and `secret-vault`. `@changesets/cli` treats that file as the
7
+ * workspace, so the root package (`homeflare-secrets` — the GitHub Release) was
8
+ * "not in the workspace". The hygiene changeset named the root. Release on main
9
+ * failed; no Version PR, no tag.
10
+ *
11
+ * ★ Root is a member only when the workspace file says so (`.` or a package.json
12
+ * `workspaces` entry). Nested leftover packages keep their own package.json for
13
+ * their own tools; they are not this repo's release.
14
+ */
15
+ import { readdir } from 'node:fs/promises';
16
+
17
+ type WorkspaceFile = { readonly packages?: readonly string[] };
18
+ type RootPkg = {
19
+ readonly name?: unknown;
20
+ readonly workspaces?: readonly string[];
21
+ };
22
+
23
+ function rootName(pkg: RootPkg): readonly string[] {
24
+ return typeof pkg.name === 'string' ? [pkg.name] : [];
25
+ }
26
+
27
+ export async function workspacePackageNames(cwd: string): Promise<readonly string[]> {
28
+ const pnpm = Bun.file(`${cwd}/pnpm-workspace.yaml`);
29
+ if (await pnpm.exists()) {
30
+ const parsed = Bun.YAML.parse(await pnpm.text()) as WorkspaceFile;
31
+ return namesFromPatterns(cwd, parsed.packages ?? []);
32
+ }
33
+
34
+ const pkg = (await Bun.file(`${cwd}/package.json`).json()) as RootPkg;
35
+ // ★ Array form only. The yarn `{ packages: [] }` object is unused in this estate,
36
+ // and `Array.isArray` does not narrow that union under exactOptionalPropertyTypes.
37
+ if (pkg.workspaces !== undefined && pkg.workspaces.length > 0) {
38
+ return namesFromPatterns(cwd, pkg.workspaces);
39
+ }
40
+ return rootName(pkg);
41
+ }
42
+
43
+ async function namesFromPatterns(cwd: string, patterns: readonly string[]): Promise<string[]> {
44
+ const names: string[] = [];
45
+ for (const pattern of patterns) {
46
+ // ⛔ Full glob support is not the job. Changesets already expands globs; this
47
+ // guard only needs `.` and literal directory members — the case that hid
48
+ // the root. A `*` pattern is skipped rather than half-parsed.
49
+ if (pattern.includes('*')) continue;
50
+ const pkgPath = pattern === '.' ? `${cwd}/package.json` : `${cwd}/${pattern}/package.json`;
51
+ const file = Bun.file(pkgPath);
52
+ if (!(await file.exists())) continue;
53
+ const name = ((await file.json()) as { readonly name?: unknown }).name;
54
+ if (typeof name === 'string') names.push(name);
55
+ }
56
+ return names;
57
+ }
58
+
59
+ /** Package names in a changeset file's YAML frontmatter. */
60
+ export function packagesNamedInChangeset(text: string): readonly string[] {
61
+ const match = /^---\n([\s\S]*?)\n---/m.exec(text);
62
+ if (match?.[1] === undefined) return [];
63
+ const parsed = Bun.YAML.parse(match[1]);
64
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return [];
65
+ return Object.keys(parsed);
66
+ }
67
+
68
+ export async function problemsFromPendingChangesets(cwd: string): Promise<readonly string[]> {
69
+ let files: string[];
70
+ try {
71
+ files = (await readdir(`${cwd}/.changeset`)).filter(
72
+ (name) => name.endsWith('.md') && name !== 'README.md',
73
+ );
74
+ } catch {
75
+ // Missing `.changeset` is not a fault — nothing pending.
76
+ return [];
77
+ }
78
+ if (files.length === 0) return [];
79
+
80
+ const workspace = new Set(await workspacePackageNames(cwd));
81
+ const problems: string[] = [];
82
+ for (const name of files) {
83
+ const rel = `.changeset/${name}`;
84
+ for (const pkg of packagesNamedInChangeset(await Bun.file(`${cwd}/${rel}`).text())) {
85
+ if (!workspace.has(pkg)) {
86
+ problems.push(
87
+ `${rel} names "${pkg}" which is not in the workspace — changeset version ` +
88
+ 'exits 1 (measured 2026-09-17: leftover pnpm-workspace.yaml listed nested ' +
89
+ 'packages and hid the root)',
90
+ );
91
+ }
92
+ }
93
+ }
94
+ return problems;
95
+ }
package/src/check.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * ⛔ NOT JSONC. Bun's `.json()` rejects comments (measured 2026-09-15), so a project
16
16
  * whose tsconfig carries them is reported as unreadable rather than silently skipped.
17
17
  */
18
+ import { type OxfmtConfig, problemsInOxfmt } from './oxfmt.ts';
18
19
 
19
20
  /** One thing a project should fix, in the imperative. */
20
21
  export type Problem = string;
@@ -131,6 +132,29 @@ async function checkCopy(
131
132
  }
132
133
  }
133
134
 
135
+ /**
136
+ * House oxfmt has no `extends`. Extra ignores are a merge; style keys are not.
137
+ * ⛔ Identity comparison rewrote generated OpenAPI (measured 2026-09-16).
138
+ */
139
+ async function checkOxfmt(projectDir: string, problems: Problem[]): Promise<void> {
140
+ const mine = Bun.file(`${projectDir}/.oxfmtrc.json`);
141
+ if (!(await mine.exists())) {
142
+ problems.push('.oxfmtrc.json: missing — copy it from @homeflare/config');
143
+ return;
144
+ }
145
+
146
+ let theirs: OxfmtConfig;
147
+ try {
148
+ theirs = JSON.parse(await mine.text()) as OxfmtConfig;
149
+ } catch {
150
+ problems.push('.oxfmtrc.json: unparseable');
151
+ return;
152
+ }
153
+
154
+ const preset = JSON.parse(await Bun.file(`${presetDir()}oxfmtrc.json`).text()) as OxfmtConfig;
155
+ problems.push(...problemsInOxfmt(preset, theirs));
156
+ }
157
+
134
158
  /**
135
159
  * Check a project directory. Returns an empty array when everything is in step.
136
160
  *
@@ -142,7 +166,7 @@ export async function checkProject(projectDir: string): Promise<readonly Problem
142
166
 
143
167
  await checkExtends(projectDir, 'tsconfig.json', '@homeflare/config/tsconfig', problems);
144
168
  await checkExtends(projectDir, '.oxlintrc.json', '@homeflare/config/oxlintrc', problems);
145
- await checkCopy(projectDir, '.oxfmtrc.json', (t) => JSON.parse(t) as unknown, problems);
169
+ await checkOxfmt(projectDir, problems);
146
170
  await checkCopy(projectDir, 'bunfig.toml', (t) => Bun.TOML.parse(t), problems);
147
171
 
148
172
  return problems;
package/src/oxfmt.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * House oxfmt is a COPY — the format has no `extends`.
3
+ *
4
+ * ⛔ IDENTITY WAS THE WRONG GATE. Measured 2026-09-16: a Worker/TanStack app that
5
+ * copied the preset verbatim would format vendor OpenAPI YAML, generated trees,
6
+ * TanStack routeTree files and openapi.json, and rewrite 154 files to singleQuote.
7
+ * Generated OpenAPI is an artefact; a quote flip there is a false diff, not style.
8
+ *
9
+ * ★ Extra `ignorePatterns` are therefore allowed. House style keys are not. Dropping
10
+ * a house ignore is still drift — that is how generated files get rewritten.
11
+ */
12
+
13
+ export type OxfmtConfig = {
14
+ readonly printWidth?: number;
15
+ readonly useTabs?: boolean;
16
+ readonly singleQuote?: boolean;
17
+ readonly semi?: boolean;
18
+ readonly ignorePatterns?: readonly string[];
19
+ readonly [key: string]: unknown;
20
+ };
21
+
22
+ const STYLE_KEYS = ['printWidth', 'useTabs', 'singleQuote', 'semi'] as const;
23
+
24
+ /**
25
+ * Problems in a project's `.oxfmtrc.json` against the house preset.
26
+ * Empty means the copy still carries house style, and at least the house ignores.
27
+ */
28
+ export function problemsInOxfmt(preset: OxfmtConfig, theirs: OxfmtConfig): readonly string[] {
29
+ const problems: string[] = [];
30
+
31
+ for (const key of STYLE_KEYS) {
32
+ if (theirs[key] !== preset[key]) {
33
+ problems.push(
34
+ `.oxfmtrc.json: ${key} differs from @homeflare/config — re-copy it, or say why it differs`,
35
+ );
36
+ }
37
+ }
38
+
39
+ const local = new Set(theirs.ignorePatterns ?? []);
40
+ for (const pattern of preset.ignorePatterns ?? []) {
41
+ if (!local.has(pattern)) {
42
+ problems.push(
43
+ `.oxfmtrc.json: missing house ignore ${pattern} — generated files would be rewritten`,
44
+ );
45
+ }
46
+ }
47
+
48
+ return problems;
49
+ }
package/src/release.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * No npm publish. Tag a GitHub Release — but ONLY when this version genuinely just
3
+ * came out of `changeset version`, never on an arbitrary changeset-less push to main.
4
+ *
5
+ * 🔴 MEASURED 2026-09-16/17 on homeflare-openbao, and documented upstream
6
+ * (changesets/action's own README, changesets/action#9): a custom publish-script runs
7
+ * on EVERY push to `main` that has zero pending changesets — not just the one push
8
+ * right after a Version Packages PR merges. The README says so directly: "a commit
9
+ * without any new changesets can always land on your base branch after a successful
10
+ * publish... you need to figure out on your own how to skip." Unguarded, this would
11
+ * tag `<name>@<version>` on every unrelated push to main once CHANGELOG.md exists.
12
+ *
13
+ * ★ THE CONDITION: proceed only if `CHANGELOG.md` has an entry for the current version
14
+ * (proof `changeset version` really ran and wrote it) AND no git tag `<name>@<version>`
15
+ * exists yet (proof this exact release was not already cut). Both checks are local —
16
+ * no network, no GitHub API — and both work from the checkout `release.yml` already
17
+ * has (`fetch-depth: 0`).
18
+ *
19
+ * ⛔ TELL changesets/action WHAT TO TAG. With a custom publish-script it learns what
20
+ * shipped only from `CHANGESETS_OUTPUT`. Without it, the action warns "GitHub releases
21
+ * and git tags cannot be created without this output" and creates neither.
22
+ */
23
+ type Pkg = { readonly name: string; readonly version: string };
24
+
25
+ /**
26
+ * Does CHANGELOG.md have a heading for this exact version?
27
+ * @changesets/changelog-github writes `## <version>` for a single, non-monorepo package.
28
+ */
29
+ export async function changelogHasEntry(cwd: string, version: string): Promise<boolean> {
30
+ const file = Bun.file(`${cwd}/CHANGELOG.md`);
31
+ if (!(await file.exists())) return false;
32
+
33
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
+ return new RegExp(`^## ${escaped}$`, 'm').test(await file.text());
35
+ }
36
+
37
+ /** Does this exact tag already exist? Local `git tag -l`, no network. */
38
+ export function tagExists(cwd: string, tag: string): boolean {
39
+ const result = Bun.spawnSync(['git', 'tag', '-l', tag], { cwd });
40
+ return result.stdout.toString().trim() === tag;
41
+ }
42
+
43
+ export type ReleaseDecision = { readonly ok: boolean; readonly reason: string };
44
+
45
+ export async function shouldRelease(cwd: string, pkg: Pkg): Promise<ReleaseDecision> {
46
+ const tag = `${pkg.name}@${pkg.version}`;
47
+
48
+ if (!(await changelogHasEntry(cwd, pkg.version))) {
49
+ return {
50
+ ok: false,
51
+ reason: `CHANGELOG.md has no "## ${pkg.version}" entry — changeset version has not run for this version`,
52
+ };
53
+ }
54
+ if (tagExists(cwd, tag)) {
55
+ return { ok: false, reason: `${tag} already exists — this version was already released` };
56
+ }
57
+ return { ok: true, reason: `CHANGELOG.md has the entry and ${tag} does not exist yet` };
58
+ }
59
+
60
+ /** One ndjson line changesets/action turns into a git tag and a GitHub Release. */
61
+ export function tagEvent(pkg: Pkg): { type: 'git-tag'; tag: string; packageName: string } {
62
+ return { type: 'git-tag', tag: `${pkg.name}@${pkg.version}`, packageName: pkg.name };
63
+ }
64
+
65
+ /**
66
+ * App-repo publish-script. Reads `package.json` at `cwd`, gates on `shouldRelease`,
67
+ * and writes the tag event to `$CHANGESETS_OUTPUT` when this version is new.
68
+ *
69
+ * ⛔ `cwd` is the APP, never this package. Defaulting from `import.meta.url` after
70
+ * publish would inspect `@homeflare/config` itself.
71
+ */
72
+ export async function runAppRelease(cwd: string): Promise<ReleaseDecision> {
73
+ const pkg = (await Bun.file(`${cwd}/package.json`).json()) as Pkg;
74
+ const decision = await shouldRelease(cwd, pkg);
75
+
76
+ if (!decision.ok) {
77
+ process.stdout.write(`skip: ${decision.reason}\n`);
78
+ return decision;
79
+ }
80
+
81
+ const outputFile = process.env['CHANGESETS_OUTPUT'];
82
+ if (outputFile !== undefined) {
83
+ await Bun.write(outputFile, `${JSON.stringify(tagEvent(pkg))}\n`);
84
+ }
85
+ process.stdout.write(
86
+ `${pkg.name}@${pkg.version} — no npm publish; tagged (${decision.reason}).\n`,
87
+ );
88
+ return decision;
89
+ }
90
+
91
+ if (import.meta.main) {
92
+ await runAppRelease(process.cwd());
93
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Refuse to let `changeset version` run against a config that would silently no-op.
3
+ *
4
+ * 🔴 MEASURED 2026-09-16 on homeflare-openbao, TWO INDEPENDENT WAYS TO GET THE SAME
5
+ * SILENT FAILURE. `@changesets/cli` 3.0.3 does neither of these things loudly:
6
+ * 1. `package.json` with no "version" — `changeset version` consumes the pending
7
+ * changeset file, writes no `CHANGELOG.md`, leaves `package.json` untouched.
8
+ * 2. `"private": true` with no `privatePackages.version: true` in
9
+ * `.changeset/config.json` — the package is excluded from versioning entirely;
10
+ * `changeset status` reports zero packages to bump even with a real version and a
11
+ * pending changeset file.
12
+ * Either gap alone reproduces the same no-op. Both are required together.
13
+ *
14
+ * ★ This is regression protection against either field being dropped — a bad merge, a
15
+ * hand-edit, a generator overwriting either file — not a bootstrap blocker.
16
+ *
17
+ * 🔴 A leftover `pnpm-workspace.yaml` that lists only nested packages hides the
18
+ * root. `changeset version` then exits 1 ("package X is not in the workspace") —
19
+ * measured 2026-09-17 on homeflare-secrets. `problemsFromPendingChangesets`
20
+ * fails that before the version command consumes the file.
21
+ *
22
+ * ⛔ Path defaults are `process.cwd()`, NEVER `import.meta.url`. After publish this
23
+ * file lives in `node_modules/@homeflare/config`; resolving next to it would check
24
+ * the published package, not the app that invoked it.
25
+ */
26
+ import { problemsFromPendingChangesets } from './changeset-workspace.ts';
27
+
28
+ type Pkg = { readonly version?: unknown; readonly private?: unknown };
29
+ type ChangesetConfig = { readonly privatePackages?: { readonly version?: unknown } };
30
+
31
+ export type ReleaseConfigPaths = {
32
+ readonly pkgPath: string;
33
+ readonly changesetConfigPath: string;
34
+ };
35
+
36
+ /** Resolve the two files a version command must see. Override in tests. */
37
+ export function releaseConfigPaths(cwd: string = process.cwd()): ReleaseConfigPaths {
38
+ return {
39
+ pkgPath: `${cwd}/package.json`,
40
+ changesetConfigPath: `${cwd}/.changeset/config.json`,
41
+ };
42
+ }
43
+
44
+ /** One thing a project should fix, in the imperative. */
45
+ export async function problemsInReleaseConfig(paths: ReleaseConfigPaths): Promise<string[]> {
46
+ const pkg = (await Bun.file(paths.pkgPath).json()) as Pkg;
47
+ const problems: string[] = [];
48
+
49
+ if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
50
+ problems.push(`${paths.pkgPath} has no "version" — changeset version would silently no-op`);
51
+ }
52
+
53
+ if (pkg.private === true) {
54
+ const changesetConfig = (await Bun.file(paths.changesetConfigPath).json()) as ChangesetConfig;
55
+ if (changesetConfig.privatePackages?.version !== true) {
56
+ problems.push(
57
+ `${paths.changesetConfigPath} has no privatePackages.version: true — a private package ` +
58
+ 'is excluded from versioning entirely, which reads as "nothing to release"',
59
+ );
60
+ }
61
+ }
62
+
63
+ const cwd = paths.pkgPath.replace(/\/package\.json$/, '');
64
+ problems.push(...(await problemsFromPendingChangesets(cwd)));
65
+
66
+ return problems;
67
+ }
68
+
69
+ /** Print and exit. App `version` scripts call this before `changeset version`. */
70
+ export async function runRequireReleaseConfig(paths?: Partial<ReleaseConfigPaths>): Promise<void> {
71
+ // ⚠️ A spread of `{ pkgPath: undefined }` from `process.argv[2]` would overwrite the
72
+ // cwd default. Only copy keys that are actually present.
73
+ const defaults = releaseConfigPaths();
74
+ const resolved: ReleaseConfigPaths = {
75
+ pkgPath: paths?.pkgPath ?? defaults.pkgPath,
76
+ changesetConfigPath: paths?.changesetConfigPath ?? defaults.changesetConfigPath,
77
+ };
78
+ const problems = await problemsInReleaseConfig(resolved);
79
+
80
+ if (problems.length > 0) {
81
+ process.stderr.write('\n✗ changeset version would silently no-op:\n');
82
+ for (const problem of problems) process.stderr.write(` - ${problem}\n`);
83
+ process.stderr.write('\n Fix the file(s) above in a reviewed commit, then re-run this.\n\n');
84
+ process.exit(1);
85
+ }
86
+
87
+ const pkg = (await Bun.file(resolved.pkgPath).json()) as Pkg;
88
+ process.stdout.write(
89
+ `ok: ${resolved.pkgPath} version is ${pkg.version}, privatePackages.version is set\n`,
90
+ );
91
+ }
92
+
93
+ if (import.meta.main) {
94
+ const pkgPath = process.argv[2];
95
+ const changesetConfigPath = process.argv[3];
96
+ await runRequireReleaseConfig({
97
+ ...(pkgPath === undefined ? {} : { pkgPath }),
98
+ ...(changesetConfigPath === undefined ? {} : { changesetConfigPath }),
99
+ });
100
+ }