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 Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as SchemaIssue from "effect/SchemaIssue";
10import * as SchemaTransformation from "effect/SchemaTransformation";
11import * as ChildProcess from "effect/process/ChildProcess";
12import { exec } from "alchemy/Util/exec";
13import { sha256 } from "alchemy/Util/sha256";
14import { packTar, unpackTar, type TarHeader } from "modern-tar";
15import { gunzipSync, gzipSync } from "node:zlib";
16import {
17 GroupName,
18 MANIFEST_FILE,
19 ManifestJson,
20 type Manifest,
21 type ManifestPackage,
22} from "../Manifest.ts";
23import { manifestArtifactName, tarballUrl } from "../Protocol.ts";
24
25export class WorkspaceError extends Data.TaggedError("WorkspaceError")<{
26 readonly message: string;
27}> {}
28
29const GROUP_SPEC = /^([^[=]+?)\s*(?:\[([^\]]*)\])?\s*=\s*(.+?)\s*$/;
30
31/**
32 * A `--group` flag, `NAME=GLOB` or `NAME[Collapsed]=GLOB`, e.g.
33 * `Alchemy=./packages/*` or `Distilled[Collapsed]=./submodules/distilled/packages/*`.
34 * `Collapsed` is the only attribute.
35 */
36export const Group = Schema.String.pipe(
37 Schema.decodeTo(
38 Schema.Struct({
39 name: GroupName,
40 pattern: Schema.String,
41 /** Render the group collapsed in the install comment. */
42 collapsed: Schema.Boolean,
43 }),
44 SchemaTransformation.transformEffect({
45 decode: (spec, options) => {
46 const match = spec.match(GROUP_SPEC);
47 const attributes = (match?.[2] ?? "")
48 .split(",")
49 .map((attribute) => attribute.trim())
50 .filter((attribute) => attribute.length > 0);
51 return match &&
52 attributes.every((attribute) => attribute === "Collapsed")
53 ? Effect.succeed({
54 name: match[1]!,
55 pattern: match[3]!,
56 collapsed: attributes.includes("Collapsed"),
57 })
58 : Effect.fail(
59 new SchemaIssue.InvalidValue(
60 { expected: "NAME=GLOB or NAME[Collapsed]=GLOB" },
61 spec,
62 options,
63 ),
64 );
65 },
66 encode: (group) =>
67 Effect.succeed(
68 `${group.name}${group.collapsed ? "[Collapsed]" : ""}=${group.pattern}`,
69 ),
70 }),
71 ),
72);
73export type Group = typeof Group.Type;
74
75/** The subset of `package.json` the CLI reads. Extra keys are preserved on the raw object. */
76export const PackageJson = Schema.Struct({
77 name: Schema.optionalKey(Schema.String),
78 version: Schema.optionalKey(Schema.String),
79 private: Schema.optionalKey(Schema.Boolean),
80});
81
82export const DEPENDENCY_SECTIONS = [
83 "dependencies",
84 "devDependencies",
85 "peerDependencies",
86 "optionalDependencies",
87] as const;
88
89const DependencyMap = Schema.optionalKey(
90 Schema.Record(Schema.String, Schema.String),
91);
92
93export const DependencySections = Schema.Struct({
94 dependencies: DependencyMap,
95 devDependencies: DependencyMap,
96 peerDependencies: DependencyMap,
97 optionalDependencies: DependencyMap,
98});
99
100/**
101 * Order packages so every package comes after the packed packages it
102 * depends on, grouped into levels that can be packed concurrently. Fails on
103 * a cycle, since a tarball cannot link to a dependency that links back.
104 */
105export const dependencyLevels = Effect.fn("dependencyLevels")(function* (
106 dependencies: ReadonlyMap<string, ReadonlySet<string>>,
107) {
108 const remaining = new Map(
109 [...dependencies].map(([name, deps]) => [
110 name,
111 new Set([...deps].filter((dep) => dependencies.has(dep))),
112 ]),
113 );
114 const levels: string[][] = [];
115 while (remaining.size > 0) {
116 const ready = [...remaining]
117 .filter(([, deps]) => deps.size === 0)
118 .map(([name]) => name)
119 .sort();
120 if (ready.length === 0) {
121 return yield* new WorkspaceError({
122 message: `Dependency cycle among packed packages: ${[...remaining.keys()].sort().join(", ")}`,
123 });
124 }
125 for (const name of ready) remaining.delete(name);
126 for (const deps of remaining.values()) {
127 for (const name of ready) deps.delete(name);
128 }
129 levels.push(ready);
130 }
131 return levels;
132});
133
134export interface WorkspacePackage {
135 readonly name: string;
136 readonly version: string;
137 /** Relative to the workspace root, POSIX separators. */
138 readonly dir: string;
139 readonly absDir: string;
140 readonly group: string;
141}
142
143/** Shared inputs that invalidate every configured package. */
144export const REBUILD_ALL_PATHS = [
145 "package.json",
146 "pnpm-lock.yaml",
147 "pnpm-workspace.yaml",
148 "package-lock.json",
149 "yarn.lock",
150 "bun.lock",
151 "bun.lockb",
152 "tsconfig.json",
153 "tsconfig.base.json",
154 "turbo.json",
155 ".github/workflows/pkg.yml",
156 ".github/workflows/pr-package.yml",
157];
158
159/** Select dependents first, then dependencies, without pulling in unrelated siblings. */
160export const selectPackages = (
161 packages: ReadonlyArray<WorkspacePackage>,
162 dependencies: ReadonlyMap<string, ReadonlySet<string>>,
163 changedFiles: ReadonlyArray<string>,
164 rebuildAllPaths: ReadonlyArray<string> = [],
165) => {
166 if (
167 changedFiles.some((file) =>
168 [...REBUILD_ALL_PATHS, ...rebuildAllPaths].some((pattern) =>
169 pattern.endsWith("/**")
170 ? file === pattern.slice(0, -3) ||
171 file.startsWith(pattern.slice(0, -2))
172 : file === pattern,
173 ),
174 )
175 )
176 return [...packages];
177
178 const selected = new Set(
179 packages
180 .filter((pkg) =>
181 changedFiles.some(
182 (file) =>
183 file === pkg.dir ||
184 file.startsWith(`${pkg.dir}/`) ||
185 pkg.dir.startsWith(`${file}/`),
186 ),
187 )
188 .map((pkg) => pkg.name),
189 );
190 // A changed gitlink selects the packages nested inside that submodule.
191 let size: number;
192 do {
193 size = selected.size;
194 for (const [name, deps] of dependencies) {
195 if ([...deps].some((dep) => selected.has(dep))) selected.add(name);
196 }
197 } while (selected.size !== size);
198 const pending = [...selected];
199 for (const name of pending) {
200 for (const dep of dependencies.get(name) ?? []) {
201 if (!selected.has(dep)) {
202 selected.add(dep);
203 pending.push(dep);
204 }
205 }
206 }
207 return packages.filter((pkg) => selected.has(pkg.name));
208};
209
210/**
211 * Expand one level of `{a,b,c}` alternatives into plain patterns, so
212 * `./packages/{alchemy,pkg}` lists exactly those two directories.
213 */
214export const expandBraces = (pattern: string): string[] => {
215 const match = pattern.match(/^(.*?)\{([^{}]*)\}(.*)$/);
216 if (!match) return [pattern];
217 return match[2]!
218 .split(",")
219 .map((alternative) => alternative.trim())
220 .filter((alternative) => alternative.length > 0)
221 .flatMap((alternative) =>
222 expandBraces(`${match[1]}${alternative}${match[3]}`),
223 );
224};
225
226/**
227 * Expand a directory glob. `*` is supported as a whole path segment and
228 * `{a,b}` as a list of alternatives, which covers `./packages/*` and
229 * `./submodules/x/packages/{core,aws}`. Matches are directories only.
230 */
231const expand = Effect.fn("expandGlob")(function* (cwd: string, glob: string) {
232 const results: string[] = [];
233 for (const pattern of expandBraces(glob)) {
234 results.push(...(yield* expandPattern(cwd, pattern)));
235 }
236 return [...new Set(results)];
237});
238
239const expandPattern = Effect.fn("expandPattern")(function* (
240 cwd: string,
241 pattern: string,
242) {
243 const fs = yield* FileSystem.FileSystem;
244 const path = yield* Path.Path;
245 if (pattern.includes("**")) {
246 return yield* new WorkspaceError({
247 message: `Unsupported pattern ${JSON.stringify(pattern)}: only a single "*" segment is supported`,
248 });
249 }
250 const segments = pattern.split("/").filter((s) => s !== "" && s !== ".");
251 let current: string[] = [cwd];
252 for (const segment of segments) {
253 const next: string[] = [];
254 for (const base of current) {
255 if (segment === "*") {
256 const entries = yield* fs.readDirectory(base);
257 for (const entry of entries.sort()) {
258 const candidate = path.join(base, entry);
259 const stat = yield* fs.stat(candidate);
260 if (stat.type === "Directory") next.push(candidate);
261 }
262 } else if (segment.includes("*")) {
263 return yield* new WorkspaceError({
264 message: `Unsupported pattern ${JSON.stringify(pattern)}: "*" must be a whole path segment`,
265 });
266 } else {
267 const candidate = path.join(base, segment);
268 if (yield* fs.exists(candidate)) next.push(candidate);
269 }
270 }
271 current = next;
272 }
273 return current;
274});
275
276/**
277 * Discover publishable packages under each group's pattern, in the order the
278 * groups and their directories were given, which is the order they are
279 * listed in. Private packages and directories without a named
280 * `package.json` are skipped. A package name appearing under two groups is
281 * an error.
282 */
283export const discover = Effect.fn("discoverPackages")(function* (
284 cwd: string,
285 groups: ReadonlyArray<Group>,
286) {
287 const fs = yield* FileSystem.FileSystem;
288 const path = yield* Path.Path;
289 const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageJson));
290 const found = new Map<string, WorkspacePackage>();
291 for (const group of groups) {
292 for (const absDir of yield* expand(cwd, group.pattern)) {
293 const manifestPath = path.join(absDir, "package.json");
294 if (!(yield* fs.exists(manifestPath))) continue;
295 const manifest = yield* decode(yield* fs.readFileString(manifestPath));
296 if (manifest.private || manifest.name === undefined) continue;
297 const existing = found.get(manifest.name);
298 if (existing !== undefined) {
299 return yield* new WorkspaceError({
300 message: `Package ${manifest.name} found in both ${existing.dir} and ${path.relative(cwd, absDir)}`,
301 });
302 }
303 found.set(manifest.name, {
304 name: manifest.name,
305 version: manifest.version ?? "0.0.0",
306 dir: path.relative(cwd, absDir).split(path.sep).join("/"),
307 absDir,
308 group: group.name,
309 });
310 }
311 }
312 return [...found.values()];
313});
314
315export class GitError extends Data.TaggedError("GitError")<{
316 readonly args: ReadonlyArray<string>;
317 readonly cwd: string;
318 readonly exitCode: number;
319 readonly stderr: string;
320}> {
321 override get message() {
322 return `git ${this.args.join(" ")} in ${this.cwd} exited with ${this.exitCode}: ${this.stderr.trim()}`;
323 }
324}
325
326/** Run `git` in `cwd` and return trimmed stdout. */
327export const git = Effect.fn("git")(function* (
328 cwd: string,
329 args: ReadonlyArray<string>,
330) {
331 const { exitCode, stdout, stderr } = yield* exec(
332 ChildProcess.make("git", [...args], { cwd, shell: false }),
333 ).pipe(Effect.scoped);
334 if (exitCode !== 0) {
335 return yield* new GitError({ args, cwd, exitCode, stderr });
336 }
337 return stdout.trim();
338});
339
340/** Absolute path of the repository (or submodule) that owns `cwd`. */
341export const toplevel = (cwd: string) =>
342 git(cwd, ["rev-parse", "--show-toplevel"]);
343
344/** Full HEAD SHA of the repository that owns `cwd`. */
345const gitHead = (cwd: string) => git(cwd, ["rev-parse", "HEAD"]);
346
347export class PackError extends Data.TaggedError("PackError")<{
348 readonly dir: string;
349 readonly message: string;
350}> {}
351
352const PnpmPackOutput = Schema.fromJsonString(
353 Schema.Struct({ filename: Schema.String }),
354);
355
356/**
357 * Rewrite every dependency on a package in `links` to that package's
358 * tarball URL. Returns the rewritten manifest text and the rewrites made.
359 */
360export const rewriteDependencies = (
361 manifestText: string,
362 links: ReadonlyMap<string, string>,
363) =>
364 Effect.gen(function* () {
365 const manifest = yield* Schema.decodeUnknownEffect(
366 Schema.fromJsonString(
367 Schema.StructWithRest(DependencySections, [
368 Schema.Record(Schema.String, Schema.Unknown),
369 ]),
370 ),
371 )(manifestText);
372 // Preserve source key order after validation: it contributes to the tarball hash.
373 const rewritten = JSON.parse(manifestText);
374 const rewrites: Array<{ section: string; name: string; url: string }> = [];
375 for (const section of DEPENDENCY_SECTIONS) {
376 const deps = manifest[section];
377 if (deps === undefined) continue;
378 const next: Record<string, string> = { ...deps };
379 for (const name of Object.keys(deps)) {
380 const url = links.get(name);
381 if (url === undefined) continue;
382 next[name] = url;
383 rewrites.push({ section, name, url });
384 }
385 rewritten[section] = next;
386 }
387 return { text: `${JSON.stringify(rewritten, null, 2)}\n`, rewrites };
388 });
389
390const EPOCH = new Date(0);
391
392/**
393 * Normalize tar headers so identical inputs produce identical bytes:
394 * fixed mtime, no ownership, entries sorted by path.
395 */
396const normalize = (header: TarHeader, size: number): TarHeader => ({
397 name: header.name,
398 size,
399 mode: header.mode,
400 type: header.type ?? "file",
401 mtime: EPOCH,
402 uid: 0,
403 gid: 0,
404 uname: "",
405 gname: "",
406 ...(header.linkname !== undefined ? { linkname: header.linkname } : {}),
407});
408
409export interface PackedTarball {
410 readonly file: string;
411 readonly sha256: string;
412 readonly size: number;
413 readonly rewrites: ReadonlyArray<{
414 section: string;
415 name: string;
416 url: string;
417 }>;
418}
419
420/**
421 * Pack one package with pnpm, rewrite its dependencies on already-packed
422 * packages to their tarball URLs, and repack reproducibly into `outDir/file`.
423 */
424export const packPackage = Effect.fn("packPackage")(function* (options: {
425 readonly absDir: string;
426 /** Tarball URL of every already-packed dependency, by package name. */
427 readonly links: ReadonlyMap<string, string>;
428 readonly outDir: string;
429 readonly file: string;
430}) {
431 const fs = yield* FileSystem.FileSystem;
432 const path = yield* Path.Path;
433 const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "pkg-pack-" });
434
435 const { exitCode, stdout, stderr } = yield* exec(
436 ChildProcess.make("pnpm", ["pack", "--json", "--pack-destination", tmp], {
437 cwd: options.absDir,
438 shell: false,
439 }),
440 );
441 if (exitCode !== 0) {
442 return yield* new PackError({
443 dir: options.absDir,
444 message: `pnpm pack exited with ${exitCode}: ${stderr.trim() || stdout.trim()}`,
445 });
446 }
447 // pnpm may print progress lines before the JSON document.
448 const json = stdout.slice(stdout.indexOf("{"));
449 const { filename } = yield* Schema.decodeUnknownEffect(PnpmPackOutput)(json);
450
451 const packed = yield* fs.readFile(path.join(tmp, path.basename(filename)));
452 const inflated = yield* Effect.sync(() => gunzipSync(packed));
453 const entries = yield* Effect.promise(() => unpackTar(inflated));
454
455 let rewrites: PackedTarball["rewrites"] = [];
456 const normalized: Array<{ header: TarHeader; data: Uint8Array }> = [];
457 for (const entry of entries.sort((a, b) =>
458 a.header.name.localeCompare(b.header.name),
459 )) {
460 let data = entry.data ?? new Uint8Array();
461 if (entry.header.name === "package/package.json") {
462 const text = yield* Effect.sync(() => new TextDecoder().decode(data));
463 const result = yield* rewriteDependencies(text, options.links).pipe(
464 Effect.mapError(
465 (e) => new PackError({ dir: options.absDir, message: String(e) }),
466 ),
467 );
468 rewrites = result.rewrites;
469 data = yield* Effect.sync(() => new TextEncoder().encode(result.text));
470 }
471 normalized.push({ header: normalize(entry.header, data.byteLength), data });
472 }
473
474 const tar = yield* Effect.promise(() => packTar(normalized));
475 const bytes = yield* Effect.sync(() => gzipSync(tar, { level: 9 }));
476 yield* fs.writeFile(path.join(options.outDir, options.file), bytes);
477 return {
478 file: options.file,
479 sha256: yield* sha256(bytes),
480 size: bytes.byteLength,
481 rewrites,
482 } satisfies PackedTarball;
483});
484
485const PullRequestEvent = Schema.fromJsonString(
486 Schema.Struct({
487 pull_request: Schema.optionalKey(
488 Schema.Struct({
489 head: Schema.Struct({ sha: Schema.String }),
490 base: Schema.Struct({ sha: Schema.String }),
491 }),
492 ),
493 }),
494);
495
496/**
497 * Pull request metadata from the GitHub Actions event payload.
498 * `undefined` outside pull request events.
499 * On that event the default checkout is a synthetic merge commit, which the
500 * registry would reject because it does not match the run's head.
501 */
502const pullRequest = Effect.gen(function* () {
503 const event = yield* Config.option(Config.String("GITHUB_EVENT_NAME"));
504 const eventPath = yield* Config.option(Config.String("GITHUB_EVENT_PATH"));
505 if (
506 Option.getOrUndefined(event) !== "pull_request" ||
507 Option.isNone(eventPath)
508 ) {
509 return undefined;
510 }
511 const fs = yield* FileSystem.FileSystem;
512 const payload = yield* fs
513 .readFileString(eventPath.value)
514 .pipe(Effect.flatMap(Schema.decodeUnknownEffect(PullRequestEvent)));
515 return payload.pull_request;
516});
517
518export interface PackOptions {
519 readonly cwd: string;
520 readonly groups: ReadonlyArray<Group>;
521 readonly registry: string;
522 readonly out: string;
523 readonly since?: string;
524 readonly all?: boolean;
525 readonly rebuildAllPaths?: ReadonlyArray<string>;
526}
527
528/**
529 * Tarball file name for a package: `@` dropped and the scope separator
530 * replaced by `+`, a character package names cannot contain, so `@a/b-c`
531 * and `@a-b/c` get distinct files.
532 */
533export const tarballFile = (name: string) =>
534 `${name.replace(/^@/, "").replace("/", "+")}.tgz`;
535
536/**
537 * Pack every discovered package into `out` with a manifest describing each
538 * tarball. Nothing here talks to the registry.
539 */
540export const pack = Effect.fn("pack")(function* (options: PackOptions) {
541 const fs = yield* FileSystem.FileSystem;
542 const path = yield* Path.Path;
543
544 const root = yield* toplevel(options.cwd);
545 const head = yield* gitHead(root);
546 const pr = yield* pullRequest;
547 const prHead = pr?.head.sha;
548 if (prHead !== undefined && prHead !== head) {
549 return yield* new WorkspaceError({
550 message:
551 `HEAD is ${head} but the pull request head is ${prHead}. ` +
552 "Check out github.event.pull_request.head.sha before packing so tarballs are addressed by a commit that exists on the pull request.",
553 });
554 }
555 let packages = yield* discover(options.cwd, options.groups);
556
557 // Dependencies between packed packages are rewritten to the dependency's
558 // immutable tarball URL, so a package is packed only after everything it
559 // depends on has a hash.
560 const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
561 const dependencies = new Map<string, Set<string>>();
562 for (const pkg of packages) {
563 const manifest = yield* fs
564 .readFileString(path.join(pkg.absDir, "package.json"))
565 .pipe(
566 Effect.flatMap(
567 Schema.decodeUnknownEffect(Schema.fromJsonString(DependencySections)),
568 ),
569 );
570 dependencies.set(
571 pkg.name,
572 new Set(
573 DEPENDENCY_SECTIONS.flatMap((section) =>
574 Object.keys(manifest[section] ?? {}),
575 ).filter((name) => name !== pkg.name && byName.has(name)),
576 ),
577 );
578 }
579 const since = options.all ? undefined : (options.since ?? pr?.base.sha);
580 if (since !== undefined) {
581 // Disable rename detection so moves affect both the old and new packages.
582 const changed = (yield* git(root, [
583 "diff",
584 "--name-only",
585 "--no-renames",
586 "-z",
587 since,
588 head,
589 "--",
590 ]))
591 .split("\0")
592 .filter(Boolean);
593 const selected = selectPackages(
594 packages.map((pkg) => ({
595 ...pkg,
596 dir: path.relative(root, pkg.absDir).split(path.sep).join("/"),
597 })),
598 dependencies,
599 changed,
600 options.rebuildAllPaths,
601 );
602 const names = new Set(selected.map((pkg) => pkg.name));
603 packages = packages.filter((pkg) => names.has(pkg.name));
604 for (const name of dependencies.keys()) {
605 if (!names.has(name)) dependencies.delete(name);
606 }
607 yield* Console.log(
608 `Selected ${packages.length} package(s) changed since ${since}, including dependents and dependencies.`,
609 );
610 }
611 const levels = yield* dependencyLevels(dependencies);
612
613 // The output directory is replaced wholesale, so it has to be a proper
614 // subdirectory of the workspace: `--out .` would otherwise delete it.
615 const outDir = path.resolve(options.cwd, options.out);
616 const relative = path.relative(options.cwd, outDir);
617 if (
618 relative === "" ||
619 relative.startsWith("..") ||
620 path.isAbsolute(relative)
621 ) {
622 return yield* new WorkspaceError({
623 message: `--out must be a subdirectory of ${options.cwd}, got ${outDir}`,
624 });
625 }
626 yield* fs.remove(outDir, { recursive: true, force: true });
627 yield* fs.makeDirectory(outDir, { recursive: true });
628
629 const links = new Map<string, string>();
630 const packedByName = new Map<string, ManifestPackage>();
631 for (const level of levels) {
632 const packedLevel = yield* Effect.forEach(
633 level,
634 Effect.fn(function* (name) {
635 const pkg = byName.get(name)!;
636 const packed = yield* packPackage({
637 absDir: pkg.absDir,
638 links,
639 outDir,
640 file: tarballFile(pkg.name),
641 }).pipe(Effect.scoped);
642 const lines = [
643 `${pkg.name}@${pkg.version} ${packed.sha256.slice(0, 12)} ${packed.size} bytes`,
644 ...packed.rewrites.map((r) => ` ${r.section}.${r.name} -> ${r.url}`),
645 ];
646 yield* Console.log(lines.join("\n"));
647 return {
648 name: pkg.name,
649 version: pkg.version,
650 dir: pkg.dir,
651 group: pkg.group,
652 file: packed.file,
653 sha256: packed.sha256,
654 size: packed.size,
655 } satisfies ManifestPackage;
656 }),
657 { concurrency: 4 },
658 );
659 for (const entry of packedLevel) {
660 links.set(
661 entry.name,
662 tarballUrl(options.registry, entry.name, entry.sha256),
663 );
664 packedByName.set(entry.name, entry);
665 }
666 }
667 // Packing ran in dependency order; the manifest keeps the listed order.
668 const entries = packages.map((pkg) => packedByName.get(pkg.name)!);
669
670 // One entry per distinct group name, in the order the groups were given.
671 const groups = [
672 ...new Map(
673 options.groups.map((group) => [
674 group.name,
675 { name: group.name, collapsed: group.collapsed },
676 ]),
677 ).values(),
678 ];
679 const manifest: Manifest = {
680 version: 1,
681 groups,
682 registry: options.registry.replace(/\/+$/, ""),
683 head,
684 packages: entries,
685 };
686 const manifestText = `${yield* Schema.encodeEffect(ManifestJson)(manifest)}\n`;
687 yield* fs.writeFileString(path.join(outDir, MANIFEST_FILE), manifestText);
688 yield* Console.log(
689 `Packed ${entries.length} package(s) into ${path.relative(options.cwd, outDir) || "."}`,
690 );
691
692 // The workflow uploads the manifest as an artifact under this name; that
693 // upload is what proves to the registry that this run vouched for it.
694 // Only a JavaScript action receives the runtime token needed to upload,
695 // so the CLI cannot do it itself.
696 const artifact = manifestArtifactName(yield* sha256(manifestText));
697 const stepOutput = yield* Config.option(Config.String("GITHUB_OUTPUT"));
698 if (Option.isSome(stepOutput)) {
699 yield* fs.writeFileString(
700 stepOutput.value,
701 `artifact-name=${artifact}\npackage-count=${entries.length}\n`,
702 {
703 flag: "a",
704 },
705 );
706 }
707 yield* Console.log(`Manifest artifact name: ${artifact}`);
708 return manifest;
709});