diff --git a/scripts/release/pack-pr-package.ts b/scripts/release/pack-pr-package.ts index bd13f82..8944afa 100644 --- a/scripts/release/pack-pr-package.ts +++ b/scripts/release/pack-pr-package.ts @@ -17,21 +17,13 @@ import { type Package, type PrPackagePlan, } from "./config.ts"; - -type Manifest = { - name?: string; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; -}; - -const DEPENDENCY_SECTIONS = [ - "dependencies", - "devDependencies", - "peerDependencies", - "optionalDependencies", -] as const; +import { + DEPENDENCY_SECTIONS, + duplicateWorkspaceDependencies, + graphEdgeTag, + graphUrl, + type Manifest, +} from "./pr-package-graph.ts"; function rewriteDependencies( plan: PrPackagePlan, @@ -42,9 +34,12 @@ function rewriteDependencies( plan.packages.map((p) => [p.name, p]), ); const publishable = new Set(plan.publishable_names); + const duplicates = duplicateWorkspaceDependencies(plan); const manifest = JSON.parse( readFileSync(manifestPath, "utf-8"), ) as Manifest; + const parentName = + manifest.name ?? plan.packages.find((pkg) => pkg.dir === dir)?.name ?? dir; let rewritten = false; for (const section of DEPENDENCY_SECTIONS) { @@ -59,7 +54,10 @@ function rewriteDependencies( ); } if (!dependency) continue; - const url = `https://${plan.install_host}/${dependency.install}/${plan.dependency_tag}`; + const tag = duplicates.has(name) + ? graphEdgeTag(plan.dependency_tag, parentName) + : plan.dependency_tag; + const url = graphUrl(plan, dependency.install, tag); dependencies[name] = url; console.log( ` ${manifest.name ?? dir}: ${section}.${name}: ${value} → ${url}`, diff --git a/scripts/release/pr-package-graph.ts b/scripts/release/pr-package-graph.ts new file mode 100644 index 0000000..c62dc90 --- /dev/null +++ b/scripts/release/pr-package-graph.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import type { PrPackagePlan } from "./config.ts"; + +export type Manifest = { + name?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +}; + +export const DEPENDENCY_SECTIONS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; + +export function duplicateWorkspaceDependencies( + plan: PrPackagePlan, +): Set { + // Every pack matrix job sees the same plan and clean checkout, so each can + // derive the graph-wide duplicate set without cross-job state. + const selected = new Set(plan.packages.map((pkg) => pkg.name)); + const counts = new Map(); + + for (const pkg of plan.packages) { + const manifest = JSON.parse( + readFileSync(join(pkg.dir, "package.json"), "utf8"), + ) as Manifest; + for (const section of DEPENDENCY_SECTIONS) { + for (const [name, value] of Object.entries(manifest[section] ?? {})) { + if (value.startsWith("workspace:") && selected.has(name)) { + counts.set(name, (counts.get(name) ?? 0) + 1); + } + } + } + } + + return new Set( + [...counts].filter(([, count]) => count > 1).map(([name]) => name), + ); +} + +export function graphEdgeTag( + dependencyTag: string, + parentName: string, +): string { + // Keep the readable basename short, but hash the full package name so + // parents with the same basename in different npm scopes stay distinct. + // Bun uses the tag in a package-store file name with a 255-byte limit. + const parent = parentName.replace(/^@[^/]+\//, ""); + const hash = createHash("sha256") + .update(parentName) + .digest("hex") + .slice(0, 8); + const identity = `${parent.slice(0, 80)}-${hash}`; + return `${dependencyTag}-from-${identity}`; +} + +export function graphUrl( + plan: PrPackagePlan, + install: string, + tag: string, +): string { + return `https://${plan.install_host}/${install}/${encodeURIComponent(tag)}`; +} diff --git a/scripts/release/publish-pr-package-graph.ts b/scripts/release/publish-pr-package-graph.ts index 6ba87bf..77f7ba1 100644 --- a/scripts/release/publish-pr-package-graph.ts +++ b/scripts/release/publish-pr-package-graph.ts @@ -11,6 +11,12 @@ import { type Package, type PrPackagePlan, } from "./config.ts"; +import { + DEPENDENCY_SECTIONS, + graphEdgeTag, + graphUrl, + type Manifest, +} from "./pr-package-graph.ts"; function findTarball(artifactRoot: string, pkg: Package): string { const artifactDir = join(artifactRoot, pkg.artifact); @@ -26,6 +32,21 @@ function findTarball(artifactRoot: string, pkg: Package): string { return tarballs[0]!; } +function packedManifest(tarball: string): Manifest { + const result = Bun.spawnSync( + ["tar", "-xOf", tarball, "package/package.json"], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + if (result.exitCode !== 0) { + process.stderr.write(result.stderr); + fail(`Failed to read package/package.json from ${tarball}`); + } + return JSON.parse(result.stdout.toString()) as Manifest; +} + async function upload( host: string, token: string, @@ -77,10 +98,30 @@ const entries = plan.packages.map((pkg) => ({ pkg, tarball: findTarball(artifactRoot, pkg), })); +const byName = new Map(entries.map((entry) => [entry.pkg.name, entry])); +const dependencyTags = new Map( + entries.map(({ pkg }) => [pkg.name, new Set([plan.dependency_tag])]), +); + +for (const { pkg, tarball } of entries) { + const manifest = packedManifest(tarball); + for (const section of DEPENDENCY_SECTIONS) { + for (const [name, value] of Object.entries(manifest[section] ?? {})) { + const dependency = byName.get(name)?.pkg; + if (!dependency) continue; + const tag = graphEdgeTag(plan.dependency_tag, manifest.name ?? pkg.name); + if (value === graphUrl(plan, dependency.install, tag)) { + dependencyTags.get(name)!.add(tag); + } + } + } +} console.log("Publishing same-commit dependency graph"); for (const { pkg, tarball } of entries) { - await upload(host, token, ttl, pkg, tarball, [plan.dependency_tag]); + await upload(host, token, ttl, pkg, tarball, [ + ...dependencyTags.get(pkg.name)!, + ]); } console.log("Dependency graph complete; exposing public tags");