1import * as Effect from "effect/Effect";
2import { isResolved } from "../Diff.ts";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import { dedent } from "../Util/dedent.ts";
6import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts";
7import type * as GitHub from "./Providers.ts";
8
9export interface ReleaseProps {
10 /**
11 * Repository owner (user or organization).
12 */
13 owner: string;
14
15 /**
16 * Repository name.
17 */
18 repository: string;
19
20 /**
21 * Git tag name for this release. The tag is the release's identity —
22 * changing it replaces the release.
23 */
24 tagName: string;
25
26 /**
27 * Release title/name. If omitted, GitHub uses the tag name.
28 */
29 name?: string;
30
31 /**
32 * Release notes/description (supports GitHub Markdown).
33 *
34 * The body is automatically dedented, so you can use indented template
35 * literals without worrying about leading whitespace. Accepts
36 * `Output<string>` at the call site via `Output.interpolate` to embed
37 * resource attributes that are not yet resolved.
38 */
39 body?: string;
40
41 /**
42 * Whether this is a draft release (not visible to users).
43 * @default false
44 */
45 draft?: boolean;
46
47 /**
48 * Whether this is a prerelease (beta, alpha, etc.).
49 * @default false
50 */
51 prerelease?: boolean;
52
53 /**
54 * Commitish (branch, commit SHA, or tag) to target. If the tag doesn't
55 * exist, the release will create it from this commitish. If omitted,
56 * GitHub defaults to the repository's default branch.
57 */
58 targetCommitish?: string;
59
60 /**
61 * Whether to automatically generate release notes from commits.
62 * @default false
63 */
64 generateReleaseNotes?: boolean;
65
66 /**
67 * Override the GitHub host or API base URL for this resource only (e.g.
68 * `github.example.com` for GitHub Enterprise). Falls back to
69 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
70 * provider. Changing it replaces the resource — the same name on a
71 * different GitHub instance is a different physical resource.
72 */
73 baseUrl?: string;
74}
75
76export interface Release extends Resource<
77 "GitHub.Release",
78 ReleaseProps,
79 {
80 /**
81 * Numeric release ID.
82 */
83 releaseId: number;
84
85 /**
86 * GraphQL node ID of the release.
87 */
88 nodeId: string;
89
90 /**
91 * The git tag name.
92 */
93 tagName: string;
94
95 /**
96 * The release name/title.
97 */
98 name: string;
99
100 /**
101 * Release notes/description.
102 */
103 body: string;
104
105 /**
106 * Whether this is a draft release.
107 */
108 draft: boolean;
109
110 /**
111 * Whether this is a prerelease.
112 */
113 prerelease: boolean;
114
115 /**
116 * URL to view the release in a browser.
117 */
118 htmlUrl: string;
119
120 /**
121 * URL for uploading release assets.
122 */
123 uploadUrl: string;
124
125 /**
126 * ISO-8601 timestamp of when the release was created.
127 */
128 createdAt: string;
129
130 /**
131 * ISO-8601 timestamp of when the release was published.
132 */
133 publishedAt: string | null;
134 },
135 never,
136 GitHub.Providers
137> {}
138
139/**
140 * A GitHub release.
141 *
142 * `Release` manages repository releases (tagged versions with notes,
143 * binaries, etc.). Releases are created on first deploy and updated in place
144 * on subsequent deploys when properties change. Publishing a draft converts it
145 * to a public release.
146 *
147 * Authentication is resolved via the `GitHubCredentials` service supplied by
148 * `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token needs
149 * `repo` scope for private repositories or `public_repo` for public ones.
150 * ### Creating Releases
151 * **Example:** Basic Release
152 * ```typescript
153 * const v1 = yield* GitHub.Release("v1", {
154 * owner: "my-org",
155 * repository: "my-repo",
156 * tagName: "v1.0.0",
157 * name: "Version 1.0.0",
158 * body: "First stable release",
159 * });
160 * ```
161 *
162 * **Example:** Prerelease
163 * ```typescript
164 * yield* GitHub.Release("beta", {
165 * owner: "my-org",
166 * repository: "my-repo",
167 * tagName: "v2.0.0-beta.1",
168 * name: "v2.0.0 Beta 1",
169 * body: "Beta release for testing",
170 * prerelease: true,
171 * });
172 * ```
173 *
174 * ### Draft Releases
175 * **Example:** Create Draft and Publish Later
176 * ```typescript
177 * // First deploy: create as draft
178 * const release = yield* GitHub.Release("v1", {
179 * owner: "my-org",
180 * repository: "my-repo",
181 * tagName: "v1.0.0",
182 * name: "Version 1.0.0",
183 * body: "Release notes here",
184 * draft: true,
185 * });
186 *
187 * // Later deploy: publish by setting draft: false
188 * const release = yield* GitHub.Release("v1", {
189 * owner: "my-org",
190 * repository: "my-repo",
191 * tagName: "v1.0.0",
192 * name: "Version 1.0.0",
193 * body: "Release notes here",
194 * draft: false,
195 * });
196 * ```
197 *
198 * ### Auto-Generated Release Notes
199 * **Example:** Generate Notes from Commits
200 * ```typescript
201 * yield* GitHub.Release("v1", {
202 * owner: "my-org",
203 * repository: "my-repo",
204 * tagName: "v1.1.0",
205 * name: "Version 1.1.0",
206 * generateReleaseNotes: true,
207 * });
208 * ```
209 *
210 * ### Targeting Specific Commits
211 * **Example:** Release from Branch or Commit
212 * ```typescript
213 * yield* GitHub.Release("hotfix", {
214 * owner: "my-org",
215 * repository: "my-repo",
216 * tagName: "v1.0.1",
217 * name: "Hotfix 1.0.1",
218 * body: "Critical bug fix",
219 * targetCommitish: "hotfix-branch",
220 * });
221 * ```
222 *
223 * ### Updating Releases
224 * Deploy with the same logical ID and tag to update the existing release.
225 *
226 * **Example:** Update Release Notes
227 * ```typescript
228 * yield* GitHub.Release("v1", {
229 * owner: "my-org",
230 * repository: "my-repo",
231 * tagName: "v1.0.0",
232 * name: "Version 1.0.0",
233 * body: `
234 * # What's New
235 *
236 * - Feature A
237 * - Feature B
238 * - Bug fixes
239 * `,
240 * });
241 * ```
242 *
243 * ### Replacing on Tag Change
244 * Changing the tag creates a new release and deletes the old one.
245 *
246 * **Example:** New Tag = New Release
247 * ```typescript
248 * // First deploy creates v1.0.0
249 * const release = yield* GitHub.Release("latest", {
250 * owner: "my-org",
251 * repository: "my-repo",
252 * tagName: "v1.0.0",
253 * });
254 *
255 * // Later deploy with different tag replaces it
256 * const release = yield* GitHub.Release("latest", {
257 * owner: "my-org",
258 * repository: "my-repo",
259 * tagName: "v1.1.0",
260 * });
261 * ```
262 *
263 * ### Wiring with Other Resources
264 * **Example:** Create Repository with Release
265 * ```typescript
266 * import * as Output from "alchemy/Output";
267 *
268 * const repo = yield* GitHub.Repository("sdk", {
269 * owner: "my-org",
270 * name: "sdk",
271 * autoInit: true,
272 * });
273 *
274 * yield* GitHub.Release("v1", {
275 * owner: "my-org",
276 * repository: Output.map(repo.fullName, (fullName) => fullName.split("/")[1]!),
277 * tagName: "v1.0.0",
278 * name: "Initial Release",
279 * body: "First public version of the SDK",
280 * });
281 * ```
282 *
283 * @resource
284 * @product Release
285 */
286export const Release = Resource<Release>("GitHub.Release");
287
288export const ReleaseProvider = () =>
289 Provider.succeed(Release, {
290 stables: ["releaseId", "nodeId"],
291
292 // A release belongs to (host, owner, repository, tagName) — changing any
293 // of these replaces the resource.
294 diff: Effect.fn(function* ({ news, olds }) {
295 if (!isResolved(news)) return;
296 if (olds === undefined) return;
297 if (
298 news.owner !== olds.owner ||
299 news.repository !== olds.repository ||
300 news.tagName !== olds.tagName ||
301 (yield* gitHubBaseUrlChanged(olds, news))
302 ) {
303 return { action: "replace" };
304 }
305 }),
306
307 read: Effect.fn(function* ({ olds, output }) {
308 const observed = yield* findRelease(olds, output?.releaseId);
309 return observed === undefined ? undefined : attrsOf(observed);
310 }),
311
312 reconcile: Effect.fn(function* ({ news, output }) {
313 const octokit = yield* octokitFor(news.baseUrl);
314 const body = news.body !== undefined ? dedent(news.body) : undefined;
315 let observed = yield* findRelease(news, output?.releaseId);
316
317 if (observed === undefined) {
318 observed = yield* Effect.tryPromise({
319 try: () =>
320 octokit.rest.repos.createRelease({
321 owner: news.owner,
322 repo: news.repository,
323 tag_name: news.tagName,
324 name: news.name,
325 body,
326 draft: news.draft,
327 prerelease: news.prerelease,
328 target_commitish: news.targetCommitish,
329 generate_release_notes: news.generateReleaseNotes,
330 }),
331 catch: (error) => error as Error & { status?: number },
332 }).pipe(
333 Effect.map(({ data }) => data),
334 Effect.catchIf(
335 (error) => error.status === 422,
336 (error) =>
337 findRelease(news).pipe(
338 Effect.flatMap((release) =>
339 release === undefined
340 ? Effect.fail(error)
341 : Effect.succeed(release),
342 ),
343 ),
344 ),
345 );
346 }
347
348 const desired = {
349 name: news.name ?? news.tagName,
350 body: body ?? (news.generateReleaseNotes ? (observed.body ?? "") : ""),
351 draft: news.draft ?? false,
352 prerelease: news.prerelease ?? false,
353 };
354 if (
355 (observed.name ?? observed.tag_name) === desired.name &&
356 (observed.body ?? "") === desired.body &&
357 observed.draft === desired.draft &&
358 observed.prerelease === desired.prerelease
359 ) {
360 return attrsOf(observed);
361 }
362
363 const { data } = yield* Effect.tryPromise({
364 try: () =>
365 octokit.rest.repos.updateRelease({
366 owner: news.owner,
367 repo: news.repository,
368 release_id: observed.id,
369 tag_name: news.tagName,
370 ...desired,
371 }),
372 catch: (e) => e as Error,
373 });
374
375 return attrsOf(data);
376 }),
377
378 // Enumerate every release across the repositories the token can see —
379 // releases are keyed by {owner, repository, tagName} with no account-wide
380 // list endpoint, so walk the repos like the Variable provider does.
381 list: Effect.fn(function* () {
382 const octokit = yield* Octokit;
383
384 const repos = yield* Effect.tryPromise({
385 try: () =>
386 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
387 per_page: 100,
388 }),
389 catch: (e) => e as Error,
390 });
391
392 const perRepo = yield* Effect.forEach(
393 repos,
394 (repo) =>
395 Effect.tryPromise({
396 try: async () => {
397 try {
398 const releases = await octokit.paginate(
399 octokit.rest.repos.listReleases,
400 {
401 owner: repo.owner.login,
402 repo: repo.name,
403 per_page: 100,
404 },
405 );
406 return releases.map(attrsOf);
407 } catch (error: any) {
408 // Repos where the token lacks release access reject with
409 // 403/404 — skip them rather than failing the whole enumeration.
410 if (error.status === 403 || error.status === 404) {
411 return [];
412 }
413 throw error;
414 }
415 },
416 catch: (e) => e as Error,
417 }),
418 { concurrency: 10 },
419 );
420
421 return perRepo.flat();
422 }),
423
424 delete: Effect.fn(function* ({ olds, output }) {
425 const octokit = yield* octokitFor(olds.baseUrl);
426
427 yield* Effect.tryPromise({
428 try: async () => {
429 try {
430 await octokit.rest.repos.deleteRelease({
431 owner: olds.owner,
432 repo: olds.repository,
433 release_id: output.releaseId,
434 });
435 } catch (error: any) {
436 if (error.status !== 404) {
437 throw error;
438 }
439 }
440 },
441 catch: (e) => e as Error,
442 });
443 }),
444 });
445
446const findRelease = Effect.fn(function* (
447 props: ReleaseProps,
448 releaseId?: number,
449) {
450 const octokit = yield* octokitFor(props.baseUrl);
451 const request = { owner: props.owner, repo: props.repository };
452 if (releaseId !== undefined) {
453 const existing = yield* Effect.tryPromise({
454 try: () =>
455 octokit.rest.repos.getRelease({ ...request, release_id: releaseId }),
456 catch: (error) => error as Error & { status?: number },
457 }).pipe(
458 Effect.map(({ data }) => data),
459 Effect.catchIf(
460 (error) => error.status === 404,
461 () => Effect.succeed(undefined),
462 ),
463 );
464 if (existing !== undefined && existing.tag_name === props.tagName)
465 return existing;
466 }
467 const published = yield* Effect.tryPromise({
468 try: () =>
469 octokit.rest.repos.getReleaseByTag({ ...request, tag: props.tagName }),
470 catch: (error) => error as Error & { status?: number },
471 }).pipe(
472 Effect.map(({ data }) => data),
473 Effect.catchIf(
474 (error) => error.status === 404,
475 () => Effect.succeed(undefined),
476 ),
477 );
478 if (published !== undefined) return published;
479
480 // The tag endpoint excludes drafts, including drafts recovered without state.
481 const releases = yield* Effect.tryPromise({
482 try: () =>
483 octokit.paginate(octokit.rest.repos.listReleases, {
484 ...request,
485 per_page: 100,
486 }),
487 catch: (error) => error as Error & { status?: number },
488 }).pipe(
489 Effect.catchIf(
490 (error) => error.status === 404,
491 () => Effect.succeed([]),
492 ),
493 );
494 return releases.find((release) => release.tag_name === props.tagName);
495});
496
497const attrsOf = (data: {
498 id: number;
499 node_id: string;
500 tag_name: string;
501 name: string | null;
502 body?: string | null;
503 draft: boolean;
504 prerelease: boolean;
505 html_url: string;
506 upload_url: string;
507 created_at: string;
508 published_at: string | null;
509}) => ({
510 releaseId: data.id,
511 nodeId: data.node_id,
512 tagName: data.tag_name,
513 name: data.name ?? data.tag_name,
514 body: data.body ?? "",
515 draft: data.draft,
516 prerelease: data.prerelease,
517 htmlUrl: data.html_url,
518 uploadUrl: data.upload_url,
519 createdAt: data.created_at,
520 publishedAt: data.published_at,
521});