@homeflare/alchemy 0.28.0 → 0.29.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/README.md +2 -0
- package/dist/caddy/admin-address.d.ts +27 -0
- package/dist/caddy/admin-address.d.ts.map +1 -0
- package/dist/caddy/admin-calls.d.ts +25 -28
- package/dist/caddy/admin-calls.d.ts.map +1 -1
- package/dist/caddy/admin.d.ts +28 -38
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/caddy-http-client.d.ts +25 -0
- package/dist/caddy/caddy-http-client.d.ts.map +1 -0
- package/dist/caddy/config-errors.d.ts +24 -0
- package/dist/caddy/config-errors.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +20 -49
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config-reconcile.d.ts +45 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +4 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +10 -2
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +3 -3
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +487 -444
- package/dist/caddy/index.js.map +16 -12
- package/dist/caddy/local-admin.d.ts +20 -34
- package/dist/caddy/local-admin.d.ts.map +1 -1
- package/dist/caddy/providers.d.ts +19 -3
- package/dist/caddy/providers.d.ts.map +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +4 -4
- package/dist/forgejo/org-actions-secrets.d.ts +4 -4
- package/dist/forgejo/org-label.d.ts +4 -4
- package/dist/forgejo/org-team.d.ts +4 -4
- package/dist/forgejo/repo-webhook.d.ts +4 -4
- package/dist/forgejo/repository.d.ts +4 -4
- package/dist/forgejo/team-member.d.ts +4 -4
- package/dist/github/declare-repo-baseline.d.ts +1 -1
- package/dist/github/declare-repo-baseline.d.ts.map +1 -1
- package/dist/github/index.d.ts +8 -4
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +83 -24
- package/dist/github/index.js.map +14 -12
- package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
- package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
- package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
- package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
- package/dist/github/repo-auto-merge-gate.d.ts +54 -0
- package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
- package/dist/github/repo-baseline-data.d.ts +10 -1
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repo-policy.d.ts +1 -1
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-errors.d.ts +3 -2
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-form.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +3 -2
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- package/dist/github/repository-ruleset-octokit.d.ts +49 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
- package/dist/github/repository-ruleset-probe.d.ts +2 -1
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
- package/dist/github/repository-ruleset-props.d.ts +7 -4
- package/dist/github/repository-ruleset-props.d.ts.map +1 -1
- package/dist/github/repository-ruleset-providers.d.ts +42 -0
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +26 -4
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts +27 -16
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/fake-stack.d.ts +9 -4
- package/dist/litellm/fake-stack.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +7 -5
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +53 -123
- package/dist/litellm/index.js.map +8 -8
- package/dist/litellm/operations.d.ts +60 -0
- package/dist/litellm/operations.d.ts.map +1 -0
- package/dist/litellm/pass-through-endpoint.d.ts +10 -28
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
- package/dist/litellm/pass-through-errors.d.ts +37 -0
- package/dist/litellm/pass-through-errors.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +11 -4
- package/dist/litellm/pass-through-form.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +7 -5
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/netbox/prefix.d.ts +4 -4
- package/dist/paperless/custom-field.d.ts +21 -21
- package/dist/paperless/custom-field.d.ts.map +1 -1
- package/dist/paperless/document-type.d.ts +34 -4
- package/dist/paperless/document-type.d.ts.map +1 -1
- package/dist/paperless/fake-paperless.d.ts +36 -19
- package/dist/paperless/fake-paperless.d.ts.map +1 -1
- package/dist/paperless/index.d.ts +7 -2
- package/dist/paperless/index.d.ts.map +1 -1
- package/dist/paperless/index.js +164 -228
- package/dist/paperless/index.js.map +10 -13
- package/dist/paperless/matching-locate.d.ts +42 -20
- package/dist/paperless/matching-locate.d.ts.map +1 -1
- package/dist/paperless/matching-types.d.ts +47 -18
- package/dist/paperless/matching-types.d.ts.map +1 -1
- package/dist/paperless/matching.d.ts +30 -27
- package/dist/paperless/matching.d.ts.map +1 -1
- package/dist/paperless/providers.d.ts +13 -7
- package/dist/paperless/providers.d.ts.map +1 -1
- package/dist/paperless/storage-path.d.ts +34 -4
- package/dist/paperless/storage-path.d.ts.map +1 -1
- package/dist/paperless/tag.d.ts +43 -8
- package/dist/paperless/tag.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts +30 -23
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/distilled-pve.d.ts +89 -0
- package/dist/proxmox/distilled-pve.d.ts.map +1 -0
- package/dist/proxmox/index.js +663 -609
- package/dist/proxmox/index.js.map +12 -11
- package/dist/proxmox/members.d.ts +29 -10
- package/dist/proxmox/members.d.ts.map +1 -1
- package/docs/caddy-admin.md +1 -1
- package/docs/caddy.md +3 -2
- package/docs/credentials.md +16 -0
- package/docs/litellm.md +21 -13
- package/docs/paperless.md +12 -0
- package/docs/peers.md +5 -0
- package/docs/repo-policy.md +16 -1
- package/docs/repository-ruleset.md +53 -13
- package/docs/upstream-conformance.md +126 -10
- package/package.json +5 -2
- package/src/caddy/admin-address.ts +71 -0
- package/src/caddy/admin-calls.ts +48 -100
- package/src/caddy/admin.ts +37 -41
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/caddy-http-client.ts +229 -0
- package/src/caddy/config-adopt-provider.test.ts +139 -0
- package/src/caddy/config-adopt.test.ts +60 -126
- package/src/caddy/config-errors.ts +47 -0
- package/src/caddy/config-lifecycle.test.ts +55 -42
- package/src/caddy/config-lifecycle.ts +72 -162
- package/src/caddy/config-reconcile.ts +138 -0
- package/src/caddy/config.ts +36 -42
- package/src/caddy/fake-caddy.ts +20 -2
- package/src/caddy/index.ts +3 -8
- package/src/caddy/local-admin.test.ts +35 -21
- package/src/caddy/local-admin.ts +60 -167
- package/src/caddy/providers.test.ts +14 -6
- package/src/caddy/providers.ts +20 -4
- package/src/github/declare-repo-baseline-apply-order.test.ts +86 -0
- package/src/github/declare-repo-baseline.ts +40 -7
- package/src/github/index.ts +8 -4
- package/src/github/repo-apply-order-fake-providers.ts +119 -0
- package/src/github/repo-apply-order-fake-stack.ts +74 -0
- package/src/github/repo-auto-merge-gate.ts +57 -0
- package/src/github/repo-baseline-data.test.ts +13 -0
- package/src/github/repo-baseline-data.ts +11 -2
- package/src/github/repo-policy-apply-order.test.ts +72 -0
- package/src/github/repo-policy-stack.test.ts +59 -0
- package/src/github/repo-policy.ts +31 -3
- package/src/github/repository-ruleset-errors.ts +6 -4
- package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
- package/src/github/repository-ruleset-form.ts +7 -3
- package/src/github/repository-ruleset-guards.ts +3 -2
- package/src/github/repository-ruleset-octokit.test.ts +141 -0
- package/src/github/repository-ruleset-octokit.ts +121 -30
- package/src/github/repository-ruleset-probe.ts +2 -1
- package/src/github/repository-ruleset-props.ts +7 -4
- package/src/github/repository-ruleset-providers.ts +46 -0
- package/src/github/repository-ruleset-wire.test.ts +38 -27
- package/src/github/repository-ruleset.ts +27 -3
- package/src/litellm/fake-litellm.ts +83 -78
- package/src/litellm/fake-stack.ts +13 -5
- package/src/litellm/index.ts +7 -21
- package/src/litellm/operations.ts +149 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
- package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
- package/src/litellm/pass-through-endpoint.test.ts +8 -8
- package/src/litellm/pass-through-endpoint.ts +21 -31
- package/src/litellm/pass-through-errors.ts +37 -0
- package/src/litellm/pass-through-form.ts +20 -10
- package/src/litellm/providers.ts +8 -7
- package/src/paperless/custom-field.test.ts +49 -40
- package/src/paperless/custom-field.ts +57 -33
- package/src/paperless/document-type.test.ts +48 -0
- package/src/paperless/document-type.ts +51 -22
- package/src/paperless/fake-paperless.ts +63 -72
- package/src/paperless/index.ts +7 -13
- package/src/paperless/matching-locate.test.ts +102 -0
- package/src/paperless/matching-locate.ts +80 -86
- package/src/paperless/matching-types.ts +49 -16
- package/src/paperless/matching.ts +77 -90
- package/src/paperless/providers.ts +14 -10
- package/src/paperless/storage-path.test.ts +56 -0
- package/src/paperless/storage-path.ts +53 -24
- package/src/paperless/tag-identity.test.ts +127 -0
- package/src/paperless/tag.test.ts +100 -0
- package/src/paperless/tag.ts +62 -32
- package/src/proxmox/acl.test.ts +145 -0
- package/src/proxmox/acl.ts +128 -150
- package/src/proxmox/distilled-pve.test.ts +231 -0
- package/src/proxmox/distilled-pve.ts +147 -0
- package/src/proxmox/members-timeout.test.ts +161 -0
- package/src/proxmox/members.test.ts +9 -0
- package/src/proxmox/members.ts +36 -2
- package/dist/litellm/client.d.ts +0 -76
- package/dist/litellm/client.d.ts.map +0 -1
- package/dist/litellm/credentials.d.ts +0 -48
- package/dist/litellm/credentials.d.ts.map +0 -1
- package/dist/litellm/generated/pass-through.d.ts +0 -71
- package/dist/litellm/generated/pass-through.d.ts.map +0 -1
- package/dist/paperless/client.d.ts +0 -37
- package/dist/paperless/client.d.ts.map +0 -1
- package/dist/paperless/credentials.d.ts +0 -40
- package/dist/paperless/credentials.d.ts.map +0 -1
- package/dist/paperless/errors.d.ts +0 -49
- package/dist/paperless/errors.d.ts.map +0 -1
- package/src/litellm/client.ts +0 -195
- package/src/litellm/credentials.ts +0 -73
- package/src/litellm/generated/pass-through.ts +0 -73
- package/src/paperless/client.ts +0 -88
- package/src/paperless/credentials.ts +0 -69
- package/src/paperless/errors.ts +0 -62
- package/src/paperless/matching-identity.test.ts +0 -194
- package/src/paperless/matching.test.ts +0 -128
package/src/caddy/providers.ts
CHANGED
|
@@ -6,12 +6,28 @@
|
|
|
6
6
|
* ★ launchdProviders() TOO when the stack uses caddyWithFile(): the file is a launchd-subpath
|
|
7
7
|
* HostFile, written through that subpath's HostRunner.
|
|
8
8
|
* ⚠️ The default is localCaddyAdmin(): `http://127.0.0.1:2019`, Caddy's default admin listener.
|
|
9
|
-
* Pass another
|
|
9
|
+
* Pass another CaddyTransport for a unix socket, a narrowed `admin { origins }`, or an SSH
|
|
10
|
+
* forward — `localCaddyAdmin({ address, hostHeader })` builds one; a consumer can provide any
|
|
11
|
+
* other `{ endpoint, listener, layer }` reaching `@distilled.cloud/caddy`'s own `Credentials`
|
|
12
|
+
* and `HttpClient.HttpClient`.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ `Layer.provideMerge`, NEVER PLAIN `Layer.provide`, for `caddyAdminLayer`. `CaddyConfigProvider()`'s
|
|
15
|
+
* `read`/`diff`/`reconcile` handlers each still need `CaddyAdminService` and
|
|
16
|
+
* `@distilled.cloud/caddy`'s `Credentials`/`HttpClient` — Alchemy's own `Provider.effect` types
|
|
17
|
+
* those as part of the LAYER's overall requirement (`ProviderService`'s per-handler `Req` type
|
|
18
|
+
* parameters, `alchemy/Provider.ts`), for exactly this reason. Plain `Layer.provide` SEALS a
|
|
19
|
+
* layer — it satisfies `CaddyConfigProvider()`'s requirement only for BUILDING the `Provider`
|
|
20
|
+
* value (the `admin` in its outer `Effect.gen`), then hides those services from anything that
|
|
21
|
+
* runs LATER, when the returned `read`/`diff`/`reconcile` FUNCTIONS actually execute. MEASURED
|
|
22
|
+
* 2026-09-23: with plain `Layer.provide`, `caddyProviders()`'s own `reconcile` handler dies with
|
|
23
|
+
* "Service not found: homeflare/caddy/CaddyAdmin" the moment the ENGINE calls it. `provideMerge`
|
|
24
|
+
* feeds `caddyAdminLayer`'s output into `CaddyConfigProvider()`'s requirement AND keeps that same
|
|
25
|
+
* output live in the result, so every later handler call still runs with it in context.
|
|
10
26
|
*/
|
|
11
27
|
import * as Layer from 'effect/Layer';
|
|
12
|
-
import { type
|
|
28
|
+
import { type CaddyTransport, caddyAdminLayer } from './admin.ts';
|
|
13
29
|
import { CaddyConfigProvider } from './config.ts';
|
|
14
30
|
import { localCaddyAdmin } from './local-admin.ts';
|
|
15
31
|
|
|
16
|
-
export const caddyProviders = (
|
|
17
|
-
CaddyConfigProvider().pipe(Layer.
|
|
32
|
+
export const caddyProviders = (transport: CaddyTransport = localCaddyAdmin()) =>
|
|
33
|
+
CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(transport)));
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declareRepoBaseline` through Alchemy's own Plan+Apply (repo-apply-order-fake-stack.ts) — not
|
|
3
|
+
* the pure form functions — proving the K5 apply-ordering fix (repo-auto-merge-gate.ts) actually
|
|
4
|
+
* changes what the ENGINE schedules, the same reasoning `litellm/pass-through-endpoint-replace.
|
|
5
|
+
* test.ts`'s header gives for driving the engine instead of asserting a `diff` answer.
|
|
6
|
+
*
|
|
7
|
+
* ★ THE "TURNING ON" TESTS DEPLOY TWICE, ON PURPOSE. K5's own scenario is literally "a repo
|
|
8
|
+
* MOVES from checks:[] to checks:['x']" — an UPDATE to an already-live repo and ruleset, not a
|
|
9
|
+
* cold create. A first version of this file proved the ordering fix with a single deploy from
|
|
10
|
+
* empty state instead (an ADVERSARIAL REVIEW, 2026-09-24, caught this: that single-deploy
|
|
11
|
+
* shape is a cold create, and the fake ruleset provider back then had no notion that a real
|
|
12
|
+
* ruleset create 404s against a repository that does not exist yet — so the test could not
|
|
13
|
+
* have caught the ordering fix forcing exactly that impossible order on a genuine cold create).
|
|
14
|
+
* Deploying `checks: []` first, then `checks: ['ci']`, makes both resources already live by
|
|
15
|
+
* the time the gate kicks in — the actual K5 shape, and no longer a cold create.
|
|
16
|
+
*/
|
|
17
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import { declareRepoBaseline } from './declare-repo-baseline.ts';
|
|
19
|
+
import { type World, fakeGithubProviders, makeWorld } from './repo-apply-order-fake-providers.ts';
|
|
20
|
+
import { fakeStack } from './repo-apply-order-fake-stack.ts';
|
|
21
|
+
|
|
22
|
+
let world: World;
|
|
23
|
+
let stack: ReturnType<typeof fakeStack>;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
world = makeWorld();
|
|
26
|
+
stack = fakeStack(fakeGithubProviders(world));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const declare = (checks: readonly string[]) =>
|
|
30
|
+
declareRepoBaseline('acme', {
|
|
31
|
+
owner: 'taslabs-net',
|
|
32
|
+
repository: 'acme',
|
|
33
|
+
checks,
|
|
34
|
+
visibility: 'private',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('an existing repo moving from checks: [] to checks: ["ci"]', () => {
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
// Establish both as already-live, exactly like a repo baselined with no checks yet.
|
|
40
|
+
await stack.deploy(declare([]));
|
|
41
|
+
world.calls.length = 0;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the update that turns auto-merge on applies the ruleset before the repository', async () => {
|
|
45
|
+
const planned = await stack.deploy(declare(['ci']));
|
|
46
|
+
|
|
47
|
+
expect(planned).toEqual({ acme: 'update', 'acme-ruleset': 'update' });
|
|
48
|
+
expect(world.calls).toEqual(['ruleset', 'repository']);
|
|
49
|
+
// The value sent is still exactly `true` — only the ordering changed.
|
|
50
|
+
expect(world.repositoryAllowAutoMerge).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a ruleset apply failure leaves allowAutoMerge untouched', async () => {
|
|
54
|
+
world.rulesetShouldFail = true;
|
|
55
|
+
|
|
56
|
+
await expect(stack.deploy(declare(['ci']))).rejects.toBeDefined();
|
|
57
|
+
|
|
58
|
+
// The repository's `reconcile` never ran at all — blocked on the ruleset's Output, which
|
|
59
|
+
// never resolved — so `allowAutoMerge` was never even sent, let alone turned on with no
|
|
60
|
+
// required check live. It is still `false` from the first deploy above.
|
|
61
|
+
expect(world.calls).toEqual(['ruleset']);
|
|
62
|
+
expect(world.repositoryAllowAutoMerge).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('auto-merge already/staying off (checks: [])', () => {
|
|
67
|
+
test('declares both with no ordering edge forced between them', async () => {
|
|
68
|
+
const planned = await stack.deploy(declare([]));
|
|
69
|
+
|
|
70
|
+
expect(planned).toEqual({ acme: 'create', 'acme-ruleset': 'create' });
|
|
71
|
+
expect(world.calls.sort()).toEqual(['repository', 'ruleset']);
|
|
72
|
+
// `allowAutoMerge` was sent as a plain `false`, never gated on the ruleset.
|
|
73
|
+
expect(world.repositoryAllowAutoMerge).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('a repo and ruleset created together for the first time, checks from day one', () => {
|
|
78
|
+
test('fails clearly instead of silently fail-open (known, documented limit — see file header)', async () => {
|
|
79
|
+
await expect(stack.deploy(declare(['ci']))).rejects.toThrow(/does not exist yet/);
|
|
80
|
+
|
|
81
|
+
// The ruleset's own create is what refuses — never the repository's, and never a state
|
|
82
|
+
// where allowAutoMerge went live with nothing to wait on.
|
|
83
|
+
expect(world.calls).toEqual(['ruleset']);
|
|
84
|
+
expect(world.repositoryAllowAutoMerge).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -8,16 +8,34 @@
|
|
|
8
8
|
* visibility: 'private',
|
|
9
9
|
* });
|
|
10
10
|
*
|
|
11
|
-
* Provide `
|
|
12
|
-
* provider of its own, the same as `declareRepoPolicy`. Both resources
|
|
13
|
-
* `adopt(true)` ALWAYS, unlike `declareRepoPolicy`'s optional `adopt` — a
|
|
14
|
-
* shared is meant for a checkout adopting the estate's existing repository
|
|
15
|
-
* never for creating one out from under a caller who forgot the flag (H1: a
|
|
16
|
-
* ours until adopted).
|
|
11
|
+
* Provide `repoPolicyProviders()` (repository-ruleset-providers.ts) — this subpath ships no
|
|
12
|
+
* provider of its own beyond the ruleset bridge, the same as `declareRepoPolicy`. Both resources
|
|
13
|
+
* are `retain` and `adopt(true)` ALWAYS, unlike `declareRepoPolicy`'s optional `adopt` — a
|
|
14
|
+
* baseline this widely shared is meant for a checkout adopting the estate's existing repository
|
|
15
|
+
* and ruleset by name, never for creating one out from under a caller who forgot the flag (H1: a
|
|
16
|
+
* name match is not ours until adopted).
|
|
17
17
|
*
|
|
18
18
|
* ⛔ NO IMPORT FROM `@homeflare/config`, either direction — see `renderRepoShape`'s own header
|
|
19
19
|
* in `packages/config/src/repo-shape/render.ts`. A caller spreads `renderRepoShape(shape)
|
|
20
20
|
* .policy` into these options itself; `checks` only needs to be `readonly string[]`.
|
|
21
|
+
*
|
|
22
|
+
* ⛔ APPLY ORDER: `allowAutoMerge` NEVER GOES LIVE BEFORE THE RULESET IT WAITS ON. When `checks`
|
|
23
|
+
* is non-empty, the ruleset is declared and applied FIRST, and the repository's `allowAutoMerge`
|
|
24
|
+
* is threaded through `gateAutoMergeOnRuleset` (repo-auto-merge-gate.ts) so Alchemy's own
|
|
25
|
+
* Output-in-props ordering — not declaration order — makes the engine wait for it. See that
|
|
26
|
+
* file's header for the full mechanism, the K5 hazard it closes, and why the reverse direction
|
|
27
|
+
* (checks removed) needs no matching edge.
|
|
28
|
+
* ⚠️ A REPOSITORY THAT DOES NOT YET EXIST: `adopt(true)` above does NOT refuse to create — with
|
|
29
|
+
* nothing live to adopt, Alchemy creates from scratch regardless (`AdoptPolicy`'s own contract:
|
|
30
|
+
* `undefined` observed state means create, adopt policy or not). The paragraph above says this
|
|
31
|
+
* baseline is "never for creating one out from under a caller who forgot the flag", but nothing
|
|
32
|
+
* enforces that; it is the caller's discipline, not this function's. If it IS ever called for a
|
|
33
|
+
* repo+ruleset that do not exist yet with `checks` non-empty from day one, the forced
|
|
34
|
+
* ruleset-first order above now makes the ruleset's create fail — GitHub's own ruleset API
|
|
35
|
+
* 404s on a repository that does not exist — instead of racing. That failure is explicit and
|
|
36
|
+
* tested (declare-repo-baseline-apply-order.test.ts's "created together" case), not a silent
|
|
37
|
+
* fail-open, but it is still a real usability regression for that one scenario: create the bare
|
|
38
|
+
* repository first (a prior deploy, or a separate `GitHub.Repository` call).
|
|
21
39
|
*/
|
|
22
40
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
23
41
|
import * as GitHub from 'alchemy/GitHub';
|
|
@@ -28,6 +46,7 @@ import {
|
|
|
28
46
|
repoBaselineRuleset,
|
|
29
47
|
repoBaselineSettings,
|
|
30
48
|
} from './repo-baseline-data.ts';
|
|
49
|
+
import { gateAutoMergeOnRuleset } from './repo-auto-merge-gate.ts';
|
|
31
50
|
import { RepositoryRuleset } from './repository-ruleset.ts';
|
|
32
51
|
|
|
33
52
|
export interface DeclareRepoBaselineOptions extends RepoBaselineInput {}
|
|
@@ -36,7 +55,21 @@ export const declareRepoBaseline = (id: string, options: DeclareRepoBaselineOpti
|
|
|
36
55
|
Effect.gen(function* () {
|
|
37
56
|
const owned = <A, R>(resource: Effect.Effect<A, never, R>) =>
|
|
38
57
|
resource.pipe(RemovalPolicy.retain(), adopt(true));
|
|
39
|
-
const
|
|
58
|
+
const settings = repoBaselineSettings(options);
|
|
59
|
+
|
|
60
|
+
// Auto-merge is turning on (or staying on): declare and apply the ruleset FIRST, so its
|
|
61
|
+
// rulesetId is available to gate the repository's allowAutoMerge — see the file header.
|
|
62
|
+
if (settings.allowAutoMerge) {
|
|
63
|
+
const ruleset = yield* owned(
|
|
64
|
+
RepositoryRuleset(`${id}-ruleset`, repoBaselineRuleset(options)),
|
|
65
|
+
);
|
|
66
|
+
const repository = yield* owned(
|
|
67
|
+
GitHub.Repository(id, gateAutoMergeOnRuleset(settings, ruleset)),
|
|
68
|
+
);
|
|
69
|
+
return { repository, ruleset };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const repository = yield* owned(GitHub.Repository(id, settings));
|
|
40
73
|
const ruleset = yield* owned(RepositoryRuleset(`${id}-ruleset`, repoBaselineRuleset(options)));
|
|
41
74
|
return { repository, ruleset };
|
|
42
75
|
});
|
package/src/github/index.ts
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* one bridge resource this house builds atop upstream GitHub: `GitHub.RepositoryRuleset`.
|
|
4
4
|
*
|
|
5
5
|
* ⚠️ `declareRepoPolicy`/`declareRepoBaseline` COMPOSE UPSTREAM `GitHub.Repository` PLUS THIS
|
|
6
|
-
* SUBPATH'S OWN `RepositoryRulesetProvider()` — provide
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* SUBPATH'S OWN `RepositoryRulesetProvider()` — provide `repoPolicyProviders()`
|
|
7
|
+
* (repository-ruleset-providers.ts), the composed `GitHub.providers()` +
|
|
8
|
+
* `RepositoryRulesetProvider()` layer that actually typechecks (2026-09-24: writing the two
|
|
9
|
+
* side by side leaves `RepositoryRulesetProvider()`'s own `GitHubCredentials` requirement
|
|
10
|
+
* unfed). Before 2026-09-23 this subpath shipped no provider at all, composing only vendor
|
|
11
|
+
* resources; `RepositoryRuleset` is the one exception, and repository-ruleset.ts's header
|
|
12
|
+
* says why upstream's own `Ruleset` cannot fill the gap.
|
|
10
13
|
* ★ WHY THE POLICY HELPERS EXIST. "Open a pull request, let auto-merge land it" is safe only
|
|
11
14
|
* when five repository properties and four ruleset rules all agree, and every way of getting
|
|
12
15
|
* one of them wrong is silent — an auto-merge that lands on red, a force push that rewrites
|
|
@@ -42,6 +45,7 @@ export {
|
|
|
42
45
|
type RepositoryRulesetRules,
|
|
43
46
|
type RepositoryRulesetStatusChecksRule,
|
|
44
47
|
} from './repository-ruleset.ts';
|
|
48
|
+
export { repoPolicyProviders } from './repository-ruleset-providers.ts';
|
|
45
49
|
export { RULE_TYPE_COVERAGE, type RepositoryRuleType } from './repository-ruleset-constraints.ts';
|
|
46
50
|
export {
|
|
47
51
|
BypassActorNarrowed,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fully in-memory `Provider.succeed(...)` fakes for `GitHub.Repository` and `RepositoryRuleset`
|
|
3
|
+
* — no octokit, no `GitHubCredentials`, no network. Unlike `repository-ruleset-fake-octokit.ts`
|
|
4
|
+
* (a fake HTTP client fed into the REAL provider's `reconcile`), these fakes replace the provider
|
|
5
|
+
* entirely, because what `declare-repo-baseline-apply-order.test.ts` and
|
|
6
|
+
* `repo-policy-apply-order.test.ts` measure is the ENGINE's own scheduling — which resource's
|
|
7
|
+
* `reconcile` runs first, and whether one's failure reaches the other — not GitHub's wire
|
|
8
|
+
* protocol. `Provider.succeed`'s runtime lookup (`tryFindProviderRegistrationByType`,
|
|
9
|
+
* `node_modules/alchemy/src/Provider.ts`) checks the INDIVIDUAL `Provider<R>` tag before falling
|
|
10
|
+
* back to a `GitHub.Providers` collection, so registering these two standalone providers is
|
|
11
|
+
* enough regardless of `GitHub.Repository`'s own declared `Providers` slot — the same shortcut
|
|
12
|
+
* `RepositoryRulesetProvider()` already relies on.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ NO LIVE NETWORK, EVER. Nothing here imports `octokit`/`GitHubCredentials`, so a test built
|
|
15
|
+
* on this file cannot make a real GitHub call even by accident — required by this PR's own
|
|
16
|
+
* "no live GitHub writes" rule.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ THE FAKE RULESET REFUSES A REPO THAT DOESN'T EXIST YET, ON PURPOSE — mirroring GitHub's own
|
|
19
|
+
* `POST /repos/{owner}/{repo}/rulesets`, which 404s on a repo that hasn't been created. A first
|
|
20
|
+
* ADVERSARIAL REVIEW pass (2026-09-24) on this PR found that the ordering fix forces the
|
|
21
|
+
* ruleset to apply before the repository whenever auto-merge turns on — safe for an UPDATE
|
|
22
|
+
* (the repo already exists), but a real hazard for a repo+ruleset created together for the
|
|
23
|
+
* FIRST time with checks set from day one, and the original version of this fake modeled no
|
|
24
|
+
* such constraint, so its own tests could not have caught that regression. It now can — see
|
|
25
|
+
* `declare-repo-baseline-apply-order.test.ts`'s "a repo and ruleset created together" test.
|
|
26
|
+
*/
|
|
27
|
+
import * as GitHub from 'alchemy/GitHub';
|
|
28
|
+
import * as Provider from 'alchemy/Provider';
|
|
29
|
+
import * as Effect from 'effect/Effect';
|
|
30
|
+
import * as Layer from 'effect/Layer';
|
|
31
|
+
import { RepositoryRuleset, type RepositoryRulesetProps } from './repository-ruleset.ts';
|
|
32
|
+
|
|
33
|
+
/** One resource type's name, in the order its fake `reconcile` was actually invoked. */
|
|
34
|
+
export type CallLog = ('repository' | 'ruleset')[];
|
|
35
|
+
|
|
36
|
+
export interface World {
|
|
37
|
+
readonly calls: CallLog;
|
|
38
|
+
/** `owner/name` of every repository the fake repository provider has reconciled at least
|
|
39
|
+
* once — what the fake ruleset provider checks before allowing a create. */
|
|
40
|
+
readonly liveRepositories: Set<string>;
|
|
41
|
+
/** Set before `deploy` to make the ruleset's `reconcile` fail instead of succeeding, even
|
|
42
|
+
* against a repository already in `liveRepositories`. */
|
|
43
|
+
rulesetShouldFail: boolean;
|
|
44
|
+
/** `news.allowAutoMerge` as the repository's fake `reconcile` last observed it — `undefined`
|
|
45
|
+
* until (unless) the repository's `reconcile` actually runs. */
|
|
46
|
+
repositoryAllowAutoMerge: boolean | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const makeWorld = (): World => ({
|
|
50
|
+
calls: [],
|
|
51
|
+
liveRepositories: new Set(),
|
|
52
|
+
rulesetShouldFail: false,
|
|
53
|
+
repositoryAllowAutoMerge: undefined,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
let sequence = 0;
|
|
57
|
+
const nextId = () => {
|
|
58
|
+
sequence += 1;
|
|
59
|
+
return sequence;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const fakeRepositoryProvider = (world: World) =>
|
|
63
|
+
Provider.succeed(GitHub.Repository, {
|
|
64
|
+
list: () => Effect.succeed([]),
|
|
65
|
+
reconcile: ({ news }: { readonly news: GitHub.RepositoryProps }) =>
|
|
66
|
+
Effect.sync(() => {
|
|
67
|
+
world.calls.push('repository');
|
|
68
|
+
world.repositoryAllowAutoMerge = news.allowAutoMerge;
|
|
69
|
+
const id = nextId();
|
|
70
|
+
const fullName = `${news.owner}/${news.name}`;
|
|
71
|
+
world.liveRepositories.add(fullName);
|
|
72
|
+
return {
|
|
73
|
+
repoId: id,
|
|
74
|
+
nodeId: `repo-node-${id}`,
|
|
75
|
+
fullName,
|
|
76
|
+
htmlUrl: `https://github.com/${fullName}`,
|
|
77
|
+
gitUrl: `git://github.com/${fullName}.git`,
|
|
78
|
+
sshUrl: `git@github.com:${fullName}.git`,
|
|
79
|
+
cloneUrl: `https://github.com/${fullName}.git`,
|
|
80
|
+
defaultBranch: 'main',
|
|
81
|
+
createdAt: new Date(0).toISOString(),
|
|
82
|
+
updatedAt: new Date(0).toISOString(),
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
85
|
+
delete: () => Effect.void,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const fakeRulesetProvider = (world: World) =>
|
|
89
|
+
Provider.succeed(RepositoryRuleset, {
|
|
90
|
+
list: () => Effect.succeed([]),
|
|
91
|
+
reconcile: ({ news }: { readonly news: RepositoryRulesetProps }) =>
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
world.calls.push('ruleset');
|
|
94
|
+
if (world.rulesetShouldFail) {
|
|
95
|
+
return yield* Effect.fail(new Error('fake ruleset apply failure'));
|
|
96
|
+
}
|
|
97
|
+
const fullName = `${news.owner}/${news.repository}`;
|
|
98
|
+
if (!world.liveRepositories.has(fullName)) {
|
|
99
|
+
// The real `POST /repos/{owner}/{repo}/rulesets` 404s here — the target repository
|
|
100
|
+
// does not exist yet. See this file's header.
|
|
101
|
+
return yield* Effect.fail(
|
|
102
|
+
new Error(`fake GitHub 404: repository '${fullName}' does not exist yet`),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const id = nextId();
|
|
106
|
+
return {
|
|
107
|
+
rulesetId: id,
|
|
108
|
+
nodeId: `ruleset-node-${id}`,
|
|
109
|
+
name: news.name,
|
|
110
|
+
createdAt: new Date(0).toISOString(),
|
|
111
|
+
updatedAt: new Date(0).toISOString(),
|
|
112
|
+
};
|
|
113
|
+
}),
|
|
114
|
+
delete: () => Effect.void,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** Both fakes, merged — everything `declareRepoBaseline`/`declareRepoPolicy` need. */
|
|
118
|
+
export const fakeGithubProviders = (world: World) =>
|
|
119
|
+
Layer.mergeAll(fakeRepositoryProvider(world), fakeRulesetProvider(world));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alchemy's own `Plan.make`/`apply`, run in-process over an in-memory state store, for
|
|
3
|
+
* `declare-repo-baseline-apply-order.test.ts` and `repo-policy-apply-order.test.ts`.
|
|
4
|
+
*
|
|
5
|
+
* ★ SAME SHAPE AS `litellm/fake-stack.ts` AND `openbao/fake-stack.ts` — copied, not
|
|
6
|
+
* reinvented: those two files' own headers explain why the ENGINE has to run (a `diff`/`props`
|
|
7
|
+
* answer proves nothing about what Apply actually schedules) rather than calling provider
|
|
8
|
+
* handlers directly, which is exactly what this PR needs to prove about apply ORDER. The casts
|
|
9
|
+
* below sit at the same typed boundary theirs do — `Alchemy.Stack` types its body against
|
|
10
|
+
* every service a CLI run provides; this harness's body only ever needs the providers given.
|
|
11
|
+
*/
|
|
12
|
+
import * as Alchemy from 'alchemy';
|
|
13
|
+
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
14
|
+
import { provideFreshArtifactStore } from 'alchemy/Artifacts';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import * as Layer from 'effect/Layer';
|
|
17
|
+
|
|
18
|
+
export type Planned = Readonly<Record<string, string>>;
|
|
19
|
+
export type StackBody = Effect.Effect<unknown, unknown, unknown>;
|
|
20
|
+
export type DeployOptions = { readonly adopt?: boolean };
|
|
21
|
+
|
|
22
|
+
export interface FakeStack {
|
|
23
|
+
/** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
|
|
24
|
+
readonly deploy: (body: StackBody, options?: DeployOptions) => Promise<Planned>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Node = { readonly action: string };
|
|
28
|
+
type PlanView = {
|
|
29
|
+
readonly resources: Readonly<Record<string, Node>>;
|
|
30
|
+
readonly deletions: Readonly<Record<string, Node | undefined>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const actionsOf = (plan: PlanView): Planned => {
|
|
34
|
+
const out: Record<string, string> = {};
|
|
35
|
+
for (const [fqn, node] of Object.entries(plan.resources)) out[fqn] = node.action;
|
|
36
|
+
for (const [fqn, node] of Object.entries(plan.deletions)) {
|
|
37
|
+
if (node !== undefined) out[fqn] = node.action;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const fakeStack = <ROut, E, RIn>(
|
|
43
|
+
providers: Layer.Layer<ROut, E, RIn>,
|
|
44
|
+
name = 'RepoApplyOrderStack',
|
|
45
|
+
): FakeStack => {
|
|
46
|
+
const state = Alchemy.inMemoryState();
|
|
47
|
+
const stack = Alchemy.Stack as unknown as (
|
|
48
|
+
stackName: string,
|
|
49
|
+
options: { providers: typeof providers; state: typeof state },
|
|
50
|
+
body: StackBody,
|
|
51
|
+
) => Effect.Effect<{ readonly services: never }, unknown, never>;
|
|
52
|
+
const plan = Alchemy.Plan.make as unknown as (
|
|
53
|
+
compiled: unknown,
|
|
54
|
+
) => Effect.Effect<PlanView, unknown, never>;
|
|
55
|
+
const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
|
|
56
|
+
|
|
57
|
+
const deploy = (body: StackBody, options: DeployOptions = {}) =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const compiled = yield* stack(name, { providers, state }, body);
|
|
60
|
+
return yield* Effect.gen(function* () {
|
|
61
|
+
const planned = yield* plan(compiled);
|
|
62
|
+
yield* apply(planned);
|
|
63
|
+
return actionsOf(planned);
|
|
64
|
+
}).pipe(provideFreshArtifactStore, Effect.provide(Layer.succeedContext(compiled.services)));
|
|
65
|
+
}).pipe(
|
|
66
|
+
options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
|
|
67
|
+
Effect.provideService(Alchemy.Stage, 'test'),
|
|
68
|
+
Effect.scoped,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
deploy: (body, options) => Effect.runPromise(deploy(body, options) as Effect.Effect<Planned>),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gateAutoMergeOnRuleset` — the one place `declareRepoBaseline` and `declareRepoPolicy` thread
|
|
3
|
+
* a real apply-ordering edge from `GitHub.Repository`'s `allowAutoMerge` to the sibling
|
|
4
|
+
* `RepositoryRuleset`, so a deploy can never turn auto-merge on before the ruleset's required
|
|
5
|
+
* checks/approvals are actually live.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ K5 (kit PR 216, 2026-09-24): both callers declare `GitHub.Repository` and `RepositoryRuleset`
|
|
8
|
+
* as two INDEPENDENT resources with no dependency edge between them. When a repo moves from
|
|
9
|
+
* `checks:[]` to `checks:['x']`, `allowAutoMerge` flips to `true` in the very same deploy that
|
|
10
|
+
* adds the required check. If the Repository apply lands before the Ruleset apply — or the
|
|
11
|
+
* Ruleset apply fails outright — the repo ends up with auto-merge on and nothing required: `gh
|
|
12
|
+
* pr merge --auto` merges a CLEAN pull request immediately (memory `gh-auto-merge-merges-now`).
|
|
13
|
+
* That fail-open state is exactly what K5 exists to prevent.
|
|
14
|
+
*
|
|
15
|
+
* ★ THE MECHANISM — ALCHEMY ORDERS BY OUTPUT REFERENCES IN PROPS, NOT DECLARATION ORDER.
|
|
16
|
+
* `Plan.ts` (~l.1031-1038): a resource's upstream deps are `Output.upstreamAny(resource.Props)`.
|
|
17
|
+
* `Apply.ts` (~l.760-762, ~l.909): `waitForDeps(allUpstreamFqns())` — built the same way from
|
|
18
|
+
* `Output.resolveUpstream(node.props)` — runs before `reconcile`. See the kit's own
|
|
19
|
+
* `alchemy-output-refs-order-resources` memory. `GitHub.Repository`'s upstream `RepositoryProps`
|
|
20
|
+
* has no spare field: `reconcile` (`node_modules/alchemy/src/GitHub/Repository.ts`) sends every
|
|
21
|
+
* declared prop straight to Octokit's create/update calls, so there is nothing to smuggle an
|
|
22
|
+
* unrelated Output into. `allowAutoMerge` itself is the vehicle instead: its value is still
|
|
23
|
+
* exactly `true` whenever this function is called with it already `true` — only now computed
|
|
24
|
+
* AFTER `ruleset.rulesetId` resolves via `Output.map`, instead of written as a literal. That
|
|
25
|
+
* single Output reference is enough for `Output.upstreamAny` to record the ruleset as this
|
|
26
|
+
* Repository's upstream dependency, so the engine applies the ruleset first.
|
|
27
|
+
*
|
|
28
|
+
* ★ WHY THIS NEEDS NO REVERSE-DIRECTION EDGE. The opposite transition — checks/approvals
|
|
29
|
+
* REMOVED, so `allowAutoMerge` turns off — cannot silently race the other way. Neither caller
|
|
30
|
+
* ever declares `rules.requiredStatusChecks: false` or omits `rules.pullRequest` on purpose;
|
|
31
|
+
* dropping `checks`/`requiredApprovals` from the options just OMITS the corresponding rule key.
|
|
32
|
+
* `requiredChecksOmissionRefusal` and `undeclaredLiveRuleRefusal` (repository-ruleset-guards.ts)
|
|
33
|
+
* both refuse — unconditionally, regardless of apply order — to drop a rule that is still LIVE
|
|
34
|
+
* by omission. So the worst reachable state, whichever resource applies first, is: Repository
|
|
35
|
+
* succeeds (auto-merge off, the correct direction anyway) and Ruleset refuses (the live rule
|
|
36
|
+
* stays required) — never fail-open. Proved by the ordering/failure tests beside this file.
|
|
37
|
+
*/
|
|
38
|
+
import * as Output from 'alchemy/Output';
|
|
39
|
+
|
|
40
|
+
/** The one attribute this gate needs from a declared `RepositoryRuleset`. */
|
|
41
|
+
export interface AutoMergeGateSource {
|
|
42
|
+
readonly rulesetId: Output.Output<number, never>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns `settings` unchanged when `allowAutoMerge` is falsy — nothing to gate, and every
|
|
47
|
+
* existing declaration with auto-merge off keeps its exact prior props (no diff, no replan).
|
|
48
|
+
* Otherwise returns `settings` with `allowAutoMerge` recomputed as an Output.map over
|
|
49
|
+
* `ruleset.rulesetId` — still resolves to `true`, now with a real dependency edge attached.
|
|
50
|
+
*/
|
|
51
|
+
export function gateAutoMergeOnRuleset<P extends { readonly allowAutoMerge?: boolean }>(
|
|
52
|
+
settings: P,
|
|
53
|
+
ruleset: AutoMergeGateSource,
|
|
54
|
+
): P | (Omit<P, 'allowAutoMerge'> & { readonly allowAutoMerge: Output.Output<boolean, never> }) {
|
|
55
|
+
if (!settings.allowAutoMerge) return settings;
|
|
56
|
+
return { ...settings, allowAutoMerge: Output.map(ruleset.rulesetId, () => true) };
|
|
57
|
+
}
|
|
@@ -36,6 +36,19 @@ describe('repoBaselineSettings', () => {
|
|
|
36
36
|
visibility: undefined,
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
// K5 (2026-09-24): zero required checks means `gh pr merge --auto` has nothing to wait for —
|
|
41
|
+
// GitHub merges a CLEAN pull request immediately, with no review and no green run. Regression
|
|
42
|
+
// for repoBaselineSettings hardcoding `allowAutoMerge: true` regardless of `checks`.
|
|
43
|
+
test('zero checks turns auto-merge off — nothing would ever hold a merge back', () => {
|
|
44
|
+
const settings = repoBaselineSettings({ ...input, checks: [] });
|
|
45
|
+
expect(settings.allowAutoMerge).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('at least one check keeps auto-merge on', () => {
|
|
49
|
+
const settings = repoBaselineSettings({ ...input, checks: ['ci'] });
|
|
50
|
+
expect(settings.allowAutoMerge).toBe(true);
|
|
51
|
+
});
|
|
39
52
|
});
|
|
40
53
|
|
|
41
54
|
describe('repoBaselineRuleset', () => {
|
|
@@ -21,7 +21,16 @@ export interface RepoBaselineInput {
|
|
|
21
21
|
|
|
22
22
|
/** `GitHub.Repository` fields this baseline owns. Every merge-automation caller across the 17
|
|
23
23
|
* tray checkouts uses `--squash` (measured 2026-09-23; the only non-squash mentions are
|
|
24
|
-
* GitLab-era comments in cliff.toml), so squash-only is not a per-repo choice.
|
|
24
|
+
* GitLab-era comments in cliff.toml), so squash-only is not a per-repo choice.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ K5 (2026-09-24): `allowAutoMerge` FOLLOWS `checks.length`, NOT A HARDCODED `true`. `gh pr
|
|
27
|
+
* merge --auto` (and the ruleset's own auto-merge) is a queue only while something is
|
|
28
|
+
* outstanding — with zero required status checks there is nothing to wait for, so GitHub
|
|
29
|
+
* merges a CLEAN pull request on the spot, with no review and no green run. This is the same
|
|
30
|
+
* failure `repo-policy-guards.ts`'s `assertAutoMergeWaits` refuses on the sibling `repoPolicy`
|
|
31
|
+
* path (five doors to the same "nothing outstanding" state); this baseline has no `autoMerge`
|
|
32
|
+
* opt-out prop to refuse WITH, so the fix here is unconditional rather than a thrown refusal —
|
|
33
|
+
* a repo with an empty `checks` list simply never gets auto-merge turned on by this baseline. */
|
|
25
34
|
export function repoBaselineSettings(input: RepoBaselineInput) {
|
|
26
35
|
return {
|
|
27
36
|
owner: input.owner,
|
|
@@ -31,7 +40,7 @@ export function repoBaselineSettings(input: RepoBaselineInput) {
|
|
|
31
40
|
allowSquashMerge: true,
|
|
32
41
|
allowMergeCommit: false,
|
|
33
42
|
allowRebaseMerge: false,
|
|
34
|
-
allowAutoMerge:
|
|
43
|
+
allowAutoMerge: input.checks.length > 0,
|
|
35
44
|
deleteBranchOnMerge: true,
|
|
36
45
|
hasWiki: false,
|
|
37
46
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declareRepoPolicy` through Alchemy's own Plan+Apply (repo-apply-order-fake-stack.ts) — the
|
|
3
|
+
* same K5 apply-ordering proof as declare-repo-baseline-apply-order.test.ts, for the OTHER
|
|
4
|
+
* caller of `gateAutoMergeOnRuleset`. See that file's header for why the "turning on" tests
|
|
5
|
+
* deploy twice (K5's own scenario is an update to an already-live repo+ruleset, not a cold
|
|
6
|
+
* create) and for the cold-create limitation this suite also tests explicitly.
|
|
7
|
+
*/
|
|
8
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
9
|
+
import { type World, fakeGithubProviders, makeWorld } from './repo-apply-order-fake-providers.ts';
|
|
10
|
+
import { fakeStack } from './repo-apply-order-fake-stack.ts';
|
|
11
|
+
import { declareRepoPolicy } from './repo-policy.ts';
|
|
12
|
+
|
|
13
|
+
let world: World;
|
|
14
|
+
let stack: ReturnType<typeof fakeStack>;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
world = makeWorld();
|
|
17
|
+
stack = fakeStack(fakeGithubProviders(world));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const declare = (checks: readonly string[]) =>
|
|
21
|
+
declareRepoPolicy('api', { owner: 'taslabs-net', repository: 'api', checks });
|
|
22
|
+
|
|
23
|
+
const declareOff = () =>
|
|
24
|
+
declareRepoPolicy('api', {
|
|
25
|
+
owner: 'taslabs-net',
|
|
26
|
+
repository: 'api',
|
|
27
|
+
checks: [],
|
|
28
|
+
autoMerge: false,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('an existing repo moving from checks: [] to checks: ["ci"]', () => {
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
await stack.deploy(declareOff());
|
|
34
|
+
world.calls.length = 0;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the update that turns auto-merge on applies the ruleset before the repository', async () => {
|
|
38
|
+
const planned = await stack.deploy(declare(['ci']));
|
|
39
|
+
|
|
40
|
+
expect(planned).toEqual({ api: 'update', 'api-ruleset': 'update' });
|
|
41
|
+
expect(world.calls).toEqual(['ruleset', 'repository']);
|
|
42
|
+
expect(world.repositoryAllowAutoMerge).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a ruleset apply failure leaves allowAutoMerge untouched', async () => {
|
|
46
|
+
world.rulesetShouldFail = true;
|
|
47
|
+
|
|
48
|
+
await expect(stack.deploy(declare(['ci']))).rejects.toBeDefined();
|
|
49
|
+
|
|
50
|
+
expect(world.calls).toEqual(['ruleset']);
|
|
51
|
+
expect(world.repositoryAllowAutoMerge).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('auto-merge off (autoMerge: false, no checks)', () => {
|
|
56
|
+
test('declares both with no ordering edge forced between them', async () => {
|
|
57
|
+
const planned = await stack.deploy(declareOff());
|
|
58
|
+
|
|
59
|
+
expect(planned).toEqual({ api: 'create', 'api-ruleset': 'create' });
|
|
60
|
+
expect(world.calls.sort()).toEqual(['repository', 'ruleset']);
|
|
61
|
+
expect(world.repositoryAllowAutoMerge).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('a repo and ruleset created together for the first time, checks from day one', () => {
|
|
66
|
+
test('fails clearly instead of silently fail-open (known, documented limit)', async () => {
|
|
67
|
+
await expect(stack.deploy(declare(['ci']))).rejects.toThrow(/does not exist yet/);
|
|
68
|
+
|
|
69
|
+
expect(world.calls).toEqual(['ruleset']);
|
|
70
|
+
expect(world.repositoryAllowAutoMerge).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TYPE-LEVEL test — a real `Alchemy.Stack(...)` whose body calls `declareRepoPolicy` AND
|
|
3
|
+
* `declareRepoBaseline`, wired with the documented `repoPolicyProviders()`. This is the test
|
|
4
|
+
* that would have caught 2026-09-24's bug (homeflare-builds bump PR 6, tsc TS2345):
|
|
5
|
+
* `RepositoryRuleset`'s `Resource<>` declaration put `GitHubCredentials` — not
|
|
6
|
+
* `Provider<RepositoryRuleset>` — in its 5th type parameter, so a stack BODY that only ever
|
|
7
|
+
* calls `declareRepoPolicy`/`declareRepoBaseline` failed `tsc`, and nothing in this repo's own
|
|
8
|
+
* `bun run check` exercised that path before now: every existing test in this family drives the
|
|
9
|
+
* resource's CRUD handlers directly (repository-ruleset-*.test.ts) or the pure form functions
|
|
10
|
+
* (repo-policy-form.test.ts), never a real `Alchemy.Stack(...)` body.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ BUILT, NEVER RUN. `Alchemy.Stack(...)` returns an unexecuted `Effect` — every combinator
|
|
13
|
+
* between here and a runtime (`.pipe`, `Effect.gen`, `Effect.flatMap`, `Object.assign`) is
|
|
14
|
+
* itself lazy (MEASURED by reading `alchemy/src/Stack.ts`'s own `Stack`/`make`: no
|
|
15
|
+
* `Effect.runSync`/`runPromise` anywhere in the construction path). Building the value is
|
|
16
|
+
* enough to make `tsc` check the body's inferred requirement type against `Stack`'s own
|
|
17
|
+
* `Req extends StackServices | ProviderServices` constraint — see repository-ruleset.ts's
|
|
18
|
+
* file header for what that constraint is and why `GitHubCredentials` used to fail it. This
|
|
19
|
+
* test never calls `Effect.runPromise`/`runSync`: no network, no `GitHubCredentials`
|
|
20
|
+
* resolved, no state written.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ `.pipe(Effect.orDie)` ON `declareRepoPolicy` IS UNRELATED TO WHAT THIS TEST CHECKS.
|
|
23
|
+
* `declareRepoPolicy` refuses a bad policy through `Effect.try`'s `catch`, so its error
|
|
24
|
+
* channel is `Error`, not `never` — a separate, pre-existing type surface (present since its
|
|
25
|
+
* original PR #98) that `Alchemy.Stack`'s own `eff: Effect.Effect<A, ConfigError, Req>` does
|
|
26
|
+
* not accept regardless of the `Req`/`Providers` bug this test targets. Dying on it here keeps
|
|
27
|
+
* that surface out of the way without papering over it — a real caller still has to decide how
|
|
28
|
+
* to handle a refused policy, which this test deliberately does not answer.
|
|
29
|
+
*/
|
|
30
|
+
import { expect, test } from 'bun:test';
|
|
31
|
+
import * as Alchemy from 'alchemy';
|
|
32
|
+
import * as Effect from 'effect/Effect';
|
|
33
|
+
import { declareRepoBaseline } from './declare-repo-baseline.ts';
|
|
34
|
+
import { declareRepoPolicy } from './repo-policy.ts';
|
|
35
|
+
import { repoPolicyProviders } from './repository-ruleset-providers.ts';
|
|
36
|
+
|
|
37
|
+
test('declareRepoPolicy and declareRepoBaseline typecheck as one Alchemy.Stack body', () => {
|
|
38
|
+
const stack = Alchemy.Stack(
|
|
39
|
+
'repo-policy-typecheck',
|
|
40
|
+
{ providers: repoPolicyProviders(), state: Alchemy.inMemoryState() },
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const policy = yield* declareRepoPolicy('api', {
|
|
43
|
+
owner: 'taslabs-net',
|
|
44
|
+
repository: 'api',
|
|
45
|
+
checks: ['ci', 'secret scan'],
|
|
46
|
+
}).pipe(Effect.orDie);
|
|
47
|
+
const baseline = yield* declareRepoBaseline('widgets', {
|
|
48
|
+
owner: 'taslabs-net',
|
|
49
|
+
repository: 'widgets',
|
|
50
|
+
checks: ['ci'],
|
|
51
|
+
visibility: 'private',
|
|
52
|
+
});
|
|
53
|
+
return { policy, baseline };
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
// Not run — see file header. This only proves construction didn't throw synchronously; the
|
|
57
|
+
// real assertion is `tsc`'s, over the whole expression above.
|
|
58
|
+
expect(stack).toBeDefined();
|
|
59
|
+
});
|