1import * as Config from "effect/Config";
2import * as Console from "effect/Console";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import * as HttpApiClient from "effect/http-api/HttpApiClient";
9import { MANIFEST_FILE, ManifestJson, type Manifest } from "../Manifest.ts";
10import { PkgApi, type TarballRef } from "../Protocol.ts";
11
12export class PublishError extends Data.TaggedError("PublishError")<{
13 readonly message: string;
14}> {}
15
16export 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 */
25const 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`. */
42const 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 */
55export 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});