@homeflare/alchemy 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Everything `repoPolicy` refuses, and why. Extracted from repo-policy-form.ts so the
3
+ * shape stays readable and the reasoning stays whole.
4
+ *
5
+ * ⛔ FIVE DIFFERENT DOORS LEAD TO THE SAME FAILURE — auto-merge with nothing to wait for.
6
+ * `gh pr merge --auto` is a queue only while something is outstanding; with nothing
7
+ * outstanding GitHub merges the pull request on the spot. An empty `checks` is the
8
+ * obvious door. `enforcement: 'disabled'` or `'evaluate'` is the quiet one: the ruleset
9
+ * exists, the required checks are listed on it, and none of them block anything. Then
10
+ * three ways to end up with a ruleset that matches NO REF — which enforces on no ref
11
+ * while the GitHub UI still shows it as `active`: an empty `include`, a blank ref
12
+ * pattern, and an `exclude` that cancels every `include`. All five are refused.
13
+ *
14
+ * ★ THE REF DOORS WERE FOUND BY ADVERSARIAL REVIEW, NOT BY DESIGN. The first cut of this
15
+ * module guarded `include.length === 0` and spread `exclude` raw, so
16
+ * `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` and `include: [' ']`
17
+ * both sailed through with `allowAutoMerge: true` — the exact end state the empty-array
18
+ * guard exists to prevent, reached by a door beside it. Checked by execution, not by
19
+ * reading: see the `matches no ref` tests in repo-policy-form.test.ts.
20
+ */
21
+
22
+ /** A non-empty, trimmed identifier, or an error naming the field. */
23
+ export function requireName(field: string, value: string): string {
24
+ const trimmed = value.trim();
25
+ if (trimmed === '') throw new Error(`repoPolicy: ${field} is required`);
26
+ return trimmed;
27
+ }
28
+
29
+ /**
30
+ * Trim, de-duplicate, sort. ★ The sort is what makes a second deploy a no-op: GitHub
31
+ * stores the contexts in the order they were sent, so an unsorted list would diff against
32
+ * itself the first time a caller wrote the same contexts in a different order.
33
+ */
34
+ export function normalizeChecks(checks: readonly string[]): string[] {
35
+ const seen = new Set<string>();
36
+ for (const raw of checks) {
37
+ const context = raw.trim();
38
+ // ⚠️ A blank context is a typo that reads as coverage: GitHub happily stores it and
39
+ // no job ever reports it, so every pull request waits on a check that cannot come.
40
+ if (context === '') {
41
+ throw new Error(
42
+ 'repoPolicy: a required check context is blank — name it exactly as the job reports it',
43
+ );
44
+ }
45
+ seen.add(context);
46
+ }
47
+ return [...seen].sort();
48
+ }
49
+
50
+ /**
51
+ * Trim, de-duplicate, sort one list of ref patterns, refusing a blank one.
52
+ *
53
+ * ⚠️ A BLANK PATTERN IS NOT AN EMPTY LIST, AND THAT IS THE WHOLE TRAP. `[' ']` has
54
+ * length 1, so a guard that counts the array waves it through; GitHub then stores a ref
55
+ * condition that matches nothing. Same end state as `[]`, reached past the guard.
56
+ * ★ Sorted for the same reason `normalizeChecks` is: GitHub echoes the patterns back in
57
+ * the order they were sent, so an unsorted list diffs against itself the first time a
58
+ * caller writes the same patterns in a different order.
59
+ */
60
+ function normalizeRefs(field: string, patterns: readonly string[]): string[] {
61
+ const seen = new Set<string>();
62
+ for (const raw of patterns) {
63
+ const pattern = raw.trim();
64
+ if (pattern === '') {
65
+ throw new Error(
66
+ `repoPolicy: a ${field} ref pattern is blank — a ruleset that matches no ref enforces nothing.`,
67
+ );
68
+ }
69
+ seen.add(pattern);
70
+ }
71
+ return [...seen].sort();
72
+ }
73
+
74
+ /**
75
+ * ⛔ AN EMPTY `include` IS REFUSED. Alchemy defaults `conditions.include` to `['~ALL']`
76
+ * when it is absent, but an explicitly empty array is sent as an empty array, and a
77
+ * ruleset that matches no ref protects nothing while still reading as `active` in the
78
+ * GitHub UI — the worst possible combination, because it looks like it worked.
79
+ */
80
+ export function normalizeInclude(
81
+ include: readonly string[] | undefined,
82
+ fallback: readonly string[],
83
+ ): string[] {
84
+ if (include === undefined) return [...fallback];
85
+ if (include.length === 0) {
86
+ throw new Error(
87
+ 'repoPolicy: include is empty — a ruleset that matches no ref enforces nothing. Omit it for the default branch.',
88
+ );
89
+ }
90
+ return normalizeRefs('include', include);
91
+ }
92
+
93
+ /**
94
+ * ⛔ AN `exclude` THAT CANCELS EVERY `include` IS REFUSED. In a GitHub ruleset the
95
+ * exclusions win, so `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` is a
96
+ * ruleset over nothing — and it reads as a narrowing, which is what makes it worse than
97
+ * the empty array: the caller believes they scoped the policy, not switched it off.
98
+ *
99
+ * ⚠️ EXACT CANCELLATION ONLY, DELIBERATELY. GitHub's patterns are globs, so deciding in
100
+ * general whether some exclude swallows some include means implementing GitHub's
101
+ * matcher and being wrong about it quietly. This refuses the case that is decidable —
102
+ * every include pattern also appears verbatim in `exclude` — and leaves
103
+ * `exclude: ['refs/heads/*']` against `include: ['~DEFAULT_BRANCH']` to the operator.
104
+ * A narrower guard that is always right beats a broad one that false-refuses.
105
+ */
106
+ export function normalizeExclude(
107
+ exclude: readonly string[] | undefined,
108
+ include: readonly string[],
109
+ ): string[] {
110
+ const patterns = exclude === undefined ? [] : normalizeRefs('exclude', exclude);
111
+ if (patterns.length === 0) return patterns;
112
+ const excluded = new Set(patterns);
113
+ if (include.every((pattern) => excluded.has(pattern))) {
114
+ throw new Error(
115
+ [
116
+ 'repoPolicy: exclude cancels every include pattern, so the ruleset matches no ref and enforces nothing.',
117
+ `include ${JSON.stringify(include)} is fully covered by exclude ${JSON.stringify(patterns)}.`,
118
+ ].join(' '),
119
+ );
120
+ }
121
+ return patterns;
122
+ }
123
+
124
+ /**
125
+ * ⛔ `0` IS REFUSED, NOT TREATED AS "NO REVIEWS". A `pull_request` rule with zero
126
+ * approvals is the solo-maintainer shape — every change through a PR, no second person
127
+ * — and it only works with `require_extra_approval_for_unattributed_changes: false`.
128
+ * alchemy@2.0.0-beta.79's `Ruleset` cannot express that field, and GitHub defaults it
129
+ * to `true` when it is omitted (measured 2026-09-17), which blocks the very PRs the
130
+ * zero was meant to let through. Omitting `requiredApprovals` declares no rule at all,
131
+ * which is a different and honest thing.
132
+ */
133
+ export function resolveApprovals(requested: number | undefined): number | undefined {
134
+ if (requested === undefined) return undefined;
135
+ if (!Number.isInteger(requested) || requested < 1) {
136
+ throw new Error(
137
+ [
138
+ 'repoPolicy: requiredApprovals must be a whole number of 1 or more.',
139
+ 'Omit it to declare no pull_request rule.',
140
+ '0 is not that: it needs require_extra_approval_for_unattributed_changes: false,',
141
+ 'which alchemy@2.0.0-beta.79 cannot send and GitHub defaults to true.',
142
+ ].join(' '),
143
+ );
144
+ }
145
+ return requested;
146
+ }
147
+
148
+ /**
149
+ * ⚠️ THE TEST IS "SOMETHING OUTSTANDING", NOT "SOME CHECKS". A required review holds a
150
+ * pull request open just as a required check does, so `requiredApprovals` with an empty
151
+ * `checks` is a legitimate policy. What is refused is auto-merge over a ruleset that
152
+ * will not hold anything back.
153
+ */
154
+ export function assertAutoMergeWaits(input: {
155
+ readonly autoMerge: boolean;
156
+ readonly checkCount: number;
157
+ readonly approvals: number | undefined;
158
+ readonly enforcement: 'active' | 'disabled' | 'evaluate';
159
+ }): void {
160
+ if (!input.autoMerge) return;
161
+ if (input.enforcement !== 'active') {
162
+ throw new Error(
163
+ [
164
+ `repoPolicy: autoMerge with enforcement '${input.enforcement}' merges every pull request immediately.`,
165
+ 'A ruleset that is not active enforces none of its rules, so auto-merge has nothing to wait for.',
166
+ 'Pass autoMerge: false while the ruleset is being evaluated.',
167
+ ].join(' '),
168
+ );
169
+ }
170
+ if (input.checkCount === 0 && input.approvals === undefined) {
171
+ throw new Error(
172
+ [
173
+ 'repoPolicy: autoMerge with nothing required merges every pull request immediately.',
174
+ 'Name at least one always-reporting check context, or ask for requiredApprovals,',
175
+ 'or pass autoMerge: false for a repository that has no green run to require yet.',
176
+ ].join(' '),
177
+ );
178
+ }
179
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The declaring half, and the two docs that carry its traps.
3
+ *
4
+ * ★ WHY TEST A DOC. Both files ship in the tarball and are the only place a consumer is
5
+ * told that the vendor ruleset adopts nothing and that auto-merge with nothing required
6
+ * merges on the spot. A silent edit that drops either warning would fail nothing else
7
+ * in this repo, and would cost the consumer a duplicate ruleset or a PR merged on red.
8
+ * ⛔ THE HAZARDS LIVE IN A SIBLING FILE, AND THE POINTER IS TESTED TOO. Extracting them
9
+ * is only safe while `repo-policy.md` still sends the reader there; a dangling pointer
10
+ * would read as "no hazard" rather than as a broken link.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { declareRepoPolicy } from './index.ts';
14
+
15
+ const doc = await Bun.file(new URL('../../docs/repo-policy.md', import.meta.url)).text();
16
+ const hazards = await Bun.file(
17
+ new URL('../../docs/repo-policy-ruleset-hazards.md', import.meta.url),
18
+ ).text();
19
+
20
+ describe('declareRepoPolicy', () => {
21
+ test('is a function, not a factory that reaches GitHub at import time', () => {
22
+ // ⚠️ Importing this module must not construct a resource or an Octokit client: the
23
+ // barrel is loaded by the consumer smoke test with no credentials in the process.
24
+ expect(typeof declareRepoPolicy).toBe('function');
25
+ expect(declareRepoPolicy.length).toBe(2);
26
+ });
27
+ });
28
+
29
+ describe('docs/repo-policy.md', () => {
30
+ test('stays under the 200-line cap', () => {
31
+ expect(doc.split('\n').length).toBeLessThanOrEqual(200);
32
+ });
33
+
34
+ test('warns that auto-merge with nothing required merges immediately', () => {
35
+ expect(doc).toContain('auto-merge');
36
+ expect(doc).toMatch(/merges? (the pull request )?immediately/i);
37
+ });
38
+
39
+ test('sends the reader to the ruleset hazards, which is where they now live', () => {
40
+ expect(doc).toContain('repo-policy-ruleset-hazards.md');
41
+ expect(doc).toMatch(/cannot be adopted/i);
42
+ });
43
+ });
44
+
45
+ describe('docs/repo-policy-ruleset-hazards.md', () => {
46
+ test('stays under the 200-line cap', () => {
47
+ expect(hazards.split('\n').length).toBeLessThanOrEqual(200);
48
+ });
49
+
50
+ test('warns that the vendor ruleset cannot be adopted and duplicates instead', () => {
51
+ expect(hazards).toContain('createRepoRuleset');
52
+ expect(hazards).toContain('rulesets');
53
+ });
54
+
55
+ test('carries the preflight that keeps a first deploy from duplicating', () => {
56
+ expect(hazards).toContain('gh api repos/<owner>/<repo>/rulesets');
57
+ });
58
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `declareRepoPolicy` — the policy from repo-policy-form.ts, as resources.
3
+ *
4
+ * const policy = yield* declareRepoPolicy('api', {
5
+ * owner: 'my-org',
6
+ * repository: 'api',
7
+ * checks: ['ci', 'secret scan'],
8
+ * });
9
+ *
10
+ * Provide `GitHub.providers()`; this subpath adds no provider of its own.
11
+ *
12
+ * ⛔ THE RULESET CANNOT BE ADOPTED, AND A FIRST DEPLOY ONTO A REPO THAT ALREADY HAS ONE
13
+ * MAKES A SECOND. Read out of alchemy@2.0.0-beta.79's own source on 2026-09-22
14
+ * (`node_modules/alchemy/lib/github/Ruleset.js`): `read` returns `undefined` whenever
15
+ * there is no prior output, and `reconcile` looks the live ruleset up only BY THE ID IN
16
+ * THAT OUTPUT — with none, it calls `createRepoRuleset` unconditionally. GitHub permits
17
+ * several rulesets with the same name on one repository, so nothing errors; the repo
18
+ * quietly ends up with two, both enforcing.
19
+ * ★ SO, BEFORE THE FIRST DEPLOY: `gh api repos/<owner>/<repo>/rulesets` and delete a
20
+ * hand-made ruleset of the same name, or give this one a `rulesetName` of its own.
21
+ * Afterwards the state carries the id and every later deploy converges that one.
22
+ * ⚠️ `GitHub.Repository` does NOT share the problem — its `reconcile` probes by name
23
+ * and converges onto whatever is live — so only half of this helper is adopt-safe,
24
+ * and the halves fail differently. See docs/repo-policy.md.
25
+ *
26
+ * ⛔ BOTH RESOURCES RETAIN. `retain` is already the vendor default for each of them; it
27
+ * is piped anyway so that a vendor change cannot quietly turn "drop this call from the
28
+ * stack" into "delete the repository" or "unprotect the default branch".
29
+ */
30
+ import { adopt } from 'alchemy/AdoptPolicy';
31
+ import * as GitHub from 'alchemy/GitHub';
32
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
33
+ import * as Effect from 'effect/Effect';
34
+ import { type RepoPolicyOptions, repoPolicy } from './repo-policy-form.ts';
35
+
36
+ export interface DeclareRepoPolicyOptions extends RepoPolicyOptions {
37
+ /**
38
+ * `true`/`false` pipe `adopt(…)` onto both resources; omitted, the deploy's own policy
39
+ * decides. ⚠️ It changes nothing about the ruleset hazard above: adoption routes on
40
+ * what a provider's `read` reports, and this one reports nothing without prior state.
41
+ */
42
+ readonly adopt?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Declares the repository and its ruleset under `<id>` and `<id>-ruleset`. Fails, before
47
+ * declaring either, on any policy the form refuses.
48
+ */
49
+ export const declareRepoPolicy = (id: string, options: DeclareRepoPolicyOptions) =>
50
+ Effect.gen(function* () {
51
+ const policy = yield* Effect.try({
52
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
53
+ try: () => repoPolicy(options),
54
+ });
55
+ // ⚠️ `never` in the error channel, not a generic `E`: `AdoptPolicy.adopt` is typed
56
+ // `Effect<A, never, R> => Effect<A, never, R>`, so a wider error type fails to
57
+ // compile here rather than at the call site. Alchemy's resources declare `never`.
58
+ const owned = <A, R>(resource: Effect.Effect<A, never, R>) => {
59
+ const kept = resource.pipe(RemovalPolicy.retain());
60
+ return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
61
+ };
62
+ const repository = yield* owned(GitHub.Repository(id, policy.repository));
63
+ const ruleset = yield* owned(GitHub.Ruleset(`${id}-ruleset`, policy.ruleset));
64
+ return { policy, repository, ruleset };
65
+ });
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * import { R2BucketLock } from '@homeflare/alchemy/cloudflare';
8
8
  * import { BranchProtection } from '@homeflare/alchemy/forgejo';
9
+ * import { declareRepoPolicy } from '@homeflare/alchemy/github';
9
10
  * import { BaoMount } from '@homeflare/alchemy/openbao';
10
11
  * import { TalosCluster } from '@homeflare/alchemy/talos';
11
12
  * import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';