@homeflare/config 0.11.1 → 0.12.1
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/dist/repo-shape/dependabot.d.ts +11 -17
- package/dist/repo-shape/dependabot.d.ts.map +1 -1
- package/dist/repo-shape/drift.d.ts +1 -1
- package/dist/repo-shape/drift.d.ts.map +1 -1
- package/dist/repo-shape/guards.d.ts +5 -0
- package/dist/repo-shape/guards.d.ts.map +1 -1
- package/dist/repo-shape/refresh.d.ts +5 -1
- package/dist/repo-shape/refresh.d.ts.map +1 -1
- package/dist/repo-shape/render.d.ts.map +1 -1
- package/dist/repo-shape/retired.d.ts +54 -0
- package/dist/repo-shape/retired.d.ts.map +1 -0
- package/dist/repo-shape/shape.d.ts +1 -1
- package/dist/repo-shape/shape.d.ts.map +1 -1
- package/dist/repo-shape.d.ts +3 -4
- package/dist/repo-shape.d.ts.map +1 -1
- package/dist/repo-shape.js +167 -205
- package/dist/repo-shape.js.map +10 -10
- package/dist/versions.js +7 -1
- package/dist/versions.js.map +4 -4
- package/docs/repo-shape-dependabot.md +31 -43
- package/docs/repo-shape-retired.md +75 -0
- package/docs/repo-shape.md +11 -0
- package/package.json +1 -1
- package/src/repo-shape/dependabot.ts +23 -38
- package/src/repo-shape/drift.ts +19 -1
- package/src/repo-shape/guards.ts +11 -0
- package/src/repo-shape/refresh.ts +55 -2
- package/src/repo-shape/render.ts +0 -3
- package/src/repo-shape/retired.ts +113 -0
- package/src/repo-shape/shape.ts +0 -1
- package/src/repo-shape.ts +9 -4
- package/dist/repo-shape/automerge.d.ts +0 -17
- package/dist/repo-shape/automerge.d.ts.map +0 -1
- package/src/repo-shape/automerge.ts +0 -144
|
@@ -12,9 +12,17 @@
|
|
|
12
12
|
* ⚠️ IT NEVER WRITES AN EXCEPTED FILE. Refreshing a file the repository declared it owns
|
|
13
13
|
* would overwrite the deviation the reason was written for — the one destructive thing
|
|
14
14
|
* this could do, and the one it must not.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ IT DELETES A RETIRED FILE ONLY WHEN IT CAN PROVE IT RENDERED IT. `RETIRED_FILES`
|
|
17
|
+
* (`retired.ts`) names every path this package used to render; `wasRenderedByUs` is the
|
|
18
|
+
* proof — the file still carries the generated-file header. A retired path present
|
|
19
|
+
* without that header is left alone and reported as refused, on the same reasoning as
|
|
20
|
+
* never overwriting an excepted file: this writer only ever removes what it is sure is
|
|
21
|
+
* its own.
|
|
15
22
|
*/
|
|
16
23
|
import { REFRESH_COMMAND, driftInRepoShape, exceptionSummary } from './drift.ts';
|
|
17
24
|
import { renderRepoShape } from './render.ts';
|
|
25
|
+
import { RETIRED_FILES, wasRenderedByUs } from './retired.ts';
|
|
18
26
|
import { type RepoShape, isExcepted } from './shape.ts';
|
|
19
27
|
import type { RenderedPath } from './shape.ts';
|
|
20
28
|
|
|
@@ -25,9 +33,13 @@ export interface RefreshResult {
|
|
|
25
33
|
readonly unchanged: readonly string[];
|
|
26
34
|
/** Paths skipped because the shape declares an exception for them. */
|
|
27
35
|
readonly skipped: readonly string[];
|
|
36
|
+
/** Retired paths deleted because they still carried the generated-file header. */
|
|
37
|
+
readonly removed: readonly string[];
|
|
38
|
+
/** Retired paths left alone because they did not — provably hand-written, not ours. */
|
|
39
|
+
readonly refused: readonly string[];
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
/** Write a repository's rendered files into `projectDir
|
|
42
|
+
/** Write a repository's rendered files into `projectDir`, and clear its rendered fossils. */
|
|
31
43
|
export async function refreshRepoShape(
|
|
32
44
|
projectDir: string,
|
|
33
45
|
shape: RepoShape,
|
|
@@ -52,7 +64,36 @@ export async function refreshRepoShape(
|
|
|
52
64
|
written.push(path);
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
|
|
67
|
+
const { refused, removed } = await clearRetiredFiles(projectDir);
|
|
68
|
+
|
|
69
|
+
return { refused, removed, skipped, unchanged, written };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Delete every retired path this package can prove it rendered; leave every other one, and
|
|
74
|
+
* say so. A path absent from `projectDir` is neither removed nor refused — there is
|
|
75
|
+
* nothing there to have an opinion about.
|
|
76
|
+
*/
|
|
77
|
+
async function clearRetiredFiles(
|
|
78
|
+
projectDir: string,
|
|
79
|
+
): Promise<{ removed: string[]; refused: string[] }> {
|
|
80
|
+
const removed: string[] = [];
|
|
81
|
+
const refused: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (const retiredFile of RETIRED_FILES) {
|
|
84
|
+
const target = `${projectDir}/${retiredFile.path}`;
|
|
85
|
+
const file = Bun.file(target);
|
|
86
|
+
if (!(await file.exists())) continue;
|
|
87
|
+
|
|
88
|
+
if (wasRenderedByUs(await file.text())) {
|
|
89
|
+
await file.delete();
|
|
90
|
+
removed.push(retiredFile.path);
|
|
91
|
+
} else {
|
|
92
|
+
refused.push(retiredFile.path);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { refused, removed };
|
|
56
97
|
}
|
|
57
98
|
|
|
58
99
|
/**
|
|
@@ -108,6 +149,18 @@ export async function repoShapeCli(
|
|
|
108
149
|
...result.written.map((path) => `wrote ${path}`),
|
|
109
150
|
...result.unchanged.map((path) => `ok ${path}`),
|
|
110
151
|
...result.skipped.map((path) => `excepted ${path}`),
|
|
152
|
+
...result.removed.map((path) => `removed ${path} (retired; carried our header)`),
|
|
111
153
|
]);
|
|
154
|
+
// ★ ON ITS OWN LINE, TO STDERR: a refused retired file is the one outcome here that
|
|
155
|
+
// still needs a person. `--check` (above) keeps failing on it — it reports any retired
|
|
156
|
+
// path that is present, proof or not — so this is not the only place it is said, but
|
|
157
|
+
// it is the only place that says WHY refresh did not just fix it.
|
|
158
|
+
await say(
|
|
159
|
+
Bun.stderr,
|
|
160
|
+
result.refused.map(
|
|
161
|
+
(path) =>
|
|
162
|
+
`refused ${path} — present but not provably ours; not deleted. See docs/repo-shape-retired.md.`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
112
165
|
return 0;
|
|
113
166
|
}
|
package/src/repo-shape/render.ts
CHANGED
|
@@ -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
|
];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendered paths this package stopped rendering — the "no fossils" list.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY THIS EXISTS. `renderRepoShape` only emits the files a shape asks for today; it
|
|
5
|
+
* never says what it used to emit, and `refreshRepoShape` only writes what it renders —
|
|
6
|
+
* so a file retired from the renderer was never deleted by a refresh. It just stopped
|
|
7
|
+
* being updated, silently, forever, in every repository that had already taken it.
|
|
8
|
+
*
|
|
9
|
+
* MEASURED 2026-09-24: `@homeflare/config` 0.12.0 (kit PR 199) stopped rendering
|
|
10
|
+
* `.github/workflows/dependabot-automerge.yml`. Every consumer that had refreshed to
|
|
11
|
+
* 0.12.0 before this module existed (homeflare-wiki bump PR 19, homeflare-mini bump
|
|
12
|
+
* PR 47) kept the dead file, and `driftInRepoShape` never said so — it only compares
|
|
13
|
+
* paths the CURRENT shape renders, and a retired path is not one of those.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ RETIRING A PATH IS A ONE-WAY DOOR, NOT A RENAME. Once a path is here it can never
|
|
16
|
+
* become a `RenderedPath` again: `render.ts` would start writing a file this module is
|
|
17
|
+
* also trying to delete, and every refresh would fight itself. Give the replacement a
|
|
18
|
+
* new path instead. `repo-shape-retired.test.ts` asserts the two lists never overlap.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ DELETION IS GATED ON PROOF, NOT ON THE PATH ALONE. A repository can have a
|
|
21
|
+
* hand-written file sitting at the exact path a retired renderer used to own — a fork of
|
|
22
|
+
* the old rendered file, kept on purpose, or unrelated content that just landed there.
|
|
23
|
+
* `wasRenderedByUs` is the proof: every renderer in this directory writes the
|
|
24
|
+
* `🤖 RENDERED BY @homeflare/config` line into its header (grep the directory for it),
|
|
25
|
+
* and that line is the one thing a hand-written file has no reason to contain.
|
|
26
|
+
* `refreshRepoShape` deletes a retired path only when the line is present; otherwise it
|
|
27
|
+
* refuses and reports the path, the same way it refuses to overwrite an excepted file.
|
|
28
|
+
*/
|
|
29
|
+
import { requireSemver, requireSentence } from './guards.ts';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A path `@homeflare/config` no longer renders, but may still find committed in a
|
|
33
|
+
* repository that refreshed before the retirement. Extend this union at the same time a
|
|
34
|
+
* path is added to `RETIRED_FILES` below — never reuse one already there.
|
|
35
|
+
*/
|
|
36
|
+
export type RetiredPath = '.github/workflows/dependabot-automerge.yml';
|
|
37
|
+
|
|
38
|
+
export interface RetiredFile {
|
|
39
|
+
/** The path this package rendered, once. */
|
|
40
|
+
readonly path: RetiredPath;
|
|
41
|
+
/** The `@homeflare/config` version whose release stopped rendering it. */
|
|
42
|
+
readonly retiredIn: string;
|
|
43
|
+
/** Why, and which kit change did it — written at the retirement, like `except()`'s reason. */
|
|
44
|
+
readonly reason: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function retire(file: RetiredFile): RetiredFile {
|
|
48
|
+
return {
|
|
49
|
+
path: file.path,
|
|
50
|
+
reason: requireSentence('retired reason', file.reason),
|
|
51
|
+
retiredIn: requireSemver(file.retiredIn),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every path `@homeflare/config` used to render, oldest retirement first. */
|
|
56
|
+
export const RETIRED_FILES: readonly RetiredFile[] = [
|
|
57
|
+
retire({
|
|
58
|
+
path: '.github/workflows/dependabot-automerge.yml',
|
|
59
|
+
reason:
|
|
60
|
+
'kit PR 199 retired the Dependabot @homeflare group; taslabs-net/homeflare-bumper ' +
|
|
61
|
+
"carries a kit release into each consumer now, over the kit's own release workflow",
|
|
62
|
+
retiredIn: '0.12.0',
|
|
63
|
+
}),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The substring every generated file's header has carried since this package's first
|
|
68
|
+
* renderer. Kept independent of the four current renderers' own copies of this text
|
|
69
|
+
* (`ci.ts`, `security.ts`, `dependabot.ts`, `companions.ts`) on purpose: a future wording
|
|
70
|
+
* change to the live header must not stop this module recognising a file rendered under
|
|
71
|
+
* the old one.
|
|
72
|
+
*/
|
|
73
|
+
export const GENERATED_FILE_MARKER = '🤖 RENDERED BY @homeflare/config';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether `content` is provably a file this package once wrote.
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ THE MARKER HAS TO START A LINE OF ITS OWN, NOT MERELY OCCUR SOMEWHERE IN THE TEXT.
|
|
79
|
+
* Every real header carries it as a whole comment line — `# 🤖 RENDERED BY
|
|
80
|
+
* @homeflare/config — DO NOT EDIT…`, third line in every renderer in this directory, so
|
|
81
|
+
* this does not require it to be the FIRST line — but a bare `content.includes(...)`
|
|
82
|
+
* would also match a hand-written file that merely *talks about* the marker, e.g. a
|
|
83
|
+
* comment reading "this file used to be 🤖 RENDERED BY @homeflare/config before it was
|
|
84
|
+
* retired; keeping it by hand now" — adversarial review, 2026-09-24, caught this exact
|
|
85
|
+
* case before it shipped. Requiring the marker at the start of a trimmed line is what a
|
|
86
|
+
* sentence built around it, rather than a header line consisting of it, cannot satisfy.
|
|
87
|
+
* `repo-shape-retired.test.ts` asserts the mid-sentence form stays refused.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ STILL A PREFIX CHECK ON THAT LINE, NOT A FULL-LINE EXACT MATCH. The retired renderer
|
|
90
|
+
* varied its trailing header prose by `shape.runner` and by repository name, and a
|
|
91
|
+
* future wording change to what follows the marker on a live renderer's header must not
|
|
92
|
+
* stop this recognising a file rendered under the old wording — only the marker itself,
|
|
93
|
+
* `🤖 RENDERED BY @homeflare/config`, has been constant across every renderer this
|
|
94
|
+
* package has ever shipped.
|
|
95
|
+
*/
|
|
96
|
+
export function wasRenderedByUs(content: string): boolean {
|
|
97
|
+
return content
|
|
98
|
+
.split('\n')
|
|
99
|
+
.some((line) => line.trimStart().startsWith(`# ${GENERATED_FILE_MARKER}`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The message `driftInRepoShape` reports for a retired path that is still present, and
|
|
104
|
+
* `repoShapeCli --check` prints verbatim. Names the fix command either way — whether that
|
|
105
|
+
* command can actually remove the file depends on `wasRenderedByUs`, which only
|
|
106
|
+
* `refreshRepoShape` (holding the file's contents) can decide.
|
|
107
|
+
*/
|
|
108
|
+
export function retiredFileProblem(file: RetiredFile): string {
|
|
109
|
+
return (
|
|
110
|
+
`${file.path}: retired in @homeflare/config@${file.retiredIn} (${file.reason}) ` +
|
|
111
|
+
'but still present — run `bun run repo-shape:refresh` to remove it'
|
|
112
|
+
);
|
|
113
|
+
}
|
package/src/repo-shape/shape.ts
CHANGED
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
|
-
*
|
|
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,
|
|
@@ -57,6 +54,14 @@ export {
|
|
|
57
54
|
RENDERED_PATHS,
|
|
58
55
|
renderRepoShape,
|
|
59
56
|
} from './repo-shape/render.ts';
|
|
57
|
+
export {
|
|
58
|
+
type RetiredFile,
|
|
59
|
+
type RetiredPath,
|
|
60
|
+
GENERATED_FILE_MARKER,
|
|
61
|
+
RETIRED_FILES,
|
|
62
|
+
retiredFileProblem,
|
|
63
|
+
wasRenderedByUs,
|
|
64
|
+
} from './repo-shape/retired.ts';
|
|
60
65
|
export { renderSecurity } from './repo-shape/security.ts';
|
|
61
66
|
export {
|
|
62
67
|
type ExtraJob,
|
|
@@ -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"}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `.github/workflows/dependabot-automerge.yml`, rendered.
|
|
3
|
-
*
|
|
4
|
-
* ★ THE OTHER HALF OF THE `homeflare` GROUP. Dependabot opens the bump; this arms GitHub's
|
|
5
|
-
* own auto-merge on it; the branch ruleset's required checks (`ci`, `secret scan`)
|
|
6
|
-
* decide whether it ever merges. Nothing here merges anything itself, and a merge
|
|
7
|
-
* deploys nothing — every stack in the estate is deployed by hand.
|
|
8
|
-
*
|
|
9
|
-
* ⛔ `gh pr merge --auto` IS NOT ALWAYS "ARM". When the pull request is already CLEAN,
|
|
10
|
-
* UNSTABLE or HAS_HOOKS it merges AT ONCE instead (cli/cli `pkg/cmd/pr/merge/merge.go`,
|
|
11
|
-
* `isImmediatelyMergeable`, read at v2.101.0 — the version the mini's job image ships).
|
|
12
|
-
* UNSTABLE is "mergeable, with a non-passing status", which is every pull request on a
|
|
13
|
-
* branch whose rules require no check: before `check` has run, and even after it failed.
|
|
14
|
-
* So the step reads the base branch's active rules first and refuses — a red check, never
|
|
15
|
-
* a merge — when none requires a status check. Measured 2026-09-22: homeflare-builds (its
|
|
16
|
-
* ruleset not deployed yet) and homeflare-desktop have none.
|
|
17
|
-
*
|
|
18
|
-
* ⛔ FIRST-PARTY ONLY: A `run:` STEP AND THE GitHub CLI, NO `uses:` AT ALL. GitHub's own
|
|
19
|
-
* example ("Automating Dependabot with GitHub Actions") identifies the update with
|
|
20
|
-
* `dependabot/fetch-metadata`, which the same page marks as "not certified by GitHub".
|
|
21
|
-
* The group is identified by the branch Dependabot gives it instead, whose format is
|
|
22
|
-
* dependabot-core's (`branch_namer/dependency_group_strategy.rb`, read at v0.397.0):
|
|
23
|
-
* `<prefix>/<package manager>/<directory>/<group>-<10 hex MD5 digest>`, with the root
|
|
24
|
-
* directory collapsing to nothing.
|
|
25
|
-
*/
|
|
26
|
-
import { HOMEFLARE_GROUP } from './dependabot.ts';
|
|
27
|
-
import type { RepoShape } from './shape.ts';
|
|
28
|
-
import { runsOn } from './shape.ts';
|
|
29
|
-
import { renderSteps } from './yaml.ts';
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* The branch prefix a job-level `if:` can test with `startsWith`. GitHub's expression
|
|
33
|
-
* language has no regex, so this is the cheap filter that keeps every other pull request
|
|
34
|
-
* from taking a runner slot; `GROUP_BRANCH` is the exact test inside the step.
|
|
35
|
-
*/
|
|
36
|
-
export const GROUP_BRANCH_PREFIX: string = `dependabot/bun/${HOMEFLARE_GROUP}-`;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* ⛔ EXACT, AND FAILS CLOSED. A solo update of `@homeflare/config` is
|
|
40
|
-
* `dependabot/bun/homeflare/config-0.9.0` and a package named `homeflare-x` would be
|
|
41
|
-
* `dependabot/bun/homeflare-x-1.2.3`; neither matches. If Dependabot ever changes its
|
|
42
|
-
* format, the symptom is a bump that waits for a person — never one merged by mistake.
|
|
43
|
-
*/
|
|
44
|
-
export const GROUP_BRANCH: string = `^${GROUP_BRANCH_PREFIX}[0-9a-f]{10}$`;
|
|
45
|
-
|
|
46
|
-
const HEADER = `# Arms auto-merge on Dependabot's @homeflare/* group, and on nothing else.
|
|
47
|
-
#
|
|
48
|
-
# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.
|
|
49
|
-
# Its input is this repository's \`repo-shape.ts\`; refresh with \`bun run repo-shape:refresh\`.
|
|
50
|
-
#
|
|
51
|
-
# ★ HOW A KIT RELEASE ARRIVES: the \`${HOMEFLARE_GROUP}\` group in .github/dependabot.yml opens one
|
|
52
|
-
# pull request; this arms GitHub's auto-merge on it; the ruleset's required checks decide.
|
|
53
|
-
# Nothing here merges anything itself, and merging deploys nothing.
|
|
54
|
-
# ⛔ NO THIRD-PARTY ACTION. GitHub's own example uses dependabot/fetch-metadata, which its
|
|
55
|
-
# docs mark "not certified by GitHub"; the group is recognised by its branch name instead.
|
|
56
|
-
# ⛔ NO REQUIRED CHECK ON THE BASE BRANCH, NO ARMING: there \`gh pr merge --auto\` would merge
|
|
57
|
-
# at once, unchecked. The job fails instead, so the pull request waits for a person.
|
|
58
|
-
# ⚠️ A RENDERER CHANGE STILL NEEDS A PERSON. When a kit release changes what @homeflare/config
|
|
59
|
-
# renders, the bump fails \`check\` (the drift test) and never goes green. Run
|
|
60
|
-
# \`bun run repo-shape:refresh\` on Dependabot's branch and push. ⛔ This workflow does not do
|
|
61
|
-
# it for you: a push made with GITHUB_TOKEN starts no workflow run, so a refreshed commit
|
|
62
|
-
# would never get the required checks and the pull request would wait forever.
|
|
63
|
-
`;
|
|
64
|
-
|
|
65
|
-
const TRIGGER = `name: dependabot auto-merge
|
|
66
|
-
|
|
67
|
-
on:
|
|
68
|
-
pull_request:
|
|
69
|
-
|
|
70
|
-
# ⛔ NOTHING AT THE TOP; the one job asks for exactly what \`gh pr merge --auto\` needs.
|
|
71
|
-
permissions: {}
|
|
72
|
-
|
|
73
|
-
concurrency:
|
|
74
|
-
group: automerge-\${{ github.ref }}
|
|
75
|
-
cancel-in-progress: true
|
|
76
|
-
`;
|
|
77
|
-
|
|
78
|
-
const JOB_NOTE = ` # ⛔ BOTH LOGINS, NOT EITHER. \`user.login\` is who opened the pull request; \`github.actor\` is
|
|
79
|
-
# who started this run. A person pushing to Dependabot's branch changes the actor, so
|
|
80
|
-
# their commit never arms anything; only Dependabot's own rebases do.`;
|
|
81
|
-
|
|
82
|
-
const PERMISSIONS_NOTE = ` # ★ WHAT GitHub'S OWN EXAMPLE GRANTS FOR THIS STEP, AND NO MORE. A run Dependabot starts gets
|
|
83
|
-
# a read-only GITHUB_TOKEN unless the workflow raises it ("Troubleshooting Dependabot on
|
|
84
|
-
# GitHub Actions" → "Changing GITHUB_TOKEN permissions").`;
|
|
85
|
-
|
|
86
|
-
const ARM = `set -euo pipefail
|
|
87
|
-
if [[ ! "$HEAD_REF" =~ ${GROUP_BRANCH} ]]; then
|
|
88
|
-
echo "::notice::$HEAD_REF is not the ${HOMEFLARE_GROUP} group's branch; auto-merge not armed"
|
|
89
|
-
exit 0
|
|
90
|
-
fi
|
|
91
|
-
# ⛔ gh merges a CLEAN or UNSTABLE pull request at once rather than arming it. Only a branch
|
|
92
|
-
# rule that requires a status check keeps it BLOCKED until the checks have passed.
|
|
93
|
-
required=$(gh api "repos/$GITHUB_REPOSITORY/rules/branches/$BASE_REF" \\
|
|
94
|
-
--jq '[.[] | select(.type == "required_status_checks")] | length')
|
|
95
|
-
if [ "$required" = 0 ]; then
|
|
96
|
-
echo "::error::$BASE_REF requires no status check, so gh would merge at once; auto-merge not armed"
|
|
97
|
-
exit 1
|
|
98
|
-
fi
|
|
99
|
-
# ★ --squash: the house repositories allow squash merges only (declareRepoPolicy).
|
|
100
|
-
gh pr merge --auto --squash "$PR_URL"`;
|
|
101
|
-
|
|
102
|
-
/** The whole `dependabot-automerge.yml` for a shape. */
|
|
103
|
-
export function renderAutomerge(shape: RepoShape): string {
|
|
104
|
-
// ⚠️ GitHub ACTIONS EXPRESSIONS, NOT TEMPLATE LITERALS: the runner interpolates `${{ … }}`
|
|
105
|
-
// at job time, so they must reach the file verbatim (see security.ts for the same note).
|
|
106
|
-
// oxlint-disable-next-line no-template-curly-in-string
|
|
107
|
-
const headRef = '${{ github.head_ref }}';
|
|
108
|
-
// oxlint-disable-next-line no-template-curly-in-string
|
|
109
|
-
const baseRef = '${{ github.base_ref }}';
|
|
110
|
-
// oxlint-disable-next-line no-template-curly-in-string
|
|
111
|
-
const prUrl = '${{ github.event.pull_request.html_url }}';
|
|
112
|
-
// oxlint-disable-next-line no-template-curly-in-string
|
|
113
|
-
const token = '${{ secrets.GITHUB_TOKEN }}';
|
|
114
|
-
const condition = [
|
|
115
|
-
"github.event.pull_request.user.login == 'dependabot[bot]'",
|
|
116
|
-
"github.actor == 'dependabot[bot]'",
|
|
117
|
-
`startsWith(github.head_ref, '${GROUP_BRANCH_PREFIX}')`,
|
|
118
|
-
].join(' && ');
|
|
119
|
-
|
|
120
|
-
return `${HEADER}
|
|
121
|
-
${TRIGGER}
|
|
122
|
-
jobs:
|
|
123
|
-
${JOB_NOTE}
|
|
124
|
-
arm:
|
|
125
|
-
name: arm auto-merge
|
|
126
|
-
if: ${condition}
|
|
127
|
-
runs-on: ${runsOn(shape.runner)}
|
|
128
|
-
${PERMISSIONS_NOTE}
|
|
129
|
-
permissions:
|
|
130
|
-
contents: write
|
|
131
|
-
pull-requests: write
|
|
132
|
-
steps:
|
|
133
|
-
${renderSteps(
|
|
134
|
-
[
|
|
135
|
-
{
|
|
136
|
-
env: { BASE_REF: baseRef, GH_TOKEN: token, HEAD_REF: headRef, PR_URL: prUrl },
|
|
137
|
-
name: 'Arm auto-merge on the homeflare group',
|
|
138
|
-
run: ARM,
|
|
139
|
-
},
|
|
140
|
-
],
|
|
141
|
-
3,
|
|
142
|
-
)}
|
|
143
|
-
`;
|
|
144
|
-
}
|