1import * as GitHub from "@/GitHub";
2import { Octokit } from "@/GitHub/Octokit.ts";
3import * as Output from "@/Output";
4import { destroy } from "@/RemovalPolicy";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9
10const { test } = Test.make({ providers: GitHub.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17// These tests create, mutate, and delete a real deployment environment, so
18// they run against the dedicated test org (never a real one). Set
19// GITHUB_TEST_OWNER="" to skip. The host repository is public because
20// environments (and their protection rules) on private repositories are
21// plan-gated.
22const owner = process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test";
23const repo =
24 process.env.GITHUB_TEST_ENVIRONMENT_REPOSITORY ??
25 "alchemy-effect-environment-test";
26
27// Derive the repository name from the `fullName` output — referencing an
28// output (rather than the `repo` constant) makes the engine order dependent
29// resources after the repository exists.
30const repoName = (repository: GitHub.Repository) =>
31 Output.map(repository.fullName, (fullName) => fullName.split("/")[1]!);
32
33const getEnvironment = (name: string) =>
34 Effect.gen(function* () {
35 const octokit = yield* Octokit;
36 return yield* Effect.tryPromise({
37 try: async () => {
38 try {
39 const { data } = await octokit.rest.repos.getEnvironment({
40 owner,
41 repo,
42 environment_name: name,
43 });
44 return data;
45 } catch (error: any) {
46 if (error.status === 404) return undefined;
47 throw error;
48 }
49 },
50 catch: (e) => e as Error,
51 });
52 });
53
54const listBranchPolicies = (name: string) =>
55 Effect.gen(function* () {
56 const octokit = yield* Octokit;
57 return yield* Effect.tryPromise({
58 try: async () => {
59 const { data } = await octokit.rest.repos.listDeploymentBranchPolicies({
60 owner,
61 repo,
62 environment_name: name,
63 per_page: 100,
64 });
65 return (data.branch_policies ?? []).map((policy) => policy.name);
66 },
67 catch: (e) => e as Error,
68 });
69 });
70
71test.provider.skipIf(!owner)(
72 "create, update, and delete an environment with protection rules",
73 (stack) =>
74 Effect.gen(function* () {
75 const name = "alchemy-test-production";
76
77 // Clean up any leftovers from a previous run before deploying.
78 yield* stack.destroy();
79
80 // Create — environment with a wait timer and custom branch patterns.
81 // `Repository` defaults to `retain`, so the host repo is created once
82 // and reused across runs (reconcile is idempotent).
83 const created = yield* stack.deploy(
84 Effect.gen(function* () {
85 const repository = yield* GitHub.Repository("Repo", {
86 owner,
87 name: repo,
88 description: "alchemy-effect environment test",
89 visibility: "public",
90 autoInit: true,
91 });
92
93 return yield* GitHub.Environment("Env", {
94 owner,
95 // Derive the repo name from a repository output so the engine
96 // orders the environment after the repository exists.
97 repository: repoName(repository),
98 name,
99 waitTimer: 5,
100 preventSelfReview: true,
101 deploymentBranchPolicy: {
102 customBranchPolicies: ["main", "release/*"],
103 },
104 }).pipe(destroy());
105 }),
106 );
107
108 expect(created.environmentId).toBeGreaterThan(0);
109 expect(created.name).toEqual(name);
110 expect(created.htmlUrl).toContain(repo);
111
112 const fetched = yield* getEnvironment(name);
113 expect(fetched?.id).toEqual(created.environmentId);
114 expect(fetched?.deployment_branch_policy?.custom_branch_policies).toBe(
115 true,
116 );
117 const waitRule = fetched?.protection_rules?.find(
118 (rule) => rule.type === "wait_timer",
119 );
120 expect(
121 waitRule !== undefined && "wait_timer" in waitRule
122 ? waitRule.wait_timer
123 : undefined,
124 ).toEqual(5);
125
126 const patterns = yield* listBranchPolicies(name);
127 expect(patterns.sort()).toEqual(["main", "release/*"]);
128
129 // Update — drop the wait timer, converge the pattern list, same
130 // logical ID → same environmentId (update in place, not replace).
131 const updated = yield* stack.deploy(
132 Effect.gen(function* () {
133 const repository = yield* GitHub.Repository("Repo", {
134 owner,
135 name: repo,
136 description: "alchemy-effect environment test",
137 visibility: "public",
138 });
139
140 return yield* GitHub.Environment("Env", {
141 owner,
142 repository: repoName(repository),
143 name,
144 deploymentBranchPolicy: {
145 customBranchPolicies: ["main"],
146 },
147 }).pipe(destroy());
148 }),
149 );
150
151 expect(updated.environmentId).toEqual(created.environmentId);
152 const afterUpdate = yield* getEnvironment(name);
153 const waitAfterUpdate = afterUpdate?.protection_rules?.find(
154 (rule) => rule.type === "wait_timer",
155 );
156 expect(waitAfterUpdate).toBeUndefined();
157 expect(yield* listBranchPolicies(name)).toEqual(["main"]);
158
159 // Switch the policy mode to protected branches only.
160 const switched = yield* stack.deploy(
161 Effect.gen(function* () {
162 const repository = yield* GitHub.Repository("Repo", {
163 owner,
164 name: repo,
165 description: "alchemy-effect environment test",
166 visibility: "public",
167 });
168
169 return yield* GitHub.Environment("Env", {
170 owner,
171 repository: repoName(repository),
172 name,
173 deploymentBranchPolicy: { protectedBranches: true },
174 }).pipe(destroy());
175 }),
176 );
177
178 expect(switched.environmentId).toEqual(created.environmentId);
179 const afterSwitch = yield* getEnvironment(name);
180 expect(afterSwitch?.deployment_branch_policy?.protected_branches).toBe(
181 true,
182 );
183
184 // Delete — the environment goes away; the retained repo stays.
185 yield* stack.destroy();
186 const afterDestroy = yield* getEnvironment(name);
187 expect(afterDestroy).toBeUndefined();
188 }).pipe(logLevel),
189 {
190 tags: ["provider:github", "provider:github:repository", "live"],
191 timeout: 120_000,
192 },
193);
194
195test.provider.skipIf(!owner)(
196 "environment-scoped variable lifecycle",
197 (stack) =>
198 Effect.gen(function* () {
199 const name = "alchemy-test-variables";
200
201 yield* stack.destroy();
202
203 const deployVariable = (value: string) =>
204 stack.deploy(
205 Effect.gen(function* () {
206 const repository = yield* GitHub.Repository("Repo", {
207 owner,
208 name: repo,
209 description: "alchemy-effect environment test",
210 visibility: "public",
211 autoInit: true,
212 });
213
214 const environment = yield* GitHub.Environment("Env", {
215 owner,
216 repository: repoName(repository),
217 name,
218 }).pipe(destroy());
219
220 // Pass the Environment resource itself — the `environment` prop
221 // accepts `string | Environment` and resolves the name.
222 return yield* GitHub.Variable("Variable", {
223 owner,
224 repository: repoName(repository),
225 environment,
226 name: "ALCHEMY_ENV_TEST",
227 value,
228 }).pipe(destroy());
229 }),
230 );
231
232 const readVariable = Effect.gen(function* () {
233 const octokit = yield* Octokit;
234 return yield* Effect.tryPromise({
235 try: async () => {
236 try {
237 const { data } =
238 await octokit.rest.actions.getEnvironmentVariable({
239 owner,
240 repo,
241 environment_name: name,
242 name: "ALCHEMY_ENV_TEST",
243 });
244 return data;
245 } catch (error: any) {
246 if (error.status === 404) return undefined;
247 throw error;
248 }
249 },
250 catch: (e) => e as Error,
251 });
252 });
253
254 // Create — the variable lands in the environment, not the repo.
255 yield* deployVariable("one");
256 const fetched = yield* readVariable;
257 expect(fetched?.value).toEqual("one");
258
259 // Update — reconcile PATCHes the drifted value in place.
260 yield* deployVariable("two");
261 const afterUpdate = yield* readVariable;
262 expect(afterUpdate?.value).toEqual("two");
263
264 // Delete — destroying the stack removes the variable (and environment).
265 yield* stack.destroy();
266 const afterDestroy = yield* readVariable;
267 expect(afterDestroy).toBeUndefined();
268 }).pipe(logLevel),
269 {
270 tags: [
271 "provider:github",
272 "provider:github:repository",
273 "provider:github:variable",
274 "live",
275 ],
276 timeout: 120_000,
277 },
278);