1import * as GitHub from "@/GitHub/index.ts";
2import { GitHubCredentials } from "@/GitHub/Credentials.ts";
3import { Octokit } from "@/GitHub/Octokit.ts";
4import * as Output from "@/Output.ts";
5import * as Provider from "@/Provider.ts";
6import { destroy } from "@/RemovalPolicy.ts";
7import * as Test from "@/Test/Alchemy.ts";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const owner = process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test";
13if (owner !== "alchemy-run-test" && owner !== "alchemy-run-test-2") {
14 throw new Error(`Unsafe GITHUB_TEST_OWNER: ${owner}`);
15}
16
17const { test } = Test.make({
18 providers: GitHub.providers({ baseUrl: "github.com" }),
19});
20
21// Repositories are retained because the test token lacks delete_repo scope.
22const repository = (name: string) =>
23 GitHub.Repository("Repo", {
24 owner,
25 name: `alchemy-effect-pr-1577-release-${name}`,
26 autoInit: true,
27 visibility: "public",
28 description: "Retained fixture for Alchemy PR #1577 release tests",
29 });
30
31const release = (
32 fixture: string,
33 props: Omit<GitHub.ReleaseProps, "owner" | "repository">,
34) =>
35 Effect.gen(function* () {
36 const repo = yield* repository(fixture);
37 return yield* GitHub.Release("Release", {
38 ...props,
39 owner,
40 repository: Output.map(
41 repo.fullName,
42 (fullName) => fullName.split("/")[1]!,
43 ),
44 }).pipe(destroy());
45 });
46
47const getRelease = (fixture: string, releaseId: number) =>
48 Effect.gen(function* () {
49 const octokit = yield* Octokit;
50 return yield* Effect.tryPromise({
51 try: () =>
52 octokit.rest.repos.getRelease({
53 owner,
54 repo: `alchemy-effect-pr-1577-release-${fixture}`,
55 release_id: releaseId,
56 }),
57 catch: (error) => error as Error & { status?: number },
58 }).pipe(
59 Effect.map(({ data }) => data),
60 Effect.catchIf(
61 (error) => error.status === 404,
62 () => Effect.succeed(undefined),
63 ),
64 );
65 });
66
67const assertDeleted = (fixture: string, releaseId: number) =>
68 Effect.gen(function* () {
69 expect(yield* getRelease(fixture, releaseId)).toBeUndefined();
70 const octokit = yield* Octokit;
71 const { data } = yield* Effect.tryPromise({
72 try: () =>
73 octokit.rest.repos.listReleases({
74 owner,
75 repo: `alchemy-effect-pr-1577-release-${fixture}`,
76 }),
77 catch: (error) => error as Error,
78 });
79 expect(data).toEqual([]);
80 });
81
82test.provider(
83 "create and update release",
84 (stack) =>
85 Effect.gen(function* () {
86 yield* stack.destroy();
87 const created = yield* stack.deploy(
88 release("update", {
89 tagName: "v1.0.0",
90 name: "Version 1.0.0",
91 body: "First release",
92 }),
93 );
94 expect(created.tagName).toBe("v1.0.0");
95 expect(created.name).toBe("Version 1.0.0");
96 expect(created.body).toBe("First release");
97 expect(created.draft).toBe(false);
98 expect(created.prerelease).toBe(false);
99 expect((yield* getRelease("update", created.releaseId))?.body).toBe(
100 "First release",
101 );
102
103 const updated = yield* stack.deploy(
104 release("update", {
105 tagName: "v1.0.0",
106 name: "Version 1.0.0",
107 body: "Updated: Bug fixes and improvements",
108 prerelease: true,
109 }),
110 );
111 expect(updated.releaseId).toBe(created.releaseId);
112 expect(updated.body).toBe("Updated: Bug fixes and improvements");
113 const observed = yield* getRelease("update", updated.releaseId);
114 expect(observed?.body).toBe(updated.body);
115 expect(observed?.prerelease).toBe(true);
116
117 const reset = yield* stack.deploy(
118 release("update", { tagName: "v1.0.0" }),
119 );
120 expect(reset.releaseId).toBe(created.releaseId);
121 expect(reset.name).toBe("v1.0.0");
122 expect(reset.body).toBe("");
123 expect(reset.prerelease).toBe(false);
124 const observedReset = yield* getRelease("update", reset.releaseId);
125 expect(observedReset?.body).toBe("");
126 expect(observedReset?.prerelease).toBe(false);
127 yield* stack.deploy(repository("update"));
128 yield* assertDeleted("update", reset.releaseId);
129 yield* stack.destroy();
130 }),
131 {
132 tags: [
133 "provider:github",
134 "provider:github:release",
135 "provider:github:repository",
136 "live",
137 ],
138 timeout: 120_000,
139 },
140);
141
142test.provider(
143 "create draft and publish",
144 (stack) =>
145 Effect.gen(function* () {
146 yield* stack.destroy();
147 const created = yield* stack.deploy(
148 release("draft", {
149 tagName: "v2.0.0",
150 name: "Version 2.0.0",
151 body: "Draft release notes",
152 draft: true,
153 }),
154 );
155 expect(created.draft).toBe(true);
156 expect(created.publishedAt).toBe(null);
157 expect((yield* getRelease("draft", created.releaseId))?.draft).toBe(true);
158
159 const updated = yield* stack.deploy(
160 release("draft", {
161 tagName: "v2.0.0",
162 name: "Version 2.0.0",
163 body: "Published release notes",
164 draft: false,
165 }),
166 );
167 expect(updated.releaseId).toBe(created.releaseId);
168 expect(updated.draft).toBe(false);
169 expect(updated.publishedAt).not.toBe(null);
170 const observed = yield* getRelease("draft", updated.releaseId);
171 expect(observed?.draft).toBe(false);
172 expect(observed?.body).toBe("Published release notes");
173 yield* stack.deploy(repository("draft"));
174 yield* assertDeleted("draft", updated.releaseId);
175 yield* stack.destroy();
176 }),
177 {
178 tags: [
179 "provider:github",
180 "provider:github:release",
181 "provider:github:repository",
182 "live",
183 ],
184 timeout: 120_000,
185 },
186);
187
188test.provider(
189 "create prerelease",
190 (stack) =>
191 Effect.gen(function* () {
192 yield* stack.destroy();
193 const created = yield* stack.deploy(
194 release("prerelease", {
195 tagName: "v3.0.0-beta.1",
196 name: "v3.0.0 Beta 1",
197 body: "Beta release for testing",
198 prerelease: true,
199 }),
200 );
201 expect(created.tagName).toBe("v3.0.0-beta.1");
202 expect(created.prerelease).toBe(true);
203 expect(created.draft).toBe(false);
204 expect(
205 (yield* getRelease("prerelease", created.releaseId))?.prerelease,
206 ).toBe(true);
207 yield* stack.deploy(repository("prerelease"));
208 yield* assertDeleted("prerelease", created.releaseId);
209 yield* stack.destroy();
210 }),
211 {
212 tags: [
213 "provider:github",
214 "provider:github:release",
215 "provider:github:repository",
216 "live",
217 ],
218 timeout: 120_000,
219 },
220);
221
222test.provider(
223 "list enumeration includes deployed release",
224 (stack) =>
225 Effect.gen(function* () {
226 yield* stack.destroy();
227 const created = yield* stack.deploy(
228 release("list", {
229 tagName: "v1.0.0",
230 name: "Test Release",
231 body: "For list test",
232 }),
233 );
234 const name = "alchemy-effect-pr-1577-release-list";
235 const credentials = yield* yield* GitHubCredentials;
236 const provider = yield* Provider.findProvider(GitHub.Release);
237 // list() enumerates /user/repos; confine it to this suite's fixture.
238 const all = yield* provider.list().pipe(
239 Effect.provideService(
240 GitHubCredentials,
241 Effect.succeed({
242 ...credentials,
243 octokit: (override) => {
244 const octokit = credentials.octokit(override);
245 octokit.hook.before("request", (options) => {
246 const url = new URL(options.url, "https://api.github.com");
247 if (url.pathname === "/user/repos") {
248 url.pathname = `/orgs/${owner}/repos`;
249 options.url = url.toString();
250 }
251 if (
252 url.origin !== "https://api.github.com" ||
253 (url.pathname !== `/orgs/${owner}/repos` &&
254 url.pathname !== `/repos/${owner}/${name}/releases`)
255 ) {
256 throw new Error(`Unsafe Release list request: ${url}`);
257 }
258 });
259 octokit.hook.after("request", (response, options) => {
260 const url = new URL(options.url, "https://api.github.com");
261 if (url.pathname === `/orgs/${owner}/repos`) {
262 response.data = (
263 response.data as Array<{ name: string }>
264 ).filter((repo) => repo.name === name);
265 }
266 });
267 return octokit;
268 },
269 }),
270 ),
271 // Repository listings are eventually consistent for fresh fixtures.
272 Effect.repeat({
273 schedule: Schedule.spaced("2 seconds"),
274 times: 10,
275 until: (releases) =>
276 releases.some((item) => item.releaseId === created.releaseId),
277 }),
278 );
279 expect(
280 all.every((item) =>
281 item.htmlUrl.startsWith(`https://github.com/${owner}/`),
282 ),
283 ).toBe(true);
284 const found = all.find((item) => item.releaseId === created.releaseId);
285 expect(found).toBeDefined();
286 expect(found?.tagName).toBe(created.tagName);
287 yield* stack.deploy(repository("list"));
288 yield* assertDeleted("list", created.releaseId);
289 yield* stack.destroy();
290 }),
291 {
292 tags: [
293 "provider:github",
294 "provider:github:release",
295 "provider:github:repository",
296 "live",
297 ],
298 timeout: 120_000,
299 },
300);
301
302test.provider(
303 "tag change replaces release and deletes the old release",
304 (stack) =>
305 Effect.gen(function* () {
306 yield* stack.destroy();
307 const created = yield* stack.deploy(
308 release("replace", { tagName: "v1.0.0" }),
309 );
310 const replaced = yield* stack.deploy(
311 release("replace", { tagName: "v2.0.0" }),
312 );
313 expect(replaced.releaseId).not.toBe(created.releaseId);
314 expect(replaced.tagName).toBe("v2.0.0");
315 expect(yield* getRelease("replace", created.releaseId)).toBeUndefined();
316 expect((yield* getRelease("replace", replaced.releaseId))?.tag_name).toBe(
317 "v2.0.0",
318 );
319 yield* stack.deploy(repository("replace"));
320 yield* assertDeleted("replace", replaced.releaseId);
321 yield* stack.destroy();
322 }),
323 {
324 tags: [
325 "provider:github",
326 "provider:github:release",
327 "provider:github:repository",
328 "live",
329 ],
330 timeout: 120_000,
331 },
332);