| 1 | import * as Config from "effect/Config"; |
| 2 | import * as Console from "effect/Console"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 9 | import { MANIFEST_FILE, ManifestJson, type Manifest } from "../Manifest.ts"; |
| 10 | import { PkgApi, type TarballRef } from "../Protocol.ts"; |
| 11 | |
| 12 | export class PublishError extends Data.TaggedError("PublishError")<{ |
| 13 | readonly message: string; |
| 14 | }> {} |
| 15 | |
| 16 | export interface PublishOptions { |
| 17 | readonly cwd: string; |
| 18 | readonly dir: string; |
| 19 | readonly registry: string; |
| 20 | } |
| 21 | |
| 22 | /** |
| 23 | * The GitHub Actions run this job belongs to, from the runner's environment. |
| 24 | */ |
| 25 | const currentRun = Effect.gen(function* () { |
| 26 | const repo = yield* Config.String("GITHUB_REPOSITORY"); |
| 27 | const runId = yield* Config.Int("GITHUB_RUN_ID"); |
| 28 | const attempt = yield* Config.Int("GITHUB_RUN_ATTEMPT").pipe( |
| 29 | Config.withDefault(1), |
| 30 | ); |
| 31 | return { repo, runId, attempt }; |
| 32 | }).pipe( |
| 33 | Effect.mapError( |
| 34 | () => |
| 35 | new PublishError({ |
| 36 | message: "pkg publish must run inside a GitHub Actions job", |
| 37 | }), |
| 38 | ), |
| 39 | ); |
| 40 | |
| 41 | /** Render a registry or transport failure as a `PublishError`. */ |
| 42 | const failed = |
| 43 | (what: string) => (e: { readonly _tag: string; readonly message?: string }) => |
| 44 | new PublishError({ |
| 45 | message: `${what}: ${e.message ? `${e._tag}: ${e.message}` : e._tag}`, |
| 46 | }); |
| 47 | |
| 48 | /** |
| 49 | * Publish a `pkg pack` directory from the current GitHub Actions job. The |
| 50 | * workflow must first have uploaded the manifest as an artifact of the run |
| 51 | * under the name `pkg pack` printed; the registry verifies that artifact |
| 52 | * through GitHub, then either reports the tarballs it lacks, which are |
| 53 | * uploaded before trying again, or writes the tags. |
| 54 | */ |
| 55 | export const publish = Effect.fn("publish")(function* ( |
| 56 | options: PublishOptions, |
| 57 | ) { |
| 58 | const fs = yield* FileSystem.FileSystem; |
| 59 | const path = yield* Path.Path; |
| 60 | const registry = options.registry.replace(/\/+$/, ""); |
| 61 | const run = yield* currentRun; |
| 62 | const client = yield* HttpApiClient.make(PkgApi, { baseUrl: registry }); |
| 63 | |
| 64 | const dir = path.resolve(options.cwd, options.dir); |
| 65 | const manifestText = yield* fs.readFileString(path.join(dir, MANIFEST_FILE)); |
| 66 | const manifest = |
| 67 | yield* Schema.decodeUnknownEffect(ManifestJson)(manifestText); |
| 68 | if (manifest.registry !== registry) { |
| 69 | return yield* new PublishError({ |
| 70 | message: `artifact was packed for ${manifest.registry}, not ${registry}`, |
| 71 | }); |
| 72 | } |
| 73 | |
| 74 | if (manifest.packages.length === 0) { |
| 75 | yield* Console.log("No affected packages to publish."); |
| 76 | return { packages: [] }; |
| 77 | } |
| 78 | |
| 79 | const upload = (pkg: Manifest["packages"][number]) => |
| 80 | Effect.gen(function* () { |
| 81 | const bytes = yield* fs.readFile(path.join(dir, pkg.file)); |
| 82 | const result = yield* client.Registry.uploadTarball({ |
| 83 | params: { name: pkg.name, sha256: pkg.sha256 }, |
| 84 | query: run, |
| 85 | payload: bytes, |
| 86 | }).pipe(Effect.mapError(failed(`upload ${pkg.name}`))); |
| 87 | yield* Console.log( |
| 88 | `${result.uploaded ? "Uploaded" : "Reused"} ${pkg.name} (${pkg.sha256.slice(0, 12)}, ${pkg.size} bytes)`, |
| 89 | ); |
| 90 | }); |
| 91 | |
| 92 | const uploadMissing = (missing: ReadonlyArray<TarballRef>) => { |
| 93 | const wanted = new Set(missing.map((ref) => `${ref.name}@${ref.sha256}`)); |
| 94 | return Console.log( |
| 95 | `${missing.length} of ${manifest.packages.length} tarball(s) to upload`, |
| 96 | ).pipe( |
| 97 | Effect.andThen( |
| 98 | Effect.forEach( |
| 99 | manifest.packages.filter((pkg) => |
| 100 | wanted.has(`${pkg.name}@${pkg.sha256}`), |
| 101 | ), |
| 102 | upload, |
| 103 | { concurrency: 4, discard: true }, |
| 104 | ), |
| 105 | ), |
| 106 | ); |
| 107 | }; |
| 108 | |
| 109 | const attempt = client.Registry.publish({ |
| 110 | payload: { run, manifest: manifestText }, |
| 111 | }); |
| 112 | const published = yield* attempt.pipe( |
| 113 | // The first answer is usually the tarballs to upload; publish once more |
| 114 | // after uploading them. |
| 115 | Effect.catchTag("MissingTarballs", (e) => |
| 116 | Effect.andThen(uploadMissing(e.missing), attempt), |
| 117 | ), |
| 118 | Effect.catchTag( |
| 119 | "MissingTarballs", |
| 120 | (e) => |
| 121 | new PublishError({ |
| 122 | message: `registry still reports missing tarballs after upload: ${e.missing.map((ref) => ref.name).join(", ")}`, |
| 123 | }), |
| 124 | ), |
| 125 | Effect.mapError((e) => |
| 126 | e._tag === "PublishError" ? e : failed("publish")(e), |
| 127 | ), |
| 128 | ); |
| 129 | |
| 130 | for (const pkg of published.packages) { |
| 131 | yield* Console.log(`${pkg.name}: ${pkg.url} [${pkg.tags.join(", ")}]`); |
| 132 | } |
| 133 | return published; |
| 134 | }); |