@homeflare/config 0.11.0 → 0.12.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.
@@ -1,39 +1,42 @@
1
1
  # Dependabot, and how a kit release reaches a consumer
2
2
 
3
- `repo-shape` renders `.github/dependabot.yml` and `.github/workflows/dependabot-automerge.yml`
4
- together. The first opens one pull request per kit release in each consumer (the
5
- `homeflare` group); the second arms GitHub's auto-merge on that pull request and on nothing
6
- else. The branch ruleset's required checks decide whether it merges.
3
+ `repo-shape` renders `.github/dependabot.yml`. It watches everything EXCEPT the packages
4
+ this kit publishes: an `ignore` entry names `@homeflare/*` in the bun block, so Dependabot
5
+ never proposes them.
7
6
 
8
- **Status (2026-09-22): the Actions half works; the bun half is blocked upstream.** See
9
- [the blocker](#the-blocker-bunlock-lockfileversion-2) — it is the trigger for everything
10
- below doing anything.
7
+ **Retired 2026-09-23 (kit auto-bumper design, Tim): the `homeflare` group and the
8
+ rendered `dependabot-automerge.yml`.** They used to be how a kit release reached a
9
+ consumer — grouped, checked daily, merged on green. `taslabs-net/homeflare-bumper` does
10
+ that job now, dispatched from this repo's own `release.yml` (`notify-consumers`) with a
11
+ schedule backstop. The `ignore` below exists **so the two never compete**: without it, a
12
+ Dependabot bump and a bumper bump could open two pull requests for the same version at
13
+ once.
14
+
15
+ **Status (2026-09-22): the Actions half works; the bun half is blocked upstream regardless
16
+ of the ignore.** See [the blocker](#the-blocker-bunlock-lockfileversion-2).
11
17
 
12
18
  ## Why
13
19
 
14
20
  Merging and releasing a kit change are automated. Bumping a consumer was not, and it is the
15
21
  leg that silently stops: measured 2026-09-22, `homeflare-proxmox` and `homeflare-mini`
16
22
  pinned `@homeflare/alchemy` 0.13.0 and `@homeflare/config` 0.5.1 while the kit had published
17
- 0.19.1 and 0.8.0. Tim's decision (2026-09-23): kit releases reach consumers by Dependabot,
18
- grouped, checked daily, merged on green; no GitHub organization and no tokens.
23
+ 0.19.1 and 0.8.0. Tim's decision (2026-09-23): `homeflare-bumper` carries a kit release into
24
+ every consumer; Dependabot's job is everything else.
19
25
 
20
26
  ## What each rendered choice rests on
21
27
 
22
28
  Read on 2026-09-22 from GitHub's docs and from dependabot-core's source at v0.397.0.
23
29
 
24
- | Choice | Why | Source |
25
- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
26
- | One bun block, daily | Two blocks for one ecosystem and target branch must have "no overlap in directories defined", so a group cannot have its own schedule | [options reference: `directories`][options] |
27
- | `cooldown.exclude: ['@homeflare/*']` | Dependabot applies a 3-day cooldown "even when `cooldown` is not configured"; without the exclude, a release waits three days | [options reference: `cooldown`][options] |
28
- | `cooldown.default-days: 7` | Keeps third-party updates to roughly the weekly pace they had, by age rather than by calendar | same |
29
- | `homeflare` group first, no `update-types` | "If a dependency matches more than one rule, it's included in the first group that it matches"; a kit release moves as one set | [options reference: `groups`][options] |
30
- | `daily` means Monday–Friday | "Use `daily` to run on every weekday, Monday to Friday" | [options reference: `schedule`][options] |
31
- | Auto-merge by `gh pr merge --auto` | GitHub's documented pattern for Dependabot pull requests | [Automating Dependabot with GitHub Actions][automating] |
32
- | Refuse when the base branch requires no check | `gh pr merge --auto` merges a CLEAN or UNSTABLE pull request at once instead of arming it, so only a required status check keeps the bump waiting for `check` | cli/cli `pkg/cmd/pr/merge/merge.go`, `isImmediatelyMergeable` (v2.101.0) |
33
- | No `dependabot/fetch-metadata` | The same page labels it "not certified by GitHub"; the house CI is first-party only | same |
34
- | Group recognised by branch name | `dependabot/bun/homeflare-<10 hex>`: prefix, package manager, directory (root collapses), then group name and the first 10 hex of an MD5 digest | dependabot-core `common/lib/dependabot/pull_request_creator/branch_namer/dependency_group_strategy.rb` |
35
- | `contents: write` + `pull-requests: write` on the job only | A Dependabot-started run gets a read-only `GITHUB_TOKEN` unless the `permissions` key raises it; these two are what GitHub's own example grants | [Troubleshooting Dependabot on GitHub Actions][troubleshoot] |
36
- | `--squash` | The house policy (`declareRepoPolicy`) allows squash merges only | `@homeflare/alchemy` `repo-policy-form.ts` |
30
+ | Choice | Why | Source |
31
+ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
32
+ | `ignore: [{dependency-name: '@homeflare/*'}]` | `dependency-name`, "optionally using `*` to match zero or more characters" — stops Dependabot proposing what the bumper owns | [options reference: `ignore`][options] |
33
+ | `cooldown.default-days: 7` | Third-party updates are proposed once a release is a week old, by age rather than by calendar | [options reference: `cooldown`][options] |
34
+ | One bun block, weekly | Two blocks for one ecosystem and target branch must have "no overlap in directories defined" — moot now, kept for the directories split below | [options reference: `directories`][options] |
35
+
36
+ The bumper's own citations — `gh pr merge --auto`'s CLEAN/UNSTABLE trap, the App's token
37
+ scoping, and everything else that used to live in this repo's now-deleted
38
+ `dependabot-automerge.yml` — moved with it, to `taslabs-net/homeflare-bumper` (not
39
+ measured here whether its docs have landed yet; check that repo directly).
37
40
 
38
41
  ## Where Dependabot runs, and the billing lock
39
42
 
@@ -82,27 +85,14 @@ and rejects unsafe git tags. Downgrading the lockfile to suit Dependabot gives b
82
85
 
83
86
  ## What still needs a person
84
87
 
85
- - **A kit release that changes what `@homeflare/config` renders.** The group's pull
86
- request fails the drift test by design ([repo-shape.md](repo-shape.md), "Bumping
87
- `@homeflare/config` will go red before it goes green"). Run `bun run repo-shape:refresh`
88
- on the Dependabot branch and push; the push starts CI as any person's push does.
89
- ⛔ The workflow cannot do it: "events triggered by the `GITHUB_TOKEN` will not create a
90
- new workflow run" ([GITHUB_TOKEN][token]), so a refreshed commit pushed with it would
91
- never get its checks.
92
- - **A repository whose base branch requires no status check.** The arming job fails on
93
- purpose there (measured 2026-09-22: `homeflare-builds`, whose ruleset is not deployed yet,
94
- and `homeflare-desktop`). `gh pr merge --auto` would otherwise merge the bump at once,
95
- before `check` ran. Deploy the repository's ruleset; the next Dependabot rebase arms it.
96
- - **A release whose plan changes.** A merged bump deploys nothing, but the next deploy of
97
- that consumer applies whatever the new kit plans. Read the plan before deploying.
98
-
99
- ⚠️ **The merge itself starts no workflow on `main`.** Auto-merge armed with `GITHUB_TOKEN`
100
- merges as that token, and its push triggers nothing. The rendered `ci.yml` has no push
101
- trigger anyway, and a bump carries no changeset, so nothing is lost — but a repository that
102
- adds a `push: main` workflow should know it will not run for these merges.
88
+ - **A kit release whose plan changes.** A merged bump — from either path — deploys
89
+ nothing, but the next deploy of that consumer applies whatever the new kit plans. Read
90
+ the plan before deploying.
91
+ - **Whatever the bumper itself hands off.** Its own auto-merge refusals, App setup, and
92
+ key rotation are documented where it lives, `taslabs-net/homeflare-bumper` — not here.
103
93
 
104
94
  ⚠️ **`open-pull-requests-limit: 5` is per block.** Five stale third-party pull requests
105
- could, in principle, hold the group back. dependabot-core runs grouped updates before
95
+ could, in principle, crowd out a new one. dependabot-core runs grouped updates before
106
96
  ungrouped ones in each job (`group_update_all_versions.rb`), but the limit is enforced by
107
97
  the service, whose code is not public — so this is reasoned, not measured.
108
98
 
@@ -119,8 +109,6 @@ which only says "The updater encountered one or more errors".
119
109
 
120
110
  [options]: https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference
121
111
  [automating]: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions
122
- [troubleshoot]: https://docs.github.com/en/code-security/reference/supply-chain-security/troubleshoot-dependabot/dependabot-on-actions
123
112
  [concepts]: https://docs.github.com/en/code-security/concepts/supply-chain-security/dependabot-on-actions
124
113
  [reference]: https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-on-actions
125
114
  [selfhosted]: https://docs.github.com/en/code-security/dependabot/maintain-dependencies/managing-dependabot-on-self-hosted-runners
126
- [token]: https://docs.github.com/en/actions/concepts/security/github_token
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/config",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",
@@ -10,12 +10,25 @@
10
10
  * runs the whole `check`, every test, the build and the smoke test on every pull request.
11
11
  */
12
12
  import { existsSync } from 'node:fs';
13
+ import { resolveOxfmtConfig } from './oxfmt-config.ts';
13
14
  import { type Lane, planLanes } from './push-plan.ts';
14
15
  import { changesEverything, parsePushRefs, pushScope } from './push-range.ts';
15
- import { fail, note, ok, run, runLane, tool } from './report.ts';
16
+ import { fail, note, ok, run, runCaptured, runLane, tool } from './report.ts';
16
17
  import { scanStagedSecrets } from './secrets.ts';
17
18
  import { fingerprints, staged } from './staged.ts';
18
19
 
20
+ /**
21
+ * oxfmt's own summary line ("Finished in 2ms on 0 files using 10 threads.") is how the hook
22
+ * learns whether `--no-error-on-unmatched-pattern` turned a real run into a no-op — oxfmt has
23
+ * no flag that answers this more directly. `null` (unparsable — a future oxfmt wording change)
24
+ * is treated as "something ran": that is the safe direction, since it only ever suppresses the
25
+ * new "no formattable staged files" message, never a real failure.
26
+ */
27
+ function matchedFileCount(stdout: string): number | null {
28
+ const match = /\bon (\d+) files? using/.exec(stdout);
29
+ return match?.[1] === undefined ? null : Number(match[1]);
30
+ }
31
+
19
32
  /**
20
33
  * Format and lint what is staged, restaging only what the formatter rewrote.
21
34
  *
@@ -43,9 +56,41 @@ export async function preCommit(root: string): Promise<void> {
43
56
  // gitleaks binary, so it runs even in a worktree nobody has installed yet.
44
57
  await scanStagedSecrets();
45
58
  if (!installed(root, 'pre-commit')) return;
46
- const oxfmt = tool(root, 'oxfmt');
59
+
47
60
  const { formattable, code, partial } = await staged();
48
61
 
62
+ // ⚠️ BEFORE RESOLVING A CONFIG: if oxfmt would never run (nothing formattable staged, not
63
+ // even a partially-staged one to `--check`), a config problem — even an unresolved
64
+ // ambiguous one — must not block the commit. That is the exact shape of BUG 1: don't
65
+ // fail on "there is nothing to do here".
66
+ if (formattable.length === 0 && partial.length === 0) {
67
+ ok('pre-commit: nothing staged to format');
68
+ return;
69
+ }
70
+
71
+ const config = await resolveOxfmtConfig(root);
72
+ if (config.kind === 'ambiguous') {
73
+ fail(
74
+ 'pre-commit',
75
+ `${String(config.files.length)} oxfmt configs found (${config.files.join(', ')}) and oxfmt auto-discovers none of them`,
76
+ 'keep exactly one .oxfmtrc.* file at the repo root (.oxfmtrc.json is auto-discovered)',
77
+ );
78
+ }
79
+ // ⛔ BUG, MEASURED 2026-09-23: bare `oxfmt` only finds `.oxfmtrc.json`/`.oxfmtrc.jsonc` on
80
+ // its own — see oxfmt-config.ts. `--config` is added only when the repo's config needs it.
81
+ const oxfmt = [
82
+ ...tool(root, 'oxfmt'),
83
+ ...(config.kind === 'explicit' ? ['--config', config.path] : []),
84
+ // ⛔ BUG, MEASURED 2026-09-23: a staged file wholly excluded by oxfmt's OWN
85
+ // `ignorePatterns` (house `packages/distilled-*/src/**`, say) made oxfmt exit non-zero
86
+ // with "Expected at least one target file" — `staged()` classifies by extension only,
87
+ // blind to the repo's ignore rules, so `formattable`/`code` can be 100% ignored files.
88
+ // This flag is oxfmt's own documented answer (`--help`, oxfmt 0.68.0): still fail on a
89
+ // real formatting problem, but not on "everything here was excluded".
90
+ '--no-error-on-unmatched-pattern',
91
+ ];
92
+ const oxlint = [...tool(root, 'oxlint'), '--no-error-on-unmatched-pattern'];
93
+
49
94
  // ⛔ Checked, never rewritten — see staged.ts for why `git add` here would be theft.
50
95
  if (partial.length > 0) {
51
96
  note(`${partial.length} staged file(s) also have unstaged edits; checking without rewriting`);
@@ -64,22 +109,28 @@ export async function preCommit(root: string): Promise<void> {
64
109
  }
65
110
 
66
111
  const before = await fingerprints(formattable);
67
- if ((await run([...oxfmt, ...formattable])) !== 0) {
112
+ const { code: fmtExit, stdout: fmtOut } = await runCaptured([...oxfmt, ...formattable]);
113
+ if (fmtExit !== 0) {
68
114
  fail('pre-commit', 'oxfmt could not format the staged files', 'bun run format');
69
115
  }
70
- const after = await fingerprints(formattable);
71
- const rewritten = formattable.filter((file) => before.get(file) !== after.get(file));
116
+ const matched = matchedFileCount(fmtOut);
117
+
118
+ if (matched !== 0) {
119
+ const after = await fingerprints(formattable);
120
+ const rewritten = formattable.filter((file) => before.get(file) !== after.get(file));
72
121
 
73
- if (rewritten.length > 0) {
74
- note(`oxfmt rewrote and restaged ${rewritten.length} file(s): ${rewritten.join(', ')}`);
75
- if ((await run(['git', 'add', '--', ...rewritten])) !== 0) {
76
- fail('pre-commit', 'could not restage the formatted files', 'git add the listed files');
122
+ if (rewritten.length > 0) {
123
+ note(`oxfmt rewrote and restaged ${rewritten.length} file(s): ${rewritten.join(', ')}`);
124
+ if ((await run(['git', 'add', '--', ...rewritten])) !== 0) {
125
+ fail('pre-commit', 'could not restage the formatted files', 'git add the listed files');
126
+ }
77
127
  }
78
128
  }
79
129
 
80
130
  // ⚠️ `--deny-warnings` matches what CI runs. A hook that is laxer than CI is worse
81
- // than no hook: it certifies a change CI will reject.
82
- if (code.length > 0 && (await run([...tool(root, 'oxlint'), '--deny-warnings', ...code])) !== 0) {
131
+ // than no hook: it certifies a change CI will reject. Same ignore-rules shape as oxfmt
132
+ // above: `code` can be entirely excluded by oxlint's own `ignorePatterns`.
133
+ if (code.length > 0 && (await run([...oxlint, '--deny-warnings', ...code])) !== 0) {
83
134
  fail(
84
135
  'pre-commit',
85
136
  `oxlint found problems in ${code.length} staged file(s)`,
@@ -87,7 +138,14 @@ export async function preCommit(root: string): Promise<void> {
87
138
  );
88
139
  }
89
140
 
90
- ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);
141
+ if (matched === 0) {
142
+ note(
143
+ `${String(formattable.length)} staged file(s) matched a formattable extension, but all are excluded by ignore rules`,
144
+ );
145
+ ok('pre-commit: no formattable staged files');
146
+ } else {
147
+ ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);
148
+ }
91
149
  }
92
150
 
93
151
  /** The one-line reason a lane is in the run, printed before it starts. */
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Which `.oxfmtrc.*` file governs this repo — so the hook can name it explicitly.
3
+ *
4
+ * ⛔ BUG, MEASURED 2026-09-23 (homeflare-desktop docs agent): the shared pre-commit ran
5
+ * bare `oxfmt`, and bare `oxfmt` auto-discovers only `.oxfmtrc.json` and `.oxfmtrc.jsonc`.
6
+ * Checked against the pinned oxfmt 0.68.0 directly: a repo with only `.oxfmtrc.mjs` and
7
+ * no `-c` prints "No config found, using defaults" and formats with oxfmt's built-in
8
+ * style, silently — even though `oxfmt --help` lists `.ts/.mts/.cts/.js/.mjs/.cjs` as
9
+ * valid `--config` targets. Desktop's fix was renaming to `.oxfmtrc.json`; this fixes the
10
+ * hook so a repo does not have to.
11
+ * ★ WHY A FILE SCAN, NOT A PACKAGE.JSON READ. The repo's own `format`/`check` script names
12
+ * the config it wants (or relies on auto-discovery finding `.oxfmtrc.json`), but its exact
13
+ * invocation shape varies per repo. Finding the config file on disk and handing it to
14
+ * oxfmt directly gets the same result without parsing an arbitrary shell command.
15
+ * ⛔ EVERY `.oxfmtrc.*` LIVES AT THE REPO ROOT TODAY (checked: every repo in the estate has
16
+ * exactly one, at root — no nested per-package overrides). Explicit `-c` disables oxfmt's
17
+ * own nested-config search (measured: a nested `.oxfmtrc.json` was ignored once `-c` named
18
+ * the root one), so this only scans `root` itself — it would misfire on a repo that added a
19
+ * package-level override, which none currently do.
20
+ */
21
+ import { readdir } from 'node:fs/promises';
22
+
23
+ /** Auto-discovered by bare `oxfmt` — nothing for the hook to pass. */
24
+ const AUTO_DISCOVERED = ['.oxfmtrc.json', '.oxfmtrc.jsonc'];
25
+
26
+ /**
27
+ * Accepted by `oxfmt -c/--config` (per `oxfmt --help`, oxfmt 0.68.0) but never found on its
28
+ * own — checked empirically for each extension. Listed in the order `--help` documents them.
29
+ */
30
+ const NEEDS_EXPLICIT_CONFIG = [
31
+ '.oxfmtrc.ts',
32
+ '.oxfmtrc.mts',
33
+ '.oxfmtrc.cts',
34
+ '.oxfmtrc.js',
35
+ '.oxfmtrc.mjs',
36
+ '.oxfmtrc.cjs',
37
+ ];
38
+
39
+ export type OxfmtConfigResolution =
40
+ /** `.oxfmtrc.json` or `.oxfmtrc.jsonc` exists — bare oxfmt already finds it. */
41
+ | { readonly kind: 'auto' }
42
+ /** No `.oxfmtrc.*` at all — let oxfmt use its built-in defaults, as today. */
43
+ | { readonly kind: 'none' }
44
+ /** Exactly one config oxfmt would not find unaided — pass it with `-c`. */
45
+ | { readonly kind: 'explicit'; readonly path: string }
46
+ /**
47
+ * Two or more configs, none of them auto-discovered — bare oxfmt (and a repo's own
48
+ * bare `bun run format`) would silently fall back to defaults too, honouring neither.
49
+ * Guessing which one the repo meant would just move the silent-defaults bug here.
50
+ */
51
+ | { readonly kind: 'ambiguous'; readonly files: readonly string[] };
52
+
53
+ /** Every `.oxfmtrc.*` file actually present at `root`, in a fixed, deterministic order. */
54
+ async function present(root: string, names: readonly string[]): Promise<readonly string[]> {
55
+ const entries = new Set(await readdir(root).catch(() => []));
56
+ return names.filter((name) => entries.has(name));
57
+ }
58
+
59
+ export async function resolveOxfmtConfig(root: string): Promise<OxfmtConfigResolution> {
60
+ if ((await present(root, AUTO_DISCOVERED)).length > 0) return { kind: 'auto' };
61
+
62
+ const explicit = await present(root, NEEDS_EXPLICIT_CONFIG);
63
+ const [only, ...rest] = explicit;
64
+ if (only === undefined) return { kind: 'none' };
65
+ if (rest.length === 0) return { kind: 'explicit', path: `${root}/${only}` };
66
+ return { kind: 'ambiguous', files: explicit };
67
+ }
@@ -52,6 +52,21 @@ export async function capture(cmd: readonly string[]): Promise<string> {
52
52
  return (await probe(cmd)).stdout;
53
53
  }
54
54
 
55
+ /**
56
+ * Run a command, relaying its stdout live-enough (after it exits) while also handing the
57
+ * caller the text — gates.ts reads oxfmt's own "on N files" summary from it. `stderr` stays
58
+ * `inherit`: diagnostics (a parse error, "no files matched") must show immediately, and nothing
59
+ * here needs to inspect them.
60
+ */
61
+ export async function runCaptured(
62
+ cmd: readonly string[],
63
+ ): Promise<{ readonly code: number; readonly stdout: string }> {
64
+ const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'inherit' });
65
+ const stdout = await new Response(proc.stdout).text();
66
+ process.stdout.write(stdout);
67
+ return { code: await proc.exited, stdout };
68
+ }
69
+
55
70
  /** Capture a command's stdout AND its exit code — git plumbing that answers by status. */
56
71
  export async function probe(cmd: readonly string[]): Promise<{ code: number; stdout: string }> {
57
72
  const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'ignore' });
@@ -1,36 +1,29 @@
1
1
  /**
2
- * `.github/dependabot.yml`, rendered — and the one group that carries kit releases.
2
+ * `.github/dependabot.yml`, rendered.
3
3
  *
4
4
  * ★ MEASURED BEFORE IT WAS RENDERED (2026-09-22): 1 of 14 repositories had a Dependabot
5
5
  * config. Twelve took no dependency or Action updates at all, and nothing said so.
6
6
  *
7
- * ★ THE `homeflare` GROUP IS HOW A KIT RELEASE REACHES A CONSUMER (Tim, 2026-09-23:
8
- * "Dependabot, grouped", checked daily, merged on green). Measured the same day, the
9
- * leg nobody automated had drifted: proxmox and mini pinned `@homeflare/alchemy` 0.13.0
10
- * while the kit had published 0.19.1. `automerge.ts` renders the workflow that arms
11
- * auto-merge on this group's pull request and on nothing else.
7
+ * ⛔ `@homeflare/*` IS IGNORED HERE, NOT GROUPED (retired 2026-09-23, kit auto-bumper
8
+ * design, Tim). A `homeflare` group and a rendered `dependabot-automerge.yml`
9
+ * used to carry kit releases into a consumer; both are gone. `homeflare-bumper` does
10
+ * that job now, over `workflow_dispatch` from this repo's own release, with a schedule
11
+ * backstop — a Dependabot bump running at the same time would open a SECOND, competing
12
+ * pull request for the same version bump. The `ignore` entry below is what stops that:
13
+ * Dependabot never proposes `@homeflare/*` at all, so there is only ever one bump PR.
12
14
  *
13
15
  * Every Dependabot claim below was read from GitHub's docs or dependabot-core's source on
14
16
  * 2026-09-22 (dependabot-core v0.397.0); `docs/repo-shape-dependabot.md` has the citations.
15
17
  */
16
18
  import type { RepoShape } from './shape.ts';
17
19
 
18
- /**
19
- * The group's identifier. ⛔ IT IS ALSO HALF OF A BRANCH NAME the auto-merge workflow
20
- * matches — dependabot-core names a group's branch `dependabot/bun/<group>-<10 hex>`
21
- * (branch_namer/dependency_group_strategy.rb) — so it is exported, not retyped there.
22
- * Dependabot requires an identifier that starts and ends with a letter.
23
- */
24
- export const HOMEFLARE_GROUP = 'homeflare';
25
-
26
20
  /** The first-party scope. Every package the kit publishes is under it. */
27
21
  export const HOMEFLARE_PATTERN = '@homeflare/*';
28
22
 
29
23
  /**
30
- * ★ SEVEN DAYS FOR THIRD-PARTY VERSIONS. The bun block has to run daily for the kit's sake
31
- * (see below), so the weekly pace the estate had for everything else is kept by age
32
- * instead of by calendar: a third-party release is proposed once it is a week old. That
33
- * is also the supply-chain half — a compromised release is usually yanked within days.
24
+ * ★ SEVEN DAYS. A third-party release is proposed once it is a week old rather than on
25
+ * Dependabot's undocumented-but-real 3-day default — that is also the supply-chain
26
+ * half, since a compromised release is usually yanked within days.
34
27
  */
35
28
  export const THIRD_PARTY_COOLDOWN_DAYS = 7;
36
29
 
@@ -65,41 +58,33 @@ version: 2
65
58
 
66
59
  updates:
67
60
  # ── The toolchain (bun.lock) ────────────────────────────────────────────────
68
- # ⚠️ BLOCKED UPSTREAM, 2026-09-22: bun 1.4 writes bun.lock \`lockfileVersion\` 2 and
69
- # Dependabot's updater bundles bun 1.3.14, which reads up to 1, so this block fails in
70
- # every estate repository with "Unsupported bun.lock 'lockfileVersion' 2". The fix is
61
+ # ⚠️ STILL BLOCKED UPSTREAM, 2026-09-22 — unrelated to the ignore below. bun 1.4 writes
62
+ # bun.lock \`lockfileVersion\` 2 and Dependabot's updater bundles bun 1.3.14, which reads
63
+ # up to 1, so this whole block fails in every estate repository with "Unsupported
64
+ # bun.lock 'lockfileVersion' 2" before it reads a single manifest. The fix is
71
65
  # dependabot/dependabot-core pull request 16071. The github-actions block is unaffected.
72
- # ⛔ ONE BUN BLOCK, SO ONE SCHEDULE. Dependabot refuses two blocks for one ecosystem and
73
- # target branch whose directories overlap, so the \`homeflare\` group cannot be daily
74
- # while the rest stays weekly. The block is daily; \`cooldown\` slows the rest.
75
66
  - package-ecosystem: bun
76
67
  directories:
77
68
  ${bunDirs.map((dir) => ` - ${dir}`).join('\n')}
78
69
  schedule:
79
- # ⚠️ Dependabot's \`daily\` is Monday to Friday; a weekend kit release lands on Monday.
80
- interval: daily
70
+ interval: weekly
71
+ day: monday
81
72
  time: '09:00'
82
73
  timezone: America/New_York
83
- # ⛔ THE EXCLUDE IS NOT OPTIONAL. Dependabot applies a 3-day cooldown to every version
84
- # update even when this key is absent, so without it a kit release would wait three
85
- # days before its bump opened — and "daily" would quietly mean "three days late".
86
74
  cooldown:
87
75
  default-days: ${THIRD_PARTY_COOLDOWN_DAYS}
88
- exclude: ['${HOMEFLARE_PATTERN}']
76
+ # ⛔ NEVER PROPOSED HERE. \`homeflare-bumper\` opens the one pull request that bumps
77
+ # \`@homeflare/*\` (kit auto-bumper design, Tim 2026-09-23) — a Dependabot
78
+ # update for the same package would race it and, on the weeks they disagree, leave
79
+ # two open pull requests fighting over the same \`package.json\` line.
80
+ ignore:
81
+ - dependency-name: '${HOMEFLARE_PATTERN}'
89
82
  open-pull-requests-limit: 5
90
83
  commit-message:
91
84
  prefix: 'chore'
92
85
  include: scope
93
86
  labels: [dependencies]
94
87
  groups:
95
- # ★ FIRST, AND EVERY UPDATE TYPE. A kit release is one set of packages built to work
96
- # together, so they move as one pull request; \`bun run check\` is what reads it, and
97
- # .github/workflows/dependabot-automerge.yml merges it when that is green.
98
- # ⚠️ A release that changes what @homeflare/config renders fails the drift test here
99
- # by design; \`bun run repo-shape:refresh\` on the branch is the one-command fix.
100
- ${HOMEFLARE_GROUP}:
101
- patterns: ['${HOMEFLARE_PATTERN}']
102
-
103
88
  # oxfmt and oxlint move together and only affect style. Minor and patch bumps are
104
89
  # noise unless they fail CI, which is what CI is for.
105
90
  lint-and-format:
@@ -20,7 +20,6 @@
20
20
  * test lives in `packages/alchemy/tests/repo-shape-policy.test.ts`, which is the one
21
21
  * place that imports both.
22
22
  */
23
- import { renderAutomerge } from './automerge.ts';
24
23
  import { renderCi } from './ci.ts';
25
24
  import { renderActionlintConfig, renderChangesetConfig } from './companions.ts';
26
25
  import { renderDependabot } from './dependabot.ts';
@@ -68,7 +67,6 @@ export function renderRepoShape(shape: RepoShape): RenderedRepo {
68
67
  '.changeset/config.json': renderChangesetConfig(shape),
69
68
  '.github/dependabot.yml': renderDependabot(shape),
70
69
  '.github/workflows/ci.yml': renderCi(shape),
71
- '.github/workflows/dependabot-automerge.yml': renderAutomerge(shape),
72
70
  '.github/workflows/security.yml': renderSecurity(shape),
73
71
  };
74
72
 
@@ -87,6 +85,5 @@ export const RENDERED_PATHS: readonly RenderedPath[] = [
87
85
  '.github/actionlint.yaml',
88
86
  '.github/dependabot.yml',
89
87
  '.github/workflows/ci.yml',
90
- '.github/workflows/dependabot-automerge.yml',
91
88
  '.github/workflows/security.yml',
92
89
  ];
@@ -86,7 +86,6 @@ export type RenderedPath =
86
86
  | '.github/actionlint.yaml'
87
87
  | '.github/dependabot.yml'
88
88
  | '.github/workflows/ci.yml'
89
- | '.github/workflows/dependabot-automerge.yml'
90
89
  | '.github/workflows/security.yml';
91
90
 
92
91
  export interface RepoShapeException {
@@ -6,9 +6,15 @@
6
6
  * this house are the product. The rendered workflows are written as text with holes;
7
7
  * only the step lists, whose shape varies per repository, go through here.
8
8
  *
9
- * ⚠️ Bun can PARSE YAML natively (`Bun.YAML.parse`) but does not stringify it, measured
10
- * against Bun 1.4.0 on 2026-09-22. The tests parse what this writes with `Bun.YAML.parse`
11
- * and compare structures, so a malformed emission fails rather than shipping.
9
+ * ⚠️ Bun.YAML.stringify EXISTS (`Object.keys(Bun.YAML)` is `["parse", "stringify"]`,
10
+ * measured against Bun 1.4.0 on 2026-09-23) but its output does not fit these rules:
11
+ * (a) a multi-line `run:` comes out as a double-quoted string with `\n` escapes, e.g.
12
+ * `run: "echo a\necho b\n"`, not a `|` block scalar; (b) `09:00` comes out UNQUOTED,
13
+ * e.g. `cron: 09:00`, the YAML 1.1 sexagesimal trap the `scalar()` comment below guards
14
+ * against; (c) a mapping key is followed by a trailing space, e.g. `"steps: \n - ..."`;
15
+ * and (d) a plain JS object has nowhere to attach a comment, so it cannot carry one. The
16
+ * tests parse what this writes with `Bun.YAML.parse` and compare structures, so a
17
+ * malformed emission fails rather than shipping.
12
18
  */
13
19
  import type { JobStep } from './shape.ts';
14
20
 
@@ -53,13 +59,29 @@ function renderMapping(
53
59
  return Object.entries(entries).map(([key, value]) => `${indent(depth)}${key}: ${scalar(value)}`);
54
60
  }
55
61
 
62
+ /**
63
+ * Trim trailing `\n` characters the way `command.replace(/\n+$/, '')` used to, but
64
+ * linear instead of backtracking — the same pattern as `normalizeBaseUrl` in
65
+ * `packages/distilled-netbox/src/credentials.ts`. Exported so a test can compare it
66
+ * against the old regex directly.
67
+ *
68
+ * ⚠️ Linear on purpose: a `/\n+$/` regex backtracks polynomially on a long run of
69
+ * "\n" that is not at the end (CodeQL js/polynomial-redos), and `command` here is
70
+ * repository-configured step text.
71
+ */
72
+ export function trimTrailingNewlines(value: string): string {
73
+ let end = value.length;
74
+ while (end > 0 && value.charCodeAt(end - 1) === 10) end--;
75
+ return value.slice(0, end);
76
+ }
77
+
56
78
  /**
57
79
  * ★ BLOCK SCALAR FOR EVERY MULTI-LINE `run:`. A folded or quoted form would join the
58
80
  * lines, and a shell script whose `if` and `then` end up on one line is a syntax error
59
81
  * at job time rather than at lint time. `|` keeps them exactly as written.
60
82
  */
61
83
  function renderRun(command: string, depth: number): string[] {
62
- const lines = command.replace(/\n+$/, '').split('\n');
84
+ const lines = trimTrailingNewlines(command).split('\n');
63
85
  if (lines.length === 1) return [`${indent(depth)}run: ${scalar(lines[0] ?? '')}`];
64
86
  return [`${indent(depth)}run: |`, ...lines.map((line) => `${indent(depth + 1)}${line}`)];
65
87
  }
package/src/repo-shape.ts CHANGED
@@ -17,8 +17,7 @@
17
17
  * That one file gives the repository:
18
18
  *
19
19
  * · its FILES — `bun run repo-shape:refresh` writes ci.yml, security.yml,
20
- * dependabot-automerge.yml, actionlint.yaml, dependabot.yml and
21
- * the changeset config;
20
+ * actionlint.yaml, dependabot.yml and the changeset config;
22
21
  * · its DRIFT GATE — a `bun:test` calling `driftInRepoShape` fails on a hand edit;
23
22
  * · its SETTINGS — `renderRepoShape(shape).policy` is the options object
24
23
  * `@homeflare/alchemy`'s `declareRepoPolicy` takes, so the ruleset
@@ -34,11 +33,9 @@
34
33
  * including the ones with no Alchemy stack, so it belongs to the package they all
35
34
  * already have — and it takes no dependency on Alchemy or Effect to get there.
36
35
  */
37
- export { GROUP_BRANCH, GROUP_BRANCH_PREFIX, renderAutomerge } from './repo-shape/automerge.ts';
38
36
  export { renderCi, ACTIONLINT_VERSION, BUN_VERSION } from './repo-shape/ci.ts';
39
37
  export { renderActionlintConfig, renderChangesetConfig } from './repo-shape/companions.ts';
40
38
  export {
41
- HOMEFLARE_GROUP,
42
39
  HOMEFLARE_PATTERN,
43
40
  renderDependabot,
44
41
  THIRD_PARTY_COOLDOWN_DAYS,
@@ -1,17 +0,0 @@
1
- import type { RepoShape } from './shape.ts';
2
- /**
3
- * The branch prefix a job-level `if:` can test with `startsWith`. GitHub's expression
4
- * language has no regex, so this is the cheap filter that keeps every other pull request
5
- * from taking a runner slot; `GROUP_BRANCH` is the exact test inside the step.
6
- */
7
- export declare const GROUP_BRANCH_PREFIX: string;
8
- /**
9
- * ⛔ EXACT, AND FAILS CLOSED. A solo update of `@homeflare/config` is
10
- * `dependabot/bun/homeflare/config-0.9.0` and a package named `homeflare-x` would be
11
- * `dependabot/bun/homeflare-x-1.2.3`; neither matches. If Dependabot ever changes its
12
- * format, the symptom is a bump that waits for a person — never one merged by mistake.
13
- */
14
- export declare const GROUP_BRANCH: string;
15
- /** The whole `dependabot-automerge.yml` for a shape. */
16
- export declare function renderAutomerge(shape: RepoShape): string;
17
- //# sourceMappingURL=automerge.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"automerge.d.ts","sourceRoot":"","sources":["../../src/repo-shape/automerge.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAI5C;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,EAAE,MAA6C,CAAC;AAEhF;;;;;GAKG;AACH,eAAO,MAAM,YAAY,EAAE,MAA+C,CAAC;AA0D3E,wDAAwD;AACxD,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAyCxD"}