| 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 Option from "effect/Option"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as SchemaIssue from "effect/SchemaIssue"; |
| 10 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 11 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 12 | import { exec } from "alchemy/Util/exec"; |
| 13 | import { sha256 } from "alchemy/Util/sha256"; |
| 14 | import { packTar, unpackTar, type TarHeader } from "modern-tar"; |
| 15 | import { gunzipSync, gzipSync } from "node:zlib"; |
| 16 | import { |
| 17 | GroupName, |
| 18 | MANIFEST_FILE, |
| 19 | ManifestJson, |
| 20 | type Manifest, |
| 21 | type ManifestPackage, |
| 22 | } from "../Manifest.ts"; |
| 23 | import { manifestArtifactName, tarballUrl } from "../Protocol.ts"; |
| 24 | |
| 25 | export class WorkspaceError extends Data.TaggedError("WorkspaceError")<{ |
| 26 | readonly message: string; |
| 27 | }> {} |
| 28 | |
| 29 | const 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 | */ |
| 36 | export 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 | ); |
| 73 | export type Group = typeof Group.Type; |
| 74 | |
| 75 | /** The subset of `package.json` the CLI reads. Extra keys are preserved on the raw object. */ |
| 76 | export const PackageJson = Schema.Struct({ |
| 77 | name: Schema.optionalKey(Schema.String), |
| 78 | version: Schema.optionalKey(Schema.String), |
| 79 | private: Schema.optionalKey(Schema.Boolean), |
| 80 | }); |
| 81 | |
| 82 | export const DEPENDENCY_SECTIONS = [ |
| 83 | "dependencies", |
| 84 | "devDependencies", |
| 85 | "peerDependencies", |
| 86 | "optionalDependencies", |
| 87 | ] as const; |
| 88 | |
| 89 | const DependencyMap = Schema.optionalKey( |
| 90 | Schema.Record(Schema.String, Schema.String), |
| 91 | ); |
| 92 | |
| 93 | export 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 | */ |
| 105 | export 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 | |
| 134 | export 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. */ |
| 144 | export 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. */ |
| 160 | export 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 | */ |
| 214 | export 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 | */ |
| 231 | const 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 | |
| 239 | const 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 | */ |
| 283 | export 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 | |
| 315 | export 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. */ |
| 327 | export 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`. */ |
| 341 | export const toplevel = (cwd: string) => |
| 342 | git(cwd, ["rev-parse", "--show-toplevel"]); |
| 343 | |
| 344 | /** Full HEAD SHA of the repository that owns `cwd`. */ |
| 345 | const gitHead = (cwd: string) => git(cwd, ["rev-parse", "HEAD"]); |
| 346 | |
| 347 | export class PackError extends Data.TaggedError("PackError")<{ |
| 348 | readonly dir: string; |
| 349 | readonly message: string; |
| 350 | }> {} |
| 351 | |
| 352 | const 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 | */ |
| 360 | export 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 | |
| 390 | const 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 | */ |
| 396 | const 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 | |
| 409 | export 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 | */ |
| 424 | export 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 | |
| 485 | const 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 | */ |
| 502 | const 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 | |
| 518 | export 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 | */ |
| 533 | export 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 | */ |
| 540 | export 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 | }); |