@homeflare/alchemy 0.29.0 → 0.30.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.
Files changed (104) hide show
  1. package/README.md +20 -0
  2. package/dist/caddy/config-reconcile.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/format-warnings.d.ts +29 -0
  5. package/dist/caddy/format-warnings.d.ts.map +1 -0
  6. package/dist/caddy/format.d.ts +50 -0
  7. package/dist/caddy/format.d.ts.map +1 -0
  8. package/dist/caddy/index.d.ts +1 -0
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +78 -10
  11. package/dist/caddy/index.js.map +7 -5
  12. package/dist/github/declare-repo-baseline.d.ts.map +1 -1
  13. package/dist/github/index.js +21 -2
  14. package/dist/github/index.js.map +6 -5
  15. package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
  16. package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
  17. package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
  18. package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
  19. package/dist/github/repo-auto-merge-gate.d.ts +54 -0
  20. package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
  21. package/dist/github/repo-policy.d.ts.map +1 -1
  22. package/dist/google-workspace/credentials.d.ts +54 -0
  23. package/dist/google-workspace/credentials.d.ts.map +1 -0
  24. package/dist/google-workspace/domain-alias.d.ts +74 -0
  25. package/dist/google-workspace/domain-alias.d.ts.map +1 -0
  26. package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
  27. package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
  28. package/dist/google-workspace/group-member.d.ts +73 -0
  29. package/dist/google-workspace/group-member.d.ts.map +1 -0
  30. package/dist/google-workspace/group.d.ts +87 -0
  31. package/dist/google-workspace/group.d.ts.map +1 -0
  32. package/dist/google-workspace/index.d.ts +19 -0
  33. package/dist/google-workspace/index.d.ts.map +1 -0
  34. package/dist/google-workspace/index.js +283 -0
  35. package/dist/google-workspace/index.js.map +17 -0
  36. package/dist/google-workspace/org-unit.d.ts +75 -0
  37. package/dist/google-workspace/org-unit.d.ts.map +1 -0
  38. package/dist/google-workspace/providers.d.ts +16 -0
  39. package/dist/google-workspace/providers.d.ts.map +1 -0
  40. package/dist/google-workspace/resource.d.ts +80 -0
  41. package/dist/google-workspace/resource.d.ts.map +1 -0
  42. package/dist/google-workspace/values.d.ts +28 -0
  43. package/dist/google-workspace/values.d.ts.map +1 -0
  44. package/dist/grafana/credentials.d.ts +38 -0
  45. package/dist/grafana/credentials.d.ts.map +1 -0
  46. package/dist/grafana/datasource.d.ts +90 -0
  47. package/dist/grafana/datasource.d.ts.map +1 -0
  48. package/dist/grafana/fake-grafana.d.ts +48 -0
  49. package/dist/grafana/fake-grafana.d.ts.map +1 -0
  50. package/dist/grafana/index.d.ts +17 -0
  51. package/dist/grafana/index.d.ts.map +1 -0
  52. package/dist/grafana/index.js +149 -0
  53. package/dist/grafana/index.js.map +13 -0
  54. package/dist/grafana/providers.d.ts +17 -0
  55. package/dist/grafana/providers.d.ts.map +1 -0
  56. package/dist/grafana/resource.d.ts +68 -0
  57. package/dist/grafana/resource.d.ts.map +1 -0
  58. package/docs/caddy-fmt.md +59 -0
  59. package/docs/caddy.md +7 -0
  60. package/docs/credentials.md +11 -0
  61. package/docs/google-workspace.md +105 -0
  62. package/docs/grafana.md +102 -0
  63. package/docs/peers.md +9 -0
  64. package/docs/upstream-conformance.md +37 -0
  65. package/package.json +9 -1
  66. package/src/caddy/config-format-warning.test.ts +91 -0
  67. package/src/caddy/config-reconcile.ts +6 -0
  68. package/src/caddy/config.ts +8 -1
  69. package/src/caddy/format-warnings.test.ts +50 -0
  70. package/src/caddy/format-warnings.ts +39 -0
  71. package/src/caddy/format.test.ts +105 -0
  72. package/src/caddy/format.ts +121 -0
  73. package/src/caddy/index.ts +7 -0
  74. package/src/caddy/providers.test.ts +8 -0
  75. package/src/github/declare-repo-baseline-apply-order.test.ts +86 -0
  76. package/src/github/declare-repo-baseline.ts +34 -1
  77. package/src/github/repo-apply-order-fake-providers.ts +119 -0
  78. package/src/github/repo-apply-order-fake-stack.ts +74 -0
  79. package/src/github/repo-auto-merge-gate.ts +57 -0
  80. package/src/github/repo-policy-apply-order.test.ts +72 -0
  81. package/src/github/repo-policy.ts +28 -1
  82. package/src/google-workspace/credentials.test.ts +40 -0
  83. package/src/google-workspace/credentials.ts +75 -0
  84. package/src/google-workspace/domain-alias.test.ts +69 -0
  85. package/src/google-workspace/domain-alias.ts +116 -0
  86. package/src/google-workspace/fake-google-workspace.ts +54 -0
  87. package/src/google-workspace/group-member.test.ts +91 -0
  88. package/src/google-workspace/group-member.ts +126 -0
  89. package/src/google-workspace/group.test.ts +93 -0
  90. package/src/google-workspace/group.ts +128 -0
  91. package/src/google-workspace/index.ts +54 -0
  92. package/src/google-workspace/org-unit.test.ts +81 -0
  93. package/src/google-workspace/org-unit.ts +143 -0
  94. package/src/google-workspace/providers.ts +26 -0
  95. package/src/google-workspace/resource.test.ts +186 -0
  96. package/src/google-workspace/resource.ts +162 -0
  97. package/src/google-workspace/values.ts +36 -0
  98. package/src/grafana/credentials.ts +65 -0
  99. package/src/grafana/datasource.test.ts +183 -0
  100. package/src/grafana/datasource.ts +171 -0
  101. package/src/grafana/fake-grafana.ts +64 -0
  102. package/src/grafana/index.ts +22 -0
  103. package/src/grafana/providers.ts +19 -0
  104. package/src/grafana/resource.ts +120 -0
@@ -18,6 +18,24 @@
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 repository = yield* owned(GitHub.Repository(id, repoBaselineSettings(options)));
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
  });
@@ -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
+ }
@@ -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
+ });
@@ -25,11 +25,27 @@
25
25
  * ⛔ BOTH RESOURCES RETAIN. `retain` is already the vendor default for each of them; it
26
26
  * is piped anyway so that a vendor change cannot quietly turn "drop this call from the
27
27
  * stack" into "delete the repository" or "unprotect the default branch".
28
+ *
29
+ * ⛔ APPLY ORDER: `allowAutoMerge` NEVER GOES LIVE BEFORE THE RULESET IT WAITS ON — see
30
+ * repo-auto-merge-gate.ts's header for the mechanism (Alchemy orders by Output references in
31
+ * props, not declaration order), the K5 hazard it closes, and why checks/approvals being
32
+ * REMOVED needs no matching reverse edge. `assertAutoMergeWaits` (repo-policy-guards.ts)
33
+ * already guarantees `policy.repository.allowAutoMerge` is only ever `true` when the ruleset
34
+ * carries something outstanding (checks, approvals, or both), so that one flag is the gate.
35
+ * ⚠️ A REPOSITORY THAT DOES NOT YET EXIST: gating on the ruleset only matters once there are open
36
+ * pull requests to prematurely merge, and `GitHub.Repository`'s own `reconcile` sets
37
+ * `allowAutoMerge` atomically in the SAME create call that makes the repo — no window where a
38
+ * PR could exploit it. This helper's documented use (`builds`, above) is always a repo that
39
+ * already converged before its ruleset's first deploy; a caller declaring both fresh in one
40
+ * deploy, with checks/approvals from day one, must create the bare repository first (a prior
41
+ * deploy, or a separate `GitHub.Repository` call) — GitHub's ruleset API 404s on a repo that
42
+ * does not exist yet, and this gate now makes the ruleset apply before the repository.
28
43
  */
29
44
  import { adopt } from 'alchemy/AdoptPolicy';
30
45
  import * as GitHub from 'alchemy/GitHub';
31
46
  import * as RemovalPolicy from 'alchemy/RemovalPolicy';
32
47
  import * as Effect from 'effect/Effect';
48
+ import { gateAutoMergeOnRuleset } from './repo-auto-merge-gate.ts';
33
49
  import { type RepoPolicyOptions, repoPolicy } from './repo-policy-form.ts';
34
50
  import { RepositoryRuleset } from './repository-ruleset.ts';
35
51
 
@@ -58,9 +74,20 @@ export const declareRepoPolicy = (id: string, options: DeclareRepoPolicyOptions)
58
74
  const kept = resource.pipe(RemovalPolicy.retain());
59
75
  return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
60
76
  };
61
- const repository = yield* owned(GitHub.Repository(id, policy.repository));
62
77
  // `policy.ruleset` is a plain `RulesetProps` — every field `RepositoryRulesetProps` adds
63
78
  // over it is optional, so it needs no translation to satisfy the bridge's props.
79
+
80
+ // Auto-merge is turning on (or staying on): declare and apply the ruleset FIRST, so its
81
+ // rulesetId is available to gate the repository's allowAutoMerge — see the file header.
82
+ if (policy.repository.allowAutoMerge) {
83
+ const ruleset = yield* owned(RepositoryRuleset(`${id}-ruleset`, policy.ruleset));
84
+ const repository = yield* owned(
85
+ GitHub.Repository(id, gateAutoMergeOnRuleset(policy.repository, ruleset)),
86
+ );
87
+ return { policy, repository, ruleset };
88
+ }
89
+
90
+ const repository = yield* owned(GitHub.Repository(id, policy.repository));
64
91
  const ruleset = yield* owned(RepositoryRuleset(`${id}-ruleset`, policy.ruleset));
65
92
  return { policy, repository, ruleset };
66
93
  });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `describeKeyRef` — proves it formats a plain reference and refuses anything key-shaped, so the
3
+ * one place this family turns a `GoogleWorkspaceKeyRef` into text (docs, the census handoff
4
+ * header) cannot be handed a real credential by mistake.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { type GoogleWorkspaceKeyRef, describeKeyRef } from './credentials.ts';
8
+
9
+ const REF: GoogleWorkspaceKeyRef = {
10
+ delegatedAdmin: 'admin@schenanigans.com',
11
+ openBaoPath: 'kv/google-workspace/service-accounts/directory-admin',
12
+ scopes: ['https://www.googleapis.com/auth/admin.directory.group.readonly'],
13
+ };
14
+
15
+ describe('describeKeyRef', () => {
16
+ test('formats a plain reference — path, delegated admin, scopes', () => {
17
+ const line = describeKeyRef(REF);
18
+ expect(line).toContain('kv/google-workspace/service-accounts/directory-admin');
19
+ expect(line).toContain('admin@schenanigans.com');
20
+ expect(line).toContain('admin.directory.group.readonly');
21
+ });
22
+
23
+ test('refuses an openBaoPath that looks like a PEM private key', () => {
24
+ expect(() =>
25
+ describeKeyRef({ ...REF, openBaoPath: '-----BEGIN PRIVATE KEY-----\nMIIE...' }),
26
+ ).toThrow(/looks like key material/);
27
+ });
28
+
29
+ test('refuses a scopes entry that looks like a service-account JSON blob', () => {
30
+ expect(() =>
31
+ describeKeyRef({ ...REF, scopes: ['{"type": "service_account", "private_key": "x"}'] }),
32
+ ).toThrow(/looks like key material/);
33
+ });
34
+
35
+ test('refuses a delegatedAdmin field carrying a private_key fragment', () => {
36
+ expect(() => describeKeyRef({ ...REF, delegatedAdmin: 'private_key leaked here' })).toThrow(
37
+ /looks like key material/,
38
+ );
39
+ });
40
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Google Workspace credentials for the house — a lazy reference, never the key.
3
+ *
4
+ * ⛔ THE DISTILLED SDK'S OWN `Credentials` SERVICE TAKES ONE THING: an already-minted OAuth2
5
+ * bearer access token (`@distilled.cloud/google-workspace/Credentials` — read at
6
+ * `node_modules/@distilled.cloud/google-workspace/src/credentials.ts`, 2026-09-24: "API-compatible
7
+ * port of the distilled gcp credentials module … no ADC, no service-account signing, no refresh
8
+ * flow"). It has no service-account JSON shape and no domain-wide-delegation JWT exchange built
9
+ * in — minting the token is entirely outside the SDK's surface.
10
+ *
11
+ * ★ SO THE HOUSE PATTERN IS H8'S, UNCHANGED: a short-lived credential is minted from OpenBao and
12
+ * exported into the DEPLOY PROCESS's environment only, by a Bun wrapper the operator runs — the
13
+ * same shape the kit's Proxmox subpath already uses for its own OpenBao-sourced token. This
14
+ * family adds nothing that signs a JWT or talks to Google's token endpoint; that stays Tim's
15
+ * wrapper script, documented in the README, because building it here would be a second,
16
+ * hand-rolled auth flow next to the one the SDK already exposes (S23).
17
+ *
18
+ * ⛔ NEVER THE KEY. `GoogleWorkspaceKeyRef` below is a REFERENCE to where a service-account key
19
+ * lives — an OpenBao path, a delegated admin subject and the scopes the wrapper requests — not
20
+ * the key itself. Nothing in this family reads, parses or holds private key material; `describeKeyRef`
21
+ * is pure string formatting for docs and the census handoff header, and it is tested (below) to
22
+ * never accept or echo anything shaped like a key.
23
+ *
24
+ * Re-exported so a consumer imports one module for both the distilled service and the house
25
+ * reference type: `import { CredentialsFromEnv, fromAccessToken, type GoogleWorkspaceKeyRef } from
26
+ * '@homeflare/alchemy/google-workspace'`.
27
+ */
28
+ export {
29
+ type Config as GoogleWorkspaceConfig,
30
+ Credentials,
31
+ CredentialsFromEnv,
32
+ fromAccessToken,
33
+ } from '@distilled.cloud/google-workspace/Credentials';
34
+
35
+ /** Env var the SDK's `CredentialsFromEnv` reads — the wrapper mints into THIS var, nothing else. */
36
+ export const GOOGLE_ACCESS_TOKEN_ENV = 'GOOGLE_ACCESS_TOKEN';
37
+ /** Optional — only meaningful if a resource ever needs to scope a call to a GCP project. */
38
+ export const GOOGLE_PROJECT_ID_ENV = 'GOOGLE_PROJECT_ID';
39
+
40
+ /**
41
+ * Where the service-account key that a wrapper exchanges for `GOOGLE_ACCESS_TOKEN` lives —
42
+ * documentation and typing only. A stack file may keep one of these next to its declarations so
43
+ * the OpenBao path, delegated admin and scopes are reviewable in the same diff as the resources
44
+ * they authorize — nothing here reads OpenBao or Google, and no field may hold a key, a token or
45
+ * a private key fragment.
46
+ */
47
+ export interface GoogleWorkspaceKeyRef {
48
+ /** OpenBao KV path, e.g. `kv/google-workspace/service-accounts/directory-admin`. */
49
+ readonly openBaoPath: string;
50
+ /** The Workspace admin the service account impersonates via domain-wide delegation. */
51
+ readonly delegatedAdmin: string;
52
+ /** OAuth scopes the wrapper requests — keep this the least the declared resources need. */
53
+ readonly scopes: readonly string[];
54
+ }
55
+
56
+ /** Substrings that must never appear in a `GoogleWorkspaceKeyRef` field — this is a reference. */
57
+ const KEY_SHAPED = /-----BEGIN|private_key|"type":\s*"service_account"/i;
58
+
59
+ /**
60
+ * A one-line, key-free description for docs and the census handoff header. Throws rather than
61
+ * formatting a ref whose fields look like they hold key material — a `GoogleWorkspaceKeyRef` is
62
+ * a pointer, and this is the one place that assumption is checked instead of assumed.
63
+ */
64
+ export const describeKeyRef = (ref: GoogleWorkspaceKeyRef): string => {
65
+ for (const [field, value] of Object.entries(ref)) {
66
+ const text = Array.isArray(value) ? value.join(',') : String(value);
67
+ if (KEY_SHAPED.test(text)) {
68
+ throw new Error(`GoogleWorkspaceKeyRef.${field} looks like key material, not a reference`);
69
+ }
70
+ }
71
+ return (
72
+ `service account at OpenBao:${ref.openBaoPath}, delegating as ${ref.delegatedAdmin}, ` +
73
+ `scopes: ${ref.scopes.join(' ')}`
74
+ );
75
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `GoogleWorkspace.DomainAlias`'s `spec` against a fake Directory API — no `update` operation
3
+ * exists (domain-alias.ts's own note), so this only proves `fetchLive`/`attributes`/`matches`.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import * as Effect from 'effect/Effect';
7
+ import {
8
+ fakeFailure,
9
+ fakeGoogleWorkspace,
10
+ fakeGoogleWorkspaceLayer,
11
+ } from './fake-google-workspace.ts';
12
+ import { spec } from './domain-alias.ts';
13
+
14
+ const ALIAS_PATH = '/admin/directory/v1/customer/my_customer/domainaliases/alias.schenanigans.com';
15
+ const props = { domainAliasName: 'alias.schenanigans.com', parentDomainName: 'schenanigans.com' };
16
+
17
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
18
+ Effect.runPromise(
19
+ spec.fetchLive(props).pipe(
20
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
21
+ Effect.provide(fakeGoogleWorkspaceLayer(fetchFn)),
22
+ ),
23
+ );
24
+
25
+ describe('GoogleWorkspace.DomainAlias spec.fetchLive + spec.attributes', () => {
26
+ test('a live alias decodes with the default my_customer scope', async () => {
27
+ const fake = fakeGoogleWorkspace((method, url) =>
28
+ method === 'GET' && url.pathname === ALIAS_PATH
29
+ ? Response.json({
30
+ creationTime: '1700000000000',
31
+ domainAliasName: 'alias.schenanigans.com',
32
+ parentDomainName: 'schenanigans.com',
33
+ verified: true,
34
+ })
35
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
36
+ );
37
+ expect(await readThrough(fake.fetch)).toEqual({
38
+ creationTime: '1700000000000',
39
+ customer: 'my_customer',
40
+ domainAliasName: 'alias.schenanigans.com',
41
+ parentDomainName: 'schenanigans.com',
42
+ verified: true,
43
+ });
44
+ });
45
+
46
+ test('a 404 folds to undefined', async () => {
47
+ const fake = fakeGoogleWorkspace(() => fakeFailure(404, 'not found', 'NOT_FOUND'));
48
+ expect(await readThrough(fake.fetch)).toBeUndefined();
49
+ });
50
+ });
51
+
52
+ describe('GoogleWorkspace.DomainAlias spec.matches and spec.update', () => {
53
+ const live = {
54
+ creationTime: '1700000000000',
55
+ customer: 'my_customer',
56
+ domainAliasName: 'alias.schenanigans.com',
57
+ parentDomainName: 'schenanigans.com',
58
+ verified: true,
59
+ };
60
+
61
+ test('the same parentDomainName is no drift', () => {
62
+ expect(spec.matches(live, props)).toBe(true);
63
+ });
64
+
65
+ test('a different parentDomainName is drift — and since there is no update, diff replaces', () => {
66
+ expect(spec.matches(live, { ...props, parentDomainName: 'homeflare.dev' })).toBe(false);
67
+ expect(spec.update).toBeUndefined();
68
+ });
69
+ });