From 552a95c3f38edbf4e07568043d33b16e30df1db8 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sat, 5 Sep 2026 03:32:55 +0900 Subject: [PATCH 01/44] feat: publish workspace dependency closures --- src/index.ts | 104 +++++- src/publish-repository/create.ts | 2 + src/publish-repository/prepare-branch.ts | 22 +- src/publish-repository/publish-closure.ts | 371 ++++++++++++++++++++++ tests/index.ts | 1 + tests/specs/workspace-publication.ts | 192 +++++++++++ 6 files changed, 687 insertions(+), 5 deletions(-) create mode 100644 src/publish-repository/publish-closure.ts create mode 100644 tests/specs/workspace-publication.ts diff --git a/src/index.ts b/src/index.ts index 68fee4a..1c97a27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,12 @@ import { getGitHubRepositoryName } from './utils/github.ts'; import { createPublishRepository, type PublishRepository } from './publish-repository/create.ts'; import { preparePublishBranch } from './publish-repository/prepare-branch.ts'; import { getPublishRemote } from './publish-repository/remote.ts'; +import { + formatClosurePlan, + planWorkspacePublication, + publishWorkspaceClosure, + type PackagePreparation, +} from './publish-repository/publish-closure.ts'; const { stringify } = JSON; @@ -92,6 +98,13 @@ const { stringify } = JSON; } const workspaceDependencies: string[] = []; + const closurePackageManager = await detectPackageManager(cwd, gitRootPath); + const closurePlan = await planWorkspacePublication({ + cwd, + gitRootPath, + sourceName, + packageManager: closurePackageManager, + }).catch(() => undefined); for (const [field, dependencies] of Object.entries({ dependencies: packageJson.dependencies, optionalDependencies: packageJson.optionalDependencies, @@ -107,7 +120,7 @@ const { stringify } = JSON; } } - if (workspaceDependencies.length > 0) { + if (!closurePlan && workspaceDependencies.length > 0) { throw new Error(`Cannot publish packages with workspace dependencies: ${workspaceDependencies.join('\n')} Pre-bundle these dependencies before publishing.`); @@ -130,6 +143,95 @@ Pre-bundle these dependencies before publishing.`); const publishRemote = await getPublishRemote(gitRootPath, remote, usedDefaultRemote); const remoteUrl = publishRemote.fetchUrl; + if (closurePlan) { + if (branch) { + throw new Error('The --branch flag is not supported for workspace publication. Each package publishes to its own derived branch.'); + } + if (publishRemote.pushUrls.length !== 1) { + throw new Error(`Workspace publication requires exactly one push URL, but remote ${stringify(remote)} has ${publishRemote.pushUrls.length}.`); + } + + if (dry) { + console.log(formatClosurePlan(closurePlan, sourceName)); + } + + const packageCount = closurePlan.graph.nodes.length; + await task( + `Publishing workspace closure ${stringify(closurePlan.graph.selected)} from ${stringify(sourceName)} (${packageCount} packages)`, + async ({ setTitle, setStatus, setOutput }) => { + if (dry) { + setStatus('Dry run'); + } + + let success = false; + let preparations: PackagePreparation[] = []; + + try { + if (!dry) { + preparations = await publishWorkspaceClosure({ + plan: closurePlan, + packageManager: closurePackageManager, + sourceRepositoryPath: gitRootPath, + gitRootPath, + publishRemote, + sourceName, + sourceCommit: sourceCommit ?? undefined, + fresh, + }); + success = true; + } + } catch (error) { + if (error instanceof SubprocessError) { + const details = error.output || error.stderr; + if (details) { + console.error(details); + } + } + throw error; + } + + for (const preparation of preparations) { + console.log(lightBlue(`Publishing ${preparation.publication.packageName}`)); + console.log(preparation.files.map(({ file, size }) => `${file} ${dim(byteSize(size).toString())}`).join('\n')); + console.log(`\n${lightBlue('Total size')}`, byteSize(preparation.files.reduce((total, { size }) => total + size, 0)).toString()); + } + + if (success) { + const selectedName = closurePlan.graph.selected; + const selected = preparations.find( + preparation => preparation.publication.packageName === selectedName, + ); + if (!selected) { + throw new Error(`Missing publication for ${JSON.stringify(selectedName)}.`); + } + const repositoryName = getGitHubRepositoryName(remoteUrl); + if (repositoryName) { + const successLink = terminalLink( + `${cyan(selected.publication.branch)} ${dim(`(${selected.publication.commit})`)}`, + `https://github.com/${repositoryName}/tree/${selected.publication.branch!}`, + ); + setTitle(`Successfully published ${packageCount} packages: ${successLink}`); + } else { + setTitle(`Successfully published ${packageCount} packages`); + } + + const output = [ + 'Install command', + `${closurePackageManager} i '${selected.publication.installSpecifier}'`, + ].join('\n'); + + setOutput(output); + } + }, + ).catch(() => { + // Any failure here is already rendered within the task tree above + // (including the pack subprocess output), so exit without re-printing it. + // Set exitCode (instead of process.exit) so tasuku can flush its final render. + process.exitCode = 1; + }); + return; + } + await task( `Publishing source ${stringify(sourceName)} → ${stringify(publishBranch)}`, async ({ diff --git a/src/publish-repository/create.ts b/src/publish-repository/create.ts index 8aa5b7d..865b42d 100644 --- a/src/publish-repository/create.ts +++ b/src/publish-repository/create.ts @@ -8,6 +8,7 @@ import { } from './remote.ts'; export type PublishRepository = { + temporaryDirectory: string; publishWorktreePath: string; packWorktreePath: string; packTemporaryDirectory: string; @@ -91,6 +92,7 @@ export const createPublishRepository = async ({ } return { + temporaryDirectory, publishWorktreePath, packWorktreePath, packTemporaryDirectory, diff --git a/src/publish-repository/prepare-branch.ts b/src/publish-repository/prepare-branch.ts index ab6255f..77a89a2 100644 --- a/src/publish-repository/prepare-branch.ts +++ b/src/publish-repository/prepare-branch.ts @@ -6,11 +6,13 @@ export const preparePublishBranch = async ({ publishBranch, localBranch, fresh, + worktreePath, }: { repository: PublishRepository; publishBranch: string; localBranch: string; fresh: boolean | undefined; + worktreePath?: string; }) => { const { gitOptions, fetchRemoteName } = repository; @@ -45,19 +47,31 @@ export const preparePublishBranch = async ({ } } + const worktreeOptions = worktreePath + ? { + cwd: worktreePath, + env: gitOptions.env, + } + : gitOptions; + if (worktreePath) { + // A linked worktree starts from the fetched branch, or from the + // shared HEAD when preparing an orphan branch. + await spawn('git', ['worktree', 'add', '--force', worktreePath, orphan ? 'HEAD' : localBranch], gitOptions); + } + if (orphan) { // Fresh orphan branch with no history - await spawn('git', ['checkout', '--orphan', localBranch], gitOptions); + await spawn('git', ['checkout', '--orphan', localBranch], worktreeOptions); } else { // Repoint HEAD to the fetched branch without checkout - await spawn('git', ['symbolic-ref', 'HEAD', `refs/heads/${localBranch}`], gitOptions); + await spawn('git', ['symbolic-ref', 'HEAD', `refs/heads/${localBranch}`], worktreeOptions); } // Remove all files from index and working directory // removes tracked files from index (.catch() since it fails on empty orphan branches) - await spawn('git', ['rm', '--cached', '-r', ':/'], gitOptions).catch(() => {}); + await spawn('git', ['rm', '--cached', '-r', ':/'], worktreeOptions).catch(() => {}); // removes all untracked files from the working directory - await spawn('git', ['clean', '-fdx'], gitOptions); + await spawn('git', ['clean', '-fdx'], worktreeOptions); }; diff --git a/src/publish-repository/publish-closure.ts b/src/publish-repository/publish-closure.ts new file mode 100644 index 0000000..3054a06 --- /dev/null +++ b/src/publish-repository/publish-closure.ts @@ -0,0 +1,371 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import spawn from 'nano-spawn'; +import type { PackageJson } from '@npmcli/package-json'; +import { getStdout } from '../utils/get-stdout.ts'; +import type { PackageManager } from '../utils/detect-package-manager.ts'; +import { readJson } from '../utils/read-json.ts'; +import { packPackage } from '../utils/pack-package.ts'; +import { extractTarball, type File } from '../utils/extract-tarball.ts'; +import { gitStatusTracked } from '../utils/git.ts'; +import { + createPublishGraph, resolvePackageDirectory, type PublishGraph, type PublishGraphNode, +} from './graph.ts'; +import { preparePublishBranch } from './prepare-branch.ts'; +import { runDependencyGraph, type GraphNode } from './run-graph.ts'; +import { discoverWorkspacePackages, type Workspace } from './workspace.ts'; +import { createPublishRepository, type PublishRepository } from './create.ts'; +import type { PublishRemote } from './remote.ts'; + +const { stringify } = JSON; + +export type ClosurePlan = { + workspace: Workspace; + graph: PublishGraph; + branches: Map; +}; + +export type PackagePublication = { + packageName: string; + branch: string; + commit: string; + installSpecifier: string; + refspec: string; +}; + +export type PackagePreparation = { + publication: PackagePublication; + files: File[]; +}; + +type ClosureTask = { + node: PublishGraphNode; + tarball: string; + worktree: string; +}; + +export const planWorkspacePublication = async ({ + cwd, + gitRootPath, + sourceName, + packageManager, +}: { + cwd: string; + gitRootPath: string; + sourceName: string; + packageManager: PackageManager; +}): Promise => { + let workspace: Workspace; + try { + workspace = await discoverWorkspacePackages(cwd, packageManager); + } catch { + // Not a workspace context. The single-package flow applies, and it + // reports unreadable manifests through its own checks. + return undefined; + } + let selected: string; + try { + selected = resolvePackageDirectory(workspace, cwd).name; + } catch { + // The working directory is not inside a workspace package (for + // example, the repository root). The single-package flow applies. + return undefined; + } + const graph = createPublishGraph(workspace, selected); + const branches = new Map(); + for (const node of graph.nodes) { + const relative = path.relative(gitRootPath, node.package.dir); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Workspace package ${JSON.stringify(node.key)} is outside the Git repository and cannot be published.`); + } + const branch = `npm/${sourceName}-${node.key}`; + try { + await getStdout(spawn('git', ['check-ref-format', '--branch', branch])); + } catch { + throw new Error(`Invalid publish branch ${JSON.stringify(branch)}.`); + } + branches.set(node.key, branch); + } + return { + workspace, + graph, + branches, + }; +}; + +export const packClosurePackages = async ({ + plan, + packageManager, + repository, + gitRootPath, +}: { + plan: ClosurePlan; + packageManager: PackageManager; + repository: PublishRepository; + gitRootPath: string; +}): Promise> => { + const tarballs = new Map(); + for (const [index, node] of plan.graph.nodes.entries()) { + const tarball = await packPackage( + packageManager, + repository.packWorktreePath, + path.join(repository.packTemporaryDirectory, String(index)), + node.package.dir, + gitRootPath, + path.relative(gitRootPath, node.package.dir), + ); + tarballs.set(node.key, tarball); + } + return tarballs; +}; + +const toPackageManagerGitUrl = (url: string) => { + if (url.startsWith('git+')) { + return url; + } + if (/^(?:file|git|https?|ssh):\/\//.test(url)) { + return `git+${url}`; + } + const scpUrl = /^(?[^@/:]+@)?(?[^/:]+):(?.+)$/.exec(url)?.groups; + if (scpUrl) { + return `git+ssh://${scpUrl.user ?? ''}${scpUrl.host}/${scpUrl.path}`; + } + return `git+file://${path.resolve(url)}`; +}; + +export const toInstallSpecifier = (fetchUrl: string, commit: string) => `${toPackageManagerGitUrl(fetchUrl)}#${commit}`; + +export const prepareClosureBranches = async ({ + plan, + repository, + fresh, +}: { + plan: ClosurePlan; + repository: PublishRepository; + fresh: boolean | undefined; +}): Promise> => { + const worktrees = new Map(); + plan.graph.nodes.forEach((node, index) => { + worktrees.set(node.key, path.join(repository.temporaryDirectory, `publish-worktree-${index}`)); + }); + for (const node of plan.graph.nodes) { + await preparePublishBranch({ + repository, + publishBranch: plan.branches.get(node.key)!, + localBranch: `git-publish-${randomBytes(16).toString('hex')}`, + fresh, + worktreePath: worktrees.get(node.key)!, + }); + } + return worktrees; +}; + +export const commitClosureSnapshots = async ({ + plan, + repository, + tarballs, + worktrees, + sourceName, + sourceCommit, + fetchUrl, +}: { + plan: ClosurePlan; + repository: PublishRepository; + tarballs: Map; + worktrees: Map; + sourceName: string; + sourceCommit: string | undefined; + fetchUrl: string; +}): Promise => { + const adapted: GraphNode[] = plan.graph.nodes.map(node => ({ + key: node.key, + value: { + node, + tarball: tarballs.get(node.key)!, + worktree: worktrees.get(node.key)!, + } satisfies ClosureTask, + dependencies: node.dependencies.map(edge => edge.target), + })); + const results = await runDependencyGraph(adapted, async ( + { key, value }, + dependencyResults, + ): Promise => { + const worktreeOptions = { + cwd: value.worktree, + env: repository.gitOptions.env, + }; + const files = await extractTarball(value.tarball, value.worktree); + const manifestPath = path.join(value.worktree, 'package.json'); + const manifest = await readJson(manifestPath) as PackageJson; + const original = stringify(manifest); + for (const edge of value.node.dependencies) { + const dependency = dependencyResults.get(edge.target)!; + const field = manifest[edge.field] ?? {}; + field[edge.key] = dependency.publication.installSpecifier; + manifest[edge.field] = field; + } + const { scripts } = manifest; + if (scripts && ('prepare' in scripts || 'prepack' in scripts)) { + delete scripts.prepare; + delete scripts.prepack; + } + if (stringify(manifest) !== original) { + await fs.writeFile(manifestPath, stringify(manifest, null, 2)); + } + await spawn('git', ['add', '--all'], worktreeOptions); + const tracked = await gitStatusTracked(worktreeOptions); + let commit: string; + if (tracked.length === 0) { + console.warn(`⚠️ No new changes found for ${key}, keeping the existing publish branch.`); + commit = await getStdout(spawn('git', ['rev-parse', 'HEAD'], worktreeOptions)); + } else { + let commitMessage = `Published ${JSON.stringify(key)} from ${JSON.stringify(sourceName)}`; + if (sourceCommit) { + commitMessage += ` (${sourceCommit})`; + } + await spawn('git', [ + '-c', + 'user.name=git-publish', + '-c', + 'user.email=bot@git-publish', + 'commit', + '--no-verify', + '-m', + commitMessage, + '--author=git-publish ', + ], worktreeOptions); + commit = await getStdout(spawn('git', ['rev-parse', 'HEAD'], worktreeOptions)); + } + const branch = plan.branches.get(key)!; + const installSpecifier = toInstallSpecifier(fetchUrl, commit); + return { + publication: { + packageName: key, + branch, + commit, + installSpecifier, + refspec: `${commit}:refs/heads/${branch}`, + }, + files, + }; + }); + return plan.graph.nodes.map(node => results.get(node.key)!); +}; + +export const readRemoteTips = async ( + repository: PublishRepository, +): Promise> => { + const output = await getStdout(spawn('git', ['ls-remote', repository.fetchRemoteName], repository.gitOptions)); + const tips = new Map(); + for (const line of output.split('\n')) { + const separator = line.indexOf('\t'); + if (separator === -1) { + continue; + } + const sha = line.slice(0, separator); + const ref = line.slice(separator + 1); + if (ref.startsWith('refs/heads/')) { + tips.set(ref.slice('refs/heads/'.length), sha); + } + } + return tips; +}; + +export const pushClosureReferences = async ({ + repository, + preparations, + fresh, + remoteTips, +}: { + repository: PublishRepository; + preparations: PackagePreparation[]; + fresh: boolean | undefined; + remoteTips?: Map; +}): Promise => { + const [pushRemoteName] = repository.pushRemoteNames; + const args = ['push', '--atomic']; + if (fresh) { + for (const { publication } of preparations) { + args.push(`--force-with-lease=refs/heads/${publication.branch}:${remoteTips?.get(publication.branch) ?? ''}`); + } + } + args.push('--no-verify', pushRemoteName!, ...preparations.map(preparation => preparation.publication.refspec)); + await spawn('git', args, repository.gitOptions); +}; + +export const publishWorkspaceClosure = async ({ + plan, + packageManager, + sourceRepositoryPath, + gitRootPath, + publishRemote, + sourceName, + sourceCommit, + fresh, +}: { + plan: ClosurePlan; + packageManager: PackageManager; + sourceRepositoryPath: string; + gitRootPath: string; + publishRemote: PublishRemote; + sourceName: string; + sourceCommit: string | undefined; + fresh: boolean | undefined; +}): Promise => { + const repository = await createPublishRepository({ + sourceRepositoryPath, + publishRemote, + }); + let primaryError: unknown; + try { + const remoteTips = fresh ? await readRemoteTips(repository) : undefined; + const worktrees = await prepareClosureBranches({ + plan, + repository, + fresh, + }); + const tarballs = await packClosurePackages({ + plan, + packageManager, + repository, + gitRootPath, + }); + const preparations = await commitClosureSnapshots({ + plan, + repository, + tarballs, + worktrees, + sourceName, + sourceCommit, + fetchUrl: publishRemote.fetchUrl, + }); + await pushClosureReferences({ + repository, + preparations, + fresh, + remoteTips, + }); + return preparations; + } catch (error) { + primaryError = error; + throw error; + } finally { + await repository.dispose().catch((cleanupError: unknown) => { + if (primaryError) { + throw new AggregateError([primaryError, cleanupError], 'Failed to publish workspace closure.'); + } + throw cleanupError; + }); + } +}; + +export const formatClosurePlan = (plan: ClosurePlan, sourceName: string): string => { + const lines = [`Publishing workspace closure from ${JSON.stringify(sourceName)}:`]; + for (const node of plan.graph.nodes) { + const branch = plan.branches.get(node.key)!; + const rewrites = node.dependencies.map(edge => `${edge.key} → ${plan.branches.get(edge.target)!}`).join(', '); + lines.push(`- ${node.key} → ${branch}${rewrites ? ` (dependencies: ${rewrites})` : ''}`); + } + return lines.join('\n'); +}; diff --git a/tests/index.ts b/tests/index.ts index 6e636ec..db465b6 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -2,6 +2,7 @@ import { describe } from 'manten'; describe('git-publish', () => { import('./specs/workspace-discovery.ts'); + import('./specs/workspace-publication.ts'); import('./specs/run-graph.ts'); import('./specs/publish-graph.ts'); import('./specs/github-remotes.ts'); diff --git a/tests/specs/workspace-publication.ts b/tests/specs/workspace-publication.ts new file mode 100644 index 0000000..1bcab9e --- /dev/null +++ b/tests/specs/workspace-publication.ts @@ -0,0 +1,192 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { + describe, test, expect, onFinish, +} from 'manten'; +import { createFixture } from 'fs-fixture'; +import spawn from 'nano-spawn'; +import { createGitFixture } from '../utils/create-git.ts'; +import { gitPublish } from '../utils/git-publish.ts'; + +describe('Workspace publication', async () => { + const remoteFixture = await createGitFixture(undefined, ['--bare']); + onFinish(() => remoteFixture.rm()); + const { git: remoteGit } = remoteFixture; + + const createChainWorkspace = async (branchName: string, remote: string) => { + const fixture = await createGitFixture({ + 'package.json': JSON.stringify({ + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + }, null, 2), + 'package-lock.json': '{}', + packages: { + core: { + 'package.json': JSON.stringify({ + name: '@test/core', + version: '0.0.0', + }, null, 2), + 'index.js': 'module.exports = { core: 1 };', + }, + broker: { + 'package.json': JSON.stringify({ + name: '@test/broker', + version: '0.0.0', + dependencies: { + '@test/core': 'workspace:*', + }, + }, null, 2), + 'index.js': 'module.exports = { core: require("@test/core") };', + }, + adapter: { + 'package.json': JSON.stringify({ + name: '@test/adapter', + version: '0.0.0', + dependencies: { + '@test/broker': 'workspace:*', + }, + }, null, 2), + 'index.js': 'module.exports = { broker: require("@test/broker") };', + }, + }, + }, [`--initial-branch=${branchName}`]); + const { git } = fixture; + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', remote]); + return fixture; + }; + + test('rejects --branch for workspace publication', async () => { + const branchName = 'test-workspace-branch-flag'; + await using fixture = await createChainWorkspace(branchName, remoteFixture.path); + + const gitPublishProcess = await gitPublish(path.join(fixture.path, 'packages/adapter'), ['--branch', 'custom']); + + expect(('exitCode' in gitPublishProcess) && gitPublishProcess.exitCode).toBe(1); + expect(gitPublishProcess.stderr).toBe('Error: The --branch flag is not supported for workspace publication. Each package publishes to its own derived branch.'); + expect(await remoteGit('for-each-ref')).toBe(''); + }); + + test('rejects multiple push URLs', async () => { + const branchName = 'test-workspace-push-urls'; + await using secondPushFixture = await createGitFixture(undefined, ['--bare']); + const { git: secondRemoteGit } = secondPushFixture; + await using fixture = await createChainWorkspace(branchName, remoteFixture.path); + const { git } = fixture; + await git('config', ['--add', 'remote.origin.pushurl', remoteFixture.path]); + await git('config', ['--add', 'remote.origin.pushurl', secondPushFixture.path]); + + const gitPublishProcess = await gitPublish(path.join(fixture.path, 'packages/adapter')); + + expect(('exitCode' in gitPublishProcess) && gitPublishProcess.exitCode).toBe(1); + expect(gitPublishProcess.stderr).toContain('requires exactly one push URL'); + expect(await remoteGit('for-each-ref')).toBe(''); + expect(await secondRemoteGit('for-each-ref')).toBe(''); + }); + + test('does not update any package branch when an atomic push is rejected', async () => { + const branchName = 'test-workspace-atomic-rejection'; + await using rejectedRemoteFixture = await createGitFixture(undefined, ['--bare']); + const { git: rejectedRemoteGit } = rejectedRemoteFixture; + const hookPath = path.join(rejectedRemoteFixture.path, 'hooks/pre-receive'); + await fs.writeFile(hookPath, `#!/bin/sh + while read _ _ ref; do + if [ "$ref" = "refs/heads/npm/${branchName}-@test/adapter" ]; then + exit 1 + fi + done +`); + await fs.chmod(hookPath, 0o755); + await using fixture = await createChainWorkspace(branchName, rejectedRemoteFixture.path); + + const gitPublishProcess = await gitPublish(path.join(fixture.path, 'packages/adapter')); + + expect(('exitCode' in gitPublishProcess) && gitPublishProcess.exitCode).toBe(1); + expect(await rejectedRemoteGit('for-each-ref')).toBe(''); + }); + + test('publishes the closure and installs the selected package', async () => { + const branchName = 'test-workspace-acceptance'; + const remoteUrl = `git@example.test:${remoteFixture.path}`; + const packageManagerRemoteUrl = `git+ssh://git@example.test/${remoteFixture.path}`; + await using commandsFixture = await createFixture(async (fixture) => { + await fixture.writeFile('ssh', `#!/bin/sh +shift +exec sh -c "$*" +`); + await fixture.writeFile('upload-pack', `#!/bin/sh + if [ "$1" = "-G" ]; then + exit 0 + fi + exec git-upload-pack '${remoteFixture.path}' +`); + await fs.chmod(fixture.getPath('ssh'), 0o755); + await fs.chmod(fixture.getPath('upload-pack'), 0o755); + }); + await using fixture = await createChainWorkspace(branchName, remoteUrl); + const { git } = fixture; + await git('config', ['core.sshCommand', commandsFixture.getPath('ssh')]); + await git('config', ['ssh.variant', 'simple']); + + const adapterPath = path.join(fixture.path, 'packages/adapter'); + const gitPublishProcess = await gitPublish(adapterPath); + expect('exitCode' in gitPublishProcess).toBe(false); + + const branches = { + core: `npm/${branchName}-@test/core`, + broker: `npm/${branchName}-@test/broker`, + adapter: `npm/${branchName}-@test/adapter`, + }; + const shas = { + core: await remoteGit('rev-parse', [branches.core]), + broker: await remoteGit('rev-parse', [branches.broker]), + adapter: await remoteGit('rev-parse', [branches.adapter]), + }; + for (const sha of Object.values(shas)) { + expect(sha).toMatch(/^[0-9a-f]{40}$/); + } + for (const [name, branch] of Object.entries(branches)) { + const manifest = JSON.parse(await remoteGit('show', [`${branch}:package.json`])); + expect(manifest.name).toBe(`@test/${name}`); + } + + const brokerManifest = JSON.parse(await remoteGit('show', [`${branches.broker}:package.json`])); + expect(brokerManifest.dependencies['@test/core']).toBe(`${packageManagerRemoteUrl}#${shas.core}`); + const adapterManifest = JSON.parse(await remoteGit('show', [`${branches.adapter}:package.json`])); + expect(adapterManifest.dependencies['@test/broker']).toBe(`${packageManagerRemoteUrl}#${shas.broker}`); + + const adapterSpecifier = `${packageManagerRemoteUrl}#${shas.adapter}`; + expect(gitPublishProcess.stdout).toContain(`i '${adapterSpecifier}'`); + + await using consumerFixture = await createFixture({ + 'package.json': JSON.stringify({ + name: 'test-consumer', + version: '1.0.0', + dependencies: { + '@test/adapter': adapterSpecifier, + }, + }), + }); + await spawn('pnpm', ['install', '--ignore-scripts'], { + cwd: consumerFixture.path, + env: { + PATH: process.env.PATH, + GIT_SSH_COMMAND: commandsFixture.getPath('upload-pack'), + PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS: 'false', + }, + }); + + const resolved = await spawn('node', ['-e', 'console.log(require("@test/adapter").broker.core.core)'], { + cwd: consumerFixture.path, + }); + expect(resolved.stdout).toBe('1'); + + const lockfile = await consumerFixture.readFile('pnpm-lock.yaml', 'utf8'); + expect(lockfile).not.toContain('registry.npmjs.org'); + for (const sha of Object.values(shas)) { + expect(lockfile).toContain(sha); + } + }); +}); From 621a6f7ae8c0261b7d792d7543bb7a889bafe92d Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sat, 5 Sep 2026 09:46:12 +0900 Subject: [PATCH 02/44] fix: preserve workspace publication errors --- README.md | 18 ++++- src/index.ts | 18 +++-- src/publish-repository/graph.ts | 14 +++- src/publish-repository/publish-closure.ts | 45 +++++++---- src/publish-repository/workspace.ts | 30 +++++++ tests/specs/workspace-discovery.ts | 13 ++- tests/specs/workspace-publication.ts | 97 ++++++++++++++++++++--- 7 files changed, 195 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 0c155ea..4ff1160 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ git-publish | Flag | Description | | ----------------------- | ------------------------------------------------------------- | -| `-b, --branch ` | Target branch name. Defaults to `npm/` | +| `-b, --branch ` | Target branch name. Workspace dependencies use this name as a prefix | | `-r, --remote ` | Git remote name or URL to push to (default: `origin`) | | `-o, --fresh` | Create a fresh single-commit branch. Force-pushes to remote | | `-d, --dry` | Simulate the process. Does not commit or push | @@ -126,10 +126,22 @@ Manual commits often: Yes. Run `git-publish` from inside the specific package directory (e.g., `packages/my-lib`). -It will detect and publish only that package's contents to the root of the Git branch. +`git-publish` publishes the selected package and its internal `workspace:` dependencies from `dependencies` and `optionalDependencies`. It creates each package commit before one atomic push, so a failed branch update does not expose a partial dependency closure. + +The selected package uses the requested `--branch` name. Dependencies use `-`. Without `--branch`, each package uses `npm/-`. + +Internal workspace peer dependencies are not published. `git-publish` prints a warning for each peer so consumers can provide it. > [!IMPORTANT] -> Currently does not support resolving `workspace:` protocol dependencies. Avoid using those or pre-bundle them before publishing. +> A recursive publication requires one push URL. Git cannot atomically push one dependency closure to multiple destinations. + +#### Installing with pnpm + +pnpm can block a Git dependency declared by another Git dependency with `blockExoticSubdeps`. A consumer that installs a published workspace closure must opt in: + +```sh +pnpm install --config.block-exotic-subdeps=false '' +``` ### Can I publish to and install from a private repository? diff --git a/src/index.ts b/src/index.ts index 1c97a27..9d0e5e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { preparePublishBranch } from './publish-repository/prepare-branch.ts'; import { getPublishRemote } from './publish-repository/remote.ts'; import { formatClosurePlan, + formatWorkspacePeerDiagnostics, planWorkspacePublication, publishWorkspaceClosure, type PackagePreparation, @@ -98,13 +99,17 @@ const { stringify } = JSON; } const workspaceDependencies: string[] = []; + const { + branch, remote, fresh, dry, + } = argv.flags; const closurePackageManager = await detectPackageManager(cwd, gitRootPath); const closurePlan = await planWorkspacePublication({ cwd, gitRootPath, sourceName, packageManager: closurePackageManager, - }).catch(() => undefined); + publishBranch: branch, + }); for (const [field, dependencies] of Object.entries({ dependencies: packageJson.dependencies, optionalDependencies: packageJson.optionalDependencies, @@ -126,10 +131,6 @@ ${workspaceDependencies.join('\n')} Pre-bundle these dependencies before publishing.`); } - const { - branch, remote, fresh, dry, - } = argv.flags; - const publishBranch = branch || ( gitSubdirectory ? `npm/${sourceName}-${packageJson.name}` @@ -144,9 +145,6 @@ Pre-bundle these dependencies before publishing.`); const remoteUrl = publishRemote.fetchUrl; if (closurePlan) { - if (branch) { - throw new Error('The --branch flag is not supported for workspace publication. Each package publishes to its own derived branch.'); - } if (publishRemote.pushUrls.length !== 1) { throw new Error(`Workspace publication requires exactly one push URL, but remote ${stringify(remote)} has ${publishRemote.pushUrls.length}.`); } @@ -154,6 +152,10 @@ Pre-bundle these dependencies before publishing.`); if (dry) { console.log(formatClosurePlan(closurePlan, sourceName)); } + const peerDiagnostics = formatWorkspacePeerDiagnostics(closurePlan); + if (peerDiagnostics) { + console.warn(peerDiagnostics); + } const packageCount = closurePlan.graph.nodes.length; await task( diff --git a/src/publish-repository/graph.ts b/src/publish-repository/graph.ts index 4606b5a..3156f21 100644 --- a/src/publish-repository/graph.ts +++ b/src/publish-repository/graph.ts @@ -102,10 +102,10 @@ export const selectWorkspacePackage = ( return selected; }; -export const resolvePackageDirectory = ( +export const findWorkspacePackageDirectory = ( workspace: Workspace, cwd: string, -): WorkspacePackage => { +): WorkspacePackage | undefined => { const directory = path.resolve(cwd); let selected: WorkspacePackage | undefined; let selectedLength = -1; @@ -119,8 +119,16 @@ export const resolvePackageDirectory = ( selectedLength = candidateDirectory.length; } } + return selected; +}; + +export const resolvePackageDirectory = ( + workspace: Workspace, + cwd: string, +): WorkspacePackage => { + const selected = findWorkspacePackageDirectory(workspace, cwd); if (!selected) { - throw new Error(`Current directory ${directory} is not inside a workspace package.`); + throw new Error(`Current directory ${path.resolve(cwd)} is not inside a workspace package.`); } return selected; }; diff --git a/src/publish-repository/publish-closure.ts b/src/publish-repository/publish-closure.ts index 3054a06..26145da 100644 --- a/src/publish-repository/publish-closure.ts +++ b/src/publish-repository/publish-closure.ts @@ -10,11 +10,11 @@ import { packPackage } from '../utils/pack-package.ts'; import { extractTarball, type File } from '../utils/extract-tarball.ts'; import { gitStatusTracked } from '../utils/git.ts'; import { - createPublishGraph, resolvePackageDirectory, type PublishGraph, type PublishGraphNode, + createPublishGraph, findWorkspacePackageDirectory, type PublishGraph, type PublishGraphNode, } from './graph.ts'; import { preparePublishBranch } from './prepare-branch.ts'; import { runDependencyGraph, type GraphNode } from './run-graph.ts'; -import { discoverWorkspacePackages, type Workspace } from './workspace.ts'; +import { findWorkspacePackages, type Workspace } from './workspace.ts'; import { createPublishRepository, type PublishRepository } from './create.ts'; import type { PublishRemote } from './remote.ts'; @@ -50,41 +50,42 @@ export const planWorkspacePublication = async ({ gitRootPath, sourceName, packageManager, + publishBranch, }: { cwd: string; gitRootPath: string; sourceName: string; packageManager: PackageManager; + publishBranch?: string; }): Promise => { - let workspace: Workspace; - try { - workspace = await discoverWorkspacePackages(cwd, packageManager); - } catch { - // Not a workspace context. The single-package flow applies, and it - // reports unreadable manifests through its own checks. + const workspace = await findWorkspacePackages(cwd, packageManager); + if (!workspace) { return undefined; } - let selected: string; - try { - selected = resolvePackageDirectory(workspace, cwd).name; - } catch { - // The working directory is not inside a workspace package (for - // example, the repository root). The single-package flow applies. + const selectedPackage = findWorkspacePackageDirectory(workspace, cwd); + if (!selectedPackage) { return undefined; } + const selected = selectedPackage.name; const graph = createPublishGraph(workspace, selected); const branches = new Map(); + const branchesByName = new Set(); + const selectedBranch = publishBranch ?? `npm/${sourceName}-${selected}`; for (const node of graph.nodes) { const relative = path.relative(gitRootPath, node.package.dir); if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { throw new Error(`Workspace package ${JSON.stringify(node.key)} is outside the Git repository and cannot be published.`); } - const branch = `npm/${sourceName}-${node.key}`; + const branch = node.key === selected ? selectedBranch : `${selectedBranch}-${node.key}`; + if (branchesByName.has(branch)) { + throw new Error(`Publish branch ${JSON.stringify(branch)} is assigned to more than one workspace package.`); + } try { await getStdout(spawn('git', ['check-ref-format', '--branch', branch])); } catch { throw new Error(`Invalid publish branch ${JSON.stringify(branch)}.`); } + branchesByName.add(branch); branches.set(node.key, branch); } return { @@ -369,3 +370,17 @@ export const formatClosurePlan = (plan: ClosurePlan, sourceName: string): string } return lines.join('\n'); }; + +export const formatWorkspacePeerDiagnostics = (plan: ClosurePlan): string | undefined => { + if (plan.graph.peers.length === 0) { + return undefined; + } + const lines = ['Internal workspace peer dependencies are not published. Consumers must provide them:']; + for (const peer of plan.graph.peers) { + const target = peer.target + ? ` resolves to ${JSON.stringify(peer.target)}` + : ' does not resolve to a workspace package'; + lines.push(`- ${JSON.stringify(peer.from)} declares ${JSON.stringify(peer.key)}: ${JSON.stringify(peer.specification)}${target}.`); + } + return lines.join('\n'); +}; diff --git a/src/publish-repository/workspace.ts b/src/publish-repository/workspace.ts index aabc236..8dca830 100644 --- a/src/publish-repository/workspace.ts +++ b/src/publish-repository/workspace.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { getPackages, type Package } from '@manypkg/get-packages'; import { BunTool, NpmTool, PnpmTool, YarnTool, type Tool, @@ -29,6 +30,24 @@ const workspaceTools: Record = { bun: BunTool, }; +const findWorkspaceRoot = async ( + directory: string, + packageManager: PackageManager, +): Promise => { + const tool = workspaceTools[packageManager]; + let candidate = path.resolve(directory); + while (true) { + if (await tool.isMonorepoRoot(candidate)) { + return candidate; + } + const parent = path.dirname(candidate); + if (parent === candidate) { + return undefined; + } + candidate = parent; + } +}; + export const discoverWorkspacePackages = async ( directory: string, packageManager: PackageManager, @@ -50,3 +69,14 @@ export const discoverWorkspacePackages = async ( })), }; }; + +export const findWorkspacePackages = async ( + directory: string, + packageManager: PackageManager, +): Promise => { + const rootDirectory = await findWorkspaceRoot(directory, packageManager); + if (!rootDirectory) { + return undefined; + } + return discoverWorkspacePackages(rootDirectory, packageManager); +}; diff --git a/tests/specs/workspace-discovery.ts b/tests/specs/workspace-discovery.ts index 21f8c15..e6618c2 100644 --- a/tests/specs/workspace-discovery.ts +++ b/tests/specs/workspace-discovery.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import { describe, test, expect } from 'manten'; import { createFixture } from 'fs-fixture'; -import { discoverWorkspacePackages } from '../../src/publish-repository/workspace.ts'; +import { discoverWorkspacePackages, findWorkspacePackages } from '../../src/publish-repository/workspace.ts'; describe('Workspace discovery', () => { test('discovers npm workspace packages', async () => { @@ -181,4 +181,15 @@ describe('Workspace discovery', () => { } expect(message).toContain('No npm workspace found'); }); + + test('returns undefined for a package outside a workspace', async () => { + await using fixture = await createFixture({ + 'package.json': JSON.stringify({ + name: 'test-pkg', + version: '1.0.0', + }), + }); + + expect(await findWorkspacePackages(fixture.path, 'npm')).toBeUndefined(); + }); }); diff --git a/tests/specs/workspace-publication.ts b/tests/specs/workspace-publication.ts index 1bcab9e..744cd53 100644 --- a/tests/specs/workspace-publication.ts +++ b/tests/specs/workspace-publication.ts @@ -13,7 +13,17 @@ describe('Workspace publication', async () => { onFinish(() => remoteFixture.rm()); const { git: remoteGit } = remoteFixture; - const createChainWorkspace = async (branchName: string, remote: string) => { + const createChainWorkspace = async ( + branchName: string, + remote: string, + { + adapterSpecification = 'workspace:*', + peerSpecification, + }: { + adapterSpecification?: string; + peerSpecification?: string; + } = {}, + ) => { const fixture = await createGitFixture({ 'package.json': JSON.stringify({ name: 'test-monorepo', @@ -44,8 +54,15 @@ describe('Workspace publication', async () => { name: '@test/adapter', version: '0.0.0', dependencies: { - '@test/broker': 'workspace:*', + '@test/broker': adapterSpecification, }, + ...(peerSpecification + ? { + peerDependencies: { + '@test/core': peerSpecification, + }, + } + : {}), }, null, 2), 'index.js': 'module.exports = { broker: require("@test/broker") };', }, @@ -58,15 +75,74 @@ describe('Workspace publication', async () => { return fixture; }; - test('rejects --branch for workspace publication', async () => { - const branchName = 'test-workspace-branch-flag'; - await using fixture = await createChainWorkspace(branchName, remoteFixture.path); + test('uses --branch for an independent workspace package', async () => { + await using branchRemoteFixture = await createGitFixture(undefined, ['--bare']); + const { git: branchRemoteGit } = branchRemoteFixture; + await using fixture = await createGitFixture({ + 'package.json': JSON.stringify({ + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + }, null, 2), + 'package-lock.json': '{}', + packages: { + adapter: { + 'package.json': JSON.stringify({ + name: '@test/adapter', + version: '0.0.0', + }, null, 2), + 'index.js': 'module.exports = 1;', + }, + }, + }, ['--initial-branch=test-workspace-branch-flag']); + const { git } = fixture; + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', branchRemoteFixture.path]); + + const gitPublishProcess = await gitPublish(path.join(fixture.path, 'packages/adapter'), ['--branch', 'custom']); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(await branchRemoteGit('show', ['custom:package.json'])).toContain('@test/adapter'); + }); + + test('derives dependency branches from --branch', async () => { + await using branchRemoteFixture = await createGitFixture(undefined, ['--bare']); + const { git: branchRemoteGit } = branchRemoteFixture; + await using fixture = await createChainWorkspace('test-workspace-derived-branches', branchRemoteFixture.path); const gitPublishProcess = await gitPublish(path.join(fixture.path, 'packages/adapter'), ['--branch', 'custom']); + expect('exitCode' in gitPublishProcess).toBe(false); + for (const branch of ['custom', 'custom-@test/broker', 'custom-@test/core']) { + expect(await branchRemoteGit('rev-parse', [branch])).toMatch(/^[0-9a-f]{40}$/); + } + }); + + test('reports workspace dependency planning errors', async () => { + const branchName = 'test-workspace-invalid-specification'; + await using fixture = await createChainWorkspace(branchName, remoteFixture.path, { + adapterSpecification: 'workspace:', + }); + + const gitPublishProcess = await gitPublish(path.join(fixture.path, 'packages/adapter')); + expect(('exitCode' in gitPublishProcess) && gitPublishProcess.exitCode).toBe(1); - expect(gitPublishProcess.stderr).toBe('Error: The --branch flag is not supported for workspace publication. Each package publishes to its own derived branch.'); - expect(await remoteGit('for-each-ref')).toBe(''); + expect(gitPublishProcess.stderr).toContain('Unsupported workspace specification "workspace:"'); + expect(gitPublishProcess.stderr).not.toContain('Pre-bundle these dependencies'); + }); + + test('warns when workspace peers are excluded from publication', async () => { + const branchName = 'test-workspace-peer-diagnostic'; + await using fixture = await createChainWorkspace(branchName, remoteFixture.path, { + peerSpecification: 'workspace:*', + }); + + const gitPublishProcess = await gitPublish(path.join(fixture.path, 'packages/adapter')); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(gitPublishProcess.stderr).toContain('Internal workspace peer dependencies are not published'); + expect(gitPublishProcess.stderr).toContain('"@test/adapter" declares "@test/core": "workspace:*" resolves to "@test/core"'); }); test('rejects multiple push URLs', async () => { @@ -107,7 +183,7 @@ describe('Workspace publication', async () => { expect(await rejectedRemoteGit('for-each-ref')).toBe(''); }); - test('publishes the closure and installs the selected package', async () => { + test('publishes the closure and installs the selected package when pnpm allows Git subdependencies', async () => { const branchName = 'test-workspace-acceptance'; const remoteUrl = `git@example.test:${remoteFixture.path}`; const packageManagerRemoteUrl = `git+ssh://git@example.test/${remoteFixture.path}`; @@ -135,8 +211,8 @@ exec sh -c "$*" expect('exitCode' in gitPublishProcess).toBe(false); const branches = { - core: `npm/${branchName}-@test/core`, - broker: `npm/${branchName}-@test/broker`, + core: `npm/${branchName}-@test/adapter-@test/core`, + broker: `npm/${branchName}-@test/adapter-@test/broker`, adapter: `npm/${branchName}-@test/adapter`, }; const shas = { @@ -174,6 +250,7 @@ exec sh -c "$*" env: { PATH: process.env.PATH, GIT_SSH_COMMAND: commandsFixture.getPath('upload-pack'), + // pnpm blocks Git dependencies of Git dependencies unless the consumer opts in. PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS: 'false', }, }); From ce1a7b719e1f5ce6e93c35a5311904be834210b8 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sat, 5 Sep 2026 11:18:25 +0900 Subject: [PATCH 03/44] fix: scope workspace discovery to Git root --- src/publish-repository/publish-closure.ts | 2 +- src/publish-repository/workspace.ts | 8 ++++- tests/specs/workspace-publication.ts | 42 ++++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/publish-repository/publish-closure.ts b/src/publish-repository/publish-closure.ts index 26145da..df2f0e7 100644 --- a/src/publish-repository/publish-closure.ts +++ b/src/publish-repository/publish-closure.ts @@ -58,7 +58,7 @@ export const planWorkspacePublication = async ({ packageManager: PackageManager; publishBranch?: string; }): Promise => { - const workspace = await findWorkspacePackages(cwd, packageManager); + const workspace = await findWorkspacePackages(cwd, packageManager, gitRootPath); if (!workspace) { return undefined; } diff --git a/src/publish-repository/workspace.ts b/src/publish-repository/workspace.ts index 8dca830..c0d3309 100644 --- a/src/publish-repository/workspace.ts +++ b/src/publish-repository/workspace.ts @@ -33,13 +33,18 @@ const workspaceTools: Record = { const findWorkspaceRoot = async ( directory: string, packageManager: PackageManager, + boundaryDirectory: string, ): Promise => { const tool = workspaceTools[packageManager]; + const boundary = path.resolve(boundaryDirectory); let candidate = path.resolve(directory); while (true) { if (await tool.isMonorepoRoot(candidate)) { return candidate; } + if (candidate === boundary) { + return undefined; + } const parent = path.dirname(candidate); if (parent === candidate) { return undefined; @@ -73,8 +78,9 @@ export const discoverWorkspacePackages = async ( export const findWorkspacePackages = async ( directory: string, packageManager: PackageManager, + boundaryDirectory = directory, ): Promise => { - const rootDirectory = await findWorkspaceRoot(directory, packageManager); + const rootDirectory = await findWorkspaceRoot(directory, packageManager, boundaryDirectory); if (!rootDirectory) { return undefined; } diff --git a/tests/specs/workspace-publication.ts b/tests/specs/workspace-publication.ts index 744cd53..d36a450 100644 --- a/tests/specs/workspace-publication.ts +++ b/tests/specs/workspace-publication.ts @@ -5,7 +5,7 @@ import { } from 'manten'; import { createFixture } from 'fs-fixture'; import spawn from 'nano-spawn'; -import { createGitFixture } from '../utils/create-git.ts'; +import { createGit, createGitFixture } from '../utils/create-git.ts'; import { gitPublish } from '../utils/git-publish.ts'; describe('Workspace publication', async () => { @@ -106,6 +106,46 @@ describe('Workspace publication', async () => { expect(await branchRemoteGit('show', ['custom:package.json'])).toContain('@test/adapter'); }); + test('ignores an outer workspace beyond the Git root', async () => { + await using nestedRemoteFixture = await createGitFixture(undefined, ['--bare']); + const { git: nestedRemoteGit } = nestedRemoteFixture; + await using outerFixture = await createFixture({ + 'package.json': JSON.stringify({ + name: 'outer-workspace', + private: true, + workspaces: ['packages/*'], + }, null, 2), + 'package-lock.json': '{}', + packages: { + outer: { + 'package.json': JSON.stringify({ + name: '@test/outer', + version: '0.0.0', + }, null, 2), + }, + inner: { + 'package.json': JSON.stringify({ + name: '@test/inner', + version: '0.0.0', + }, null, 2), + 'index.js': 'module.exports = 1;', + }, + }, + }); + const repositoryPath = outerFixture.getPath('packages/inner'); + const git = createGit(repositoryPath); + await git.init(['--initial-branch=inner']); + await git('add', ['.']); + await git('commit', ['-m', 'Initial commit']); + await git('remote', ['add', 'origin', nestedRemoteFixture.path]); + + const gitPublishProcess = await gitPublish(repositoryPath); + + expect('exitCode' in gitPublishProcess).toBe(false); + expect(gitPublishProcess.stdout).toContain('Publishing source "inner" → "npm/inner"'); + expect(await nestedRemoteGit('show', ['npm/inner:package.json'])).toContain('@test/inner'); + }); + test('derives dependency branches from --branch', async () => { await using branchRemoteFixture = await createGitFixture(undefined, ['--bare']); const { git: branchRemoteGit } = branchRemoteFixture; From 9440e1fbcf3250615b9f151d73038c6f4eda9e76 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sat, 5 Sep 2026 11:43:06 +0900 Subject: [PATCH 04/44] refactor: separate workspace package publication --- src/index.ts | 50 ++- src/package-publication/prepare.ts | 136 ++++++ src/package-publication/push.ts | 64 +++ src/publish-repository/publish-closure.ts | 386 ------------------ .../discover.ts} | 0 .../graph.ts | 2 +- src/workspace-publication/plan.ts | 64 +++ src/workspace-publication/publish.ts | 161 ++++++++ .../run-graph.ts | 0 tests/index.ts | 1 + tests/specs/package-publication.ts | 102 +++++ tests/specs/publish-graph.ts | 4 +- tests/specs/run-graph.ts | 2 +- tests/specs/workspace-discovery.ts | 2 +- 14 files changed, 571 insertions(+), 403 deletions(-) create mode 100644 src/package-publication/prepare.ts create mode 100644 src/package-publication/push.ts delete mode 100644 src/publish-repository/publish-closure.ts rename src/{publish-repository/workspace.ts => workspace-publication/discover.ts} (100%) rename src/{publish-repository => workspace-publication}/graph.ts (99%) create mode 100644 src/workspace-publication/plan.ts create mode 100644 src/workspace-publication/publish.ts rename src/{publish-repository => workspace-publication}/run-graph.ts (100%) create mode 100644 tests/specs/package-publication.ts diff --git a/src/index.ts b/src/index.ts index 9d0e5e3..5f2163d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,16 +21,40 @@ import { getGitHubRepositoryName } from './utils/github.ts'; import { createPublishRepository, type PublishRepository } from './publish-repository/create.ts'; import { preparePublishBranch } from './publish-repository/prepare-branch.ts'; import { getPublishRemote } from './publish-repository/remote.ts'; -import { - formatClosurePlan, - formatWorkspacePeerDiagnostics, - planWorkspacePublication, - publishWorkspaceClosure, - type PackagePreparation, -} from './publish-repository/publish-closure.ts'; +import type { PackagePreparation } from './package-publication/prepare.ts'; +import { assertAtomicPackagePublicationDestination } from './package-publication/push.ts'; +import { planWorkspacePublication, type WorkspacePublicationPlan } from './workspace-publication/plan.ts'; +import { publishWorkspaceClosure } from './workspace-publication/publish.ts'; const { stringify } = JSON; +const formatWorkspacePublicationPlan = ( + plan: WorkspacePublicationPlan, + sourceName: string, +): string => { + const lines = [`Publishing workspace closure from ${JSON.stringify(sourceName)}:`]; + for (const node of plan.graph.nodes) { + const branch = plan.branches.get(node.key)!; + const rewrites = node.dependencies.map(edge => `${edge.key} → ${plan.branches.get(edge.target)!}`).join(', '); + lines.push(`- ${node.key} → ${branch}${rewrites ? ` (dependencies: ${rewrites})` : ''}`); + } + return lines.join('\n'); +}; + +const formatWorkspacePeerDiagnostics = (plan: WorkspacePublicationPlan): string | undefined => { + if (plan.graph.peers.length === 0) { + return undefined; + } + const lines = ['Internal workspace peer dependencies are not published. Consumers must provide them:']; + for (const peer of plan.graph.peers) { + const target = peer.target + ? ` resolves to ${JSON.stringify(peer.target)}` + : ' does not resolve to a workspace package'; + lines.push(`- ${JSON.stringify(peer.from)} declares ${JSON.stringify(peer.key)}: ${JSON.stringify(peer.specification)}${target}.`); + } + return lines.join('\n'); +}; + (async () => { let usedDefaultRemote = false; const argv = cli({ @@ -145,12 +169,10 @@ Pre-bundle these dependencies before publishing.`); const remoteUrl = publishRemote.fetchUrl; if (closurePlan) { - if (publishRemote.pushUrls.length !== 1) { - throw new Error(`Workspace publication requires exactly one push URL, but remote ${stringify(remote)} has ${publishRemote.pushUrls.length}.`); - } + assertAtomicPackagePublicationDestination(publishRemote.pushUrls); if (dry) { - console.log(formatClosurePlan(closurePlan, sourceName)); + console.log(formatWorkspacePublicationPlan(closurePlan, sourceName)); } const peerDiagnostics = formatWorkspacePeerDiagnostics(closurePlan); if (peerDiagnostics) { @@ -170,7 +192,7 @@ Pre-bundle these dependencies before publishing.`); try { if (!dry) { - preparations = await publishWorkspaceClosure({ + const result = await publishWorkspaceClosure({ plan: closurePlan, packageManager: closurePackageManager, sourceRepositoryPath: gitRootPath, @@ -180,6 +202,7 @@ Pre-bundle these dependencies before publishing.`); sourceCommit: sourceCommit ?? undefined, fresh, }); + preparations = closurePlan.graph.nodes.map(node => result.preparations.get(node.key)!); success = true; } } catch (error) { @@ -193,6 +216,9 @@ Pre-bundle these dependencies before publishing.`); } for (const preparation of preparations) { + if (preparation.reusedExistingCommit) { + console.warn(`⚠️ No new changes found for ${preparation.publication.packageName}, keeping the existing publish branch.`); + } console.log(lightBlue(`Publishing ${preparation.publication.packageName}`)); console.log(preparation.files.map(({ file, size }) => `${file} ${dim(byteSize(size).toString())}`).join('\n')); console.log(`\n${lightBlue('Total size')}`, byteSize(preparation.files.reduce((total, { size }) => total + size, 0)).toString()); diff --git a/src/package-publication/prepare.ts b/src/package-publication/prepare.ts new file mode 100644 index 0000000..ca5da61 --- /dev/null +++ b/src/package-publication/prepare.ts @@ -0,0 +1,136 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import spawn, { type Options as SpawnOptions } from 'nano-spawn'; +import type { PackageJson } from '@npmcli/package-json'; +import { extractTarball, type File } from '../utils/extract-tarball.ts'; +import { getStdout } from '../utils/get-stdout.ts'; +import { gitStatusTracked } from '../utils/git.ts'; +import { readJson } from '../utils/read-json.ts'; + +const { stringify } = JSON; + +export type PackagePublication = { + packageName: string; + branch: string; + commit: string; + installSpecifier: string; + refspec: string; +}; + +export type PackagePreparation = { + publication: PackagePublication; + files: File[]; + reusedExistingCommit: boolean; +}; + +export type PackagePublicationDependency = { + key: string; + field: 'dependencies' | 'optionalDependencies'; + target: string; +}; + +const toPackageManagerGitUrl = (url: string) => { + if (url.startsWith('git+')) { + return url; + } + if (/^(?:file|git|https?|ssh):\/\//.test(url)) { + return `git+${url}`; + } + const scpUrl = /^(?[^@/:]+@)?(?[^/:]+):(?.+)$/.exec(url)?.groups; + if (scpUrl) { + return `git+ssh://${scpUrl.user ?? ''}${scpUrl.host}/${scpUrl.path}`; + } + return `git+file://${path.resolve(url)}`; +}; + +export const toInstallSpecifier = (fetchUrl: string, commit: string) => `${toPackageManagerGitUrl(fetchUrl)}#${commit}`; + +export const preparePackagePublication = async ({ + packageName, + packedTarball, + publishWorktree, + branch, + fetchUrl, + sourceName, + sourceCommit, + dependencyEdges, + dependencyPublications, + gitOptions, +}: { + packageName: string; + packedTarball: string; + publishWorktree: string; + branch: string; + fetchUrl: string; + sourceName: string; + sourceCommit: string | undefined; + dependencyEdges: PackagePublicationDependency[]; + dependencyPublications: ReadonlyMap; + gitOptions: SpawnOptions; +}): Promise => { + const worktreeOptions = { + cwd: publishWorktree, + env: gitOptions.env, + }; + const files = await extractTarball(packedTarball, publishWorktree); + const manifestPath = path.join(publishWorktree, 'package.json'); + const manifest = await readJson(manifestPath) as PackageJson; + const original = stringify(manifest); + for (const edge of dependencyEdges) { + const dependency = dependencyPublications.get(edge.target)!; + const field = manifest[edge.field] ?? {}; + field[edge.key] = dependency.installSpecifier; + manifest[edge.field] = field; + } + const { scripts } = manifest; + if (scripts && ('prepare' in scripts || 'prepack' in scripts)) { + delete scripts.prepare; + delete scripts.prepack; + } + if (stringify(manifest) !== original) { + await fs.writeFile(manifestPath, stringify(manifest, null, 2)); + } + await spawn('git', ['add', '--all'], worktreeOptions); + const tracked = await gitStatusTracked(worktreeOptions); + if (tracked.length === 0) { + const commit = await getStdout(spawn('git', ['rev-parse', 'HEAD'], worktreeOptions)); + return { + publication: { + packageName, + branch, + commit, + installSpecifier: toInstallSpecifier(fetchUrl, commit), + refspec: `${commit}:refs/heads/${branch}`, + }, + files, + reusedExistingCommit: true, + }; + } + let commitMessage = `Published ${JSON.stringify(packageName)} from ${JSON.stringify(sourceName)}`; + if (sourceCommit) { + commitMessage += ` (${sourceCommit})`; + } + await spawn('git', [ + '-c', + 'user.name=git-publish', + '-c', + 'user.email=bot@git-publish', + 'commit', + '--no-verify', + '-m', + commitMessage, + '--author=git-publish ', + ], worktreeOptions); + const commit = await getStdout(spawn('git', ['rev-parse', 'HEAD'], worktreeOptions)); + return { + publication: { + packageName, + branch, + commit, + installSpecifier: toInstallSpecifier(fetchUrl, commit), + refspec: `${commit}:refs/heads/${branch}`, + }, + files, + reusedExistingCommit: false, + }; +}; diff --git a/src/package-publication/push.ts b/src/package-publication/push.ts new file mode 100644 index 0000000..1327d59 --- /dev/null +++ b/src/package-publication/push.ts @@ -0,0 +1,64 @@ +import spawn from 'nano-spawn'; +import type { PublishRepository } from '../publish-repository/create.ts'; +import { getStdout } from '../utils/get-stdout.ts'; +import type { PackagePreparation } from './prepare.ts'; + +export type PackagePublicationPushPlan = { + fresh: boolean; + remoteTips: ReadonlyMap; +}; + +export const assertAtomicPackagePublicationDestination = (pushUrls: string[]) => { + if (pushUrls.length !== 1) { + throw new Error(`Workspace publication requires exactly one push URL, but the selected remote has ${pushUrls.length}.`); + } +}; + +const readRemoteTips = async (repository: PublishRepository): Promise> => { + const output = await getStdout(spawn('git', ['ls-remote', repository.fetchRemoteName], repository.gitOptions)); + const tips = new Map(); + for (const line of output.split('\n')) { + const separator = line.indexOf('\t'); + if (separator === -1) { + continue; + } + const sha = line.slice(0, separator); + const ref = line.slice(separator + 1); + if (ref.startsWith('refs/heads/')) { + tips.set(ref.slice('refs/heads/'.length), sha); + } + } + return tips; +}; + +export const planPackagePublicationPush = async ( + repository: PublishRepository, + fresh: boolean | undefined, +): Promise => { + assertAtomicPackagePublicationDestination(repository.pushRemoteNames); + return { + fresh: Boolean(fresh), + remoteTips: fresh ? await readRemoteTips(repository) : new Map(), + }; +}; + +export const pushPackagePublications = async ({ + repository, + preparations, + pushPlan, +}: { + repository: PublishRepository; + preparations: Iterable; + pushPlan: PackagePublicationPushPlan; +}): Promise => { + const [pushRemoteName] = repository.pushRemoteNames; + const publications = [...preparations]; + const args = ['push', '--atomic']; + if (pushPlan.fresh) { + for (const { publication } of publications) { + args.push(`--force-with-lease=refs/heads/${publication.branch}:${pushPlan.remoteTips.get(publication.branch) ?? ''}`); + } + } + args.push('--no-verify', pushRemoteName!, ...publications.map(({ publication }) => publication.refspec)); + await spawn('git', args, repository.gitOptions); +}; diff --git a/src/publish-repository/publish-closure.ts b/src/publish-repository/publish-closure.ts deleted file mode 100644 index df2f0e7..0000000 --- a/src/publish-repository/publish-closure.ts +++ /dev/null @@ -1,386 +0,0 @@ -import path from 'node:path'; -import fs from 'node:fs/promises'; -import { randomBytes } from 'node:crypto'; -import spawn from 'nano-spawn'; -import type { PackageJson } from '@npmcli/package-json'; -import { getStdout } from '../utils/get-stdout.ts'; -import type { PackageManager } from '../utils/detect-package-manager.ts'; -import { readJson } from '../utils/read-json.ts'; -import { packPackage } from '../utils/pack-package.ts'; -import { extractTarball, type File } from '../utils/extract-tarball.ts'; -import { gitStatusTracked } from '../utils/git.ts'; -import { - createPublishGraph, findWorkspacePackageDirectory, type PublishGraph, type PublishGraphNode, -} from './graph.ts'; -import { preparePublishBranch } from './prepare-branch.ts'; -import { runDependencyGraph, type GraphNode } from './run-graph.ts'; -import { findWorkspacePackages, type Workspace } from './workspace.ts'; -import { createPublishRepository, type PublishRepository } from './create.ts'; -import type { PublishRemote } from './remote.ts'; - -const { stringify } = JSON; - -export type ClosurePlan = { - workspace: Workspace; - graph: PublishGraph; - branches: Map; -}; - -export type PackagePublication = { - packageName: string; - branch: string; - commit: string; - installSpecifier: string; - refspec: string; -}; - -export type PackagePreparation = { - publication: PackagePublication; - files: File[]; -}; - -type ClosureTask = { - node: PublishGraphNode; - tarball: string; - worktree: string; -}; - -export const planWorkspacePublication = async ({ - cwd, - gitRootPath, - sourceName, - packageManager, - publishBranch, -}: { - cwd: string; - gitRootPath: string; - sourceName: string; - packageManager: PackageManager; - publishBranch?: string; -}): Promise => { - const workspace = await findWorkspacePackages(cwd, packageManager, gitRootPath); - if (!workspace) { - return undefined; - } - const selectedPackage = findWorkspacePackageDirectory(workspace, cwd); - if (!selectedPackage) { - return undefined; - } - const selected = selectedPackage.name; - const graph = createPublishGraph(workspace, selected); - const branches = new Map(); - const branchesByName = new Set(); - const selectedBranch = publishBranch ?? `npm/${sourceName}-${selected}`; - for (const node of graph.nodes) { - const relative = path.relative(gitRootPath, node.package.dir); - if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new Error(`Workspace package ${JSON.stringify(node.key)} is outside the Git repository and cannot be published.`); - } - const branch = node.key === selected ? selectedBranch : `${selectedBranch}-${node.key}`; - if (branchesByName.has(branch)) { - throw new Error(`Publish branch ${JSON.stringify(branch)} is assigned to more than one workspace package.`); - } - try { - await getStdout(spawn('git', ['check-ref-format', '--branch', branch])); - } catch { - throw new Error(`Invalid publish branch ${JSON.stringify(branch)}.`); - } - branchesByName.add(branch); - branches.set(node.key, branch); - } - return { - workspace, - graph, - branches, - }; -}; - -export const packClosurePackages = async ({ - plan, - packageManager, - repository, - gitRootPath, -}: { - plan: ClosurePlan; - packageManager: PackageManager; - repository: PublishRepository; - gitRootPath: string; -}): Promise> => { - const tarballs = new Map(); - for (const [index, node] of plan.graph.nodes.entries()) { - const tarball = await packPackage( - packageManager, - repository.packWorktreePath, - path.join(repository.packTemporaryDirectory, String(index)), - node.package.dir, - gitRootPath, - path.relative(gitRootPath, node.package.dir), - ); - tarballs.set(node.key, tarball); - } - return tarballs; -}; - -const toPackageManagerGitUrl = (url: string) => { - if (url.startsWith('git+')) { - return url; - } - if (/^(?:file|git|https?|ssh):\/\//.test(url)) { - return `git+${url}`; - } - const scpUrl = /^(?[^@/:]+@)?(?[^/:]+):(?.+)$/.exec(url)?.groups; - if (scpUrl) { - return `git+ssh://${scpUrl.user ?? ''}${scpUrl.host}/${scpUrl.path}`; - } - return `git+file://${path.resolve(url)}`; -}; - -export const toInstallSpecifier = (fetchUrl: string, commit: string) => `${toPackageManagerGitUrl(fetchUrl)}#${commit}`; - -export const prepareClosureBranches = async ({ - plan, - repository, - fresh, -}: { - plan: ClosurePlan; - repository: PublishRepository; - fresh: boolean | undefined; -}): Promise> => { - const worktrees = new Map(); - plan.graph.nodes.forEach((node, index) => { - worktrees.set(node.key, path.join(repository.temporaryDirectory, `publish-worktree-${index}`)); - }); - for (const node of plan.graph.nodes) { - await preparePublishBranch({ - repository, - publishBranch: plan.branches.get(node.key)!, - localBranch: `git-publish-${randomBytes(16).toString('hex')}`, - fresh, - worktreePath: worktrees.get(node.key)!, - }); - } - return worktrees; -}; - -export const commitClosureSnapshots = async ({ - plan, - repository, - tarballs, - worktrees, - sourceName, - sourceCommit, - fetchUrl, -}: { - plan: ClosurePlan; - repository: PublishRepository; - tarballs: Map; - worktrees: Map; - sourceName: string; - sourceCommit: string | undefined; - fetchUrl: string; -}): Promise => { - const adapted: GraphNode[] = plan.graph.nodes.map(node => ({ - key: node.key, - value: { - node, - tarball: tarballs.get(node.key)!, - worktree: worktrees.get(node.key)!, - } satisfies ClosureTask, - dependencies: node.dependencies.map(edge => edge.target), - })); - const results = await runDependencyGraph(adapted, async ( - { key, value }, - dependencyResults, - ): Promise => { - const worktreeOptions = { - cwd: value.worktree, - env: repository.gitOptions.env, - }; - const files = await extractTarball(value.tarball, value.worktree); - const manifestPath = path.join(value.worktree, 'package.json'); - const manifest = await readJson(manifestPath) as PackageJson; - const original = stringify(manifest); - for (const edge of value.node.dependencies) { - const dependency = dependencyResults.get(edge.target)!; - const field = manifest[edge.field] ?? {}; - field[edge.key] = dependency.publication.installSpecifier; - manifest[edge.field] = field; - } - const { scripts } = manifest; - if (scripts && ('prepare' in scripts || 'prepack' in scripts)) { - delete scripts.prepare; - delete scripts.prepack; - } - if (stringify(manifest) !== original) { - await fs.writeFile(manifestPath, stringify(manifest, null, 2)); - } - await spawn('git', ['add', '--all'], worktreeOptions); - const tracked = await gitStatusTracked(worktreeOptions); - let commit: string; - if (tracked.length === 0) { - console.warn(`⚠️ No new changes found for ${key}, keeping the existing publish branch.`); - commit = await getStdout(spawn('git', ['rev-parse', 'HEAD'], worktreeOptions)); - } else { - let commitMessage = `Published ${JSON.stringify(key)} from ${JSON.stringify(sourceName)}`; - if (sourceCommit) { - commitMessage += ` (${sourceCommit})`; - } - await spawn('git', [ - '-c', - 'user.name=git-publish', - '-c', - 'user.email=bot@git-publish', - 'commit', - '--no-verify', - '-m', - commitMessage, - '--author=git-publish ', - ], worktreeOptions); - commit = await getStdout(spawn('git', ['rev-parse', 'HEAD'], worktreeOptions)); - } - const branch = plan.branches.get(key)!; - const installSpecifier = toInstallSpecifier(fetchUrl, commit); - return { - publication: { - packageName: key, - branch, - commit, - installSpecifier, - refspec: `${commit}:refs/heads/${branch}`, - }, - files, - }; - }); - return plan.graph.nodes.map(node => results.get(node.key)!); -}; - -export const readRemoteTips = async ( - repository: PublishRepository, -): Promise> => { - const output = await getStdout(spawn('git', ['ls-remote', repository.fetchRemoteName], repository.gitOptions)); - const tips = new Map(); - for (const line of output.split('\n')) { - const separator = line.indexOf('\t'); - if (separator === -1) { - continue; - } - const sha = line.slice(0, separator); - const ref = line.slice(separator + 1); - if (ref.startsWith('refs/heads/')) { - tips.set(ref.slice('refs/heads/'.length), sha); - } - } - return tips; -}; - -export const pushClosureReferences = async ({ - repository, - preparations, - fresh, - remoteTips, -}: { - repository: PublishRepository; - preparations: PackagePreparation[]; - fresh: boolean | undefined; - remoteTips?: Map; -}): Promise => { - const [pushRemoteName] = repository.pushRemoteNames; - const args = ['push', '--atomic']; - if (fresh) { - for (const { publication } of preparations) { - args.push(`--force-with-lease=refs/heads/${publication.branch}:${remoteTips?.get(publication.branch) ?? ''}`); - } - } - args.push('--no-verify', pushRemoteName!, ...preparations.map(preparation => preparation.publication.refspec)); - await spawn('git', args, repository.gitOptions); -}; - -export const publishWorkspaceClosure = async ({ - plan, - packageManager, - sourceRepositoryPath, - gitRootPath, - publishRemote, - sourceName, - sourceCommit, - fresh, -}: { - plan: ClosurePlan; - packageManager: PackageManager; - sourceRepositoryPath: string; - gitRootPath: string; - publishRemote: PublishRemote; - sourceName: string; - sourceCommit: string | undefined; - fresh: boolean | undefined; -}): Promise => { - const repository = await createPublishRepository({ - sourceRepositoryPath, - publishRemote, - }); - let primaryError: unknown; - try { - const remoteTips = fresh ? await readRemoteTips(repository) : undefined; - const worktrees = await prepareClosureBranches({ - plan, - repository, - fresh, - }); - const tarballs = await packClosurePackages({ - plan, - packageManager, - repository, - gitRootPath, - }); - const preparations = await commitClosureSnapshots({ - plan, - repository, - tarballs, - worktrees, - sourceName, - sourceCommit, - fetchUrl: publishRemote.fetchUrl, - }); - await pushClosureReferences({ - repository, - preparations, - fresh, - remoteTips, - }); - return preparations; - } catch (error) { - primaryError = error; - throw error; - } finally { - await repository.dispose().catch((cleanupError: unknown) => { - if (primaryError) { - throw new AggregateError([primaryError, cleanupError], 'Failed to publish workspace closure.'); - } - throw cleanupError; - }); - } -}; - -export const formatClosurePlan = (plan: ClosurePlan, sourceName: string): string => { - const lines = [`Publishing workspace closure from ${JSON.stringify(sourceName)}:`]; - for (const node of plan.graph.nodes) { - const branch = plan.branches.get(node.key)!; - const rewrites = node.dependencies.map(edge => `${edge.key} → ${plan.branches.get(edge.target)!}`).join(', '); - lines.push(`- ${node.key} → ${branch}${rewrites ? ` (dependencies: ${rewrites})` : ''}`); - } - return lines.join('\n'); -}; - -export const formatWorkspacePeerDiagnostics = (plan: ClosurePlan): string | undefined => { - if (plan.graph.peers.length === 0) { - return undefined; - } - const lines = ['Internal workspace peer dependencies are not published. Consumers must provide them:']; - for (const peer of plan.graph.peers) { - const target = peer.target - ? ` resolves to ${JSON.stringify(peer.target)}` - : ' does not resolve to a workspace package'; - lines.push(`- ${JSON.stringify(peer.from)} declares ${JSON.stringify(peer.key)}: ${JSON.stringify(peer.specification)}${target}.`); - } - return lines.join('\n'); -}; diff --git a/src/publish-repository/workspace.ts b/src/workspace-publication/discover.ts similarity index 100% rename from src/publish-repository/workspace.ts rename to src/workspace-publication/discover.ts diff --git a/src/publish-repository/graph.ts b/src/workspace-publication/graph.ts similarity index 99% rename from src/publish-repository/graph.ts rename to src/workspace-publication/graph.ts index 3156f21..8bc3525 100644 --- a/src/publish-repository/graph.ts +++ b/src/workspace-publication/graph.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import type { Workspace, WorkspacePackage } from './workspace.ts'; +import type { Workspace, WorkspacePackage } from './discover.ts'; export type DependencyField = 'dependencies' | 'optionalDependencies'; diff --git a/src/workspace-publication/plan.ts b/src/workspace-publication/plan.ts new file mode 100644 index 0000000..dfe19ac --- /dev/null +++ b/src/workspace-publication/plan.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import spawn from 'nano-spawn'; +import type { PackageManager } from '../utils/detect-package-manager.ts'; +import { getStdout } from '../utils/get-stdout.ts'; +import { + createPublishGraph, findWorkspacePackageDirectory, type PublishGraph, +} from './graph.ts'; +import { findWorkspacePackages, type Workspace } from './discover.ts'; + +export type WorkspacePublicationPlan = { + workspace: Workspace; + graph: PublishGraph; + branches: ReadonlyMap; +}; + +export const planWorkspacePublication = async ({ + cwd, + gitRootPath, + sourceName, + packageManager, + publishBranch, +}: { + cwd: string; + gitRootPath: string; + sourceName: string; + packageManager: PackageManager; + publishBranch?: string; +}): Promise => { + const workspace = await findWorkspacePackages(cwd, packageManager, gitRootPath); + if (!workspace) { + return undefined; + } + const selectedPackage = findWorkspacePackageDirectory(workspace, cwd); + if (!selectedPackage) { + return undefined; + } + const selected = selectedPackage.name; + const graph = createPublishGraph(workspace, selected); + const branches = new Map(); + const branchesByName = new Set(); + const selectedBranch = publishBranch ?? `npm/${sourceName}-${selected}`; + for (const node of graph.nodes) { + const relative = path.relative(gitRootPath, node.package.dir); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Workspace package ${JSON.stringify(node.key)} is outside the Git repository and cannot be published.`); + } + const branch = node.key === selected ? selectedBranch : `${selectedBranch}-${node.key}`; + if (branchesByName.has(branch)) { + throw new Error(`Publish branch ${JSON.stringify(branch)} is assigned to more than one workspace package.`); + } + try { + await getStdout(spawn('git', ['check-ref-format', '--branch', branch])); + } catch { + throw new Error(`Invalid publish branch ${JSON.stringify(branch)}.`); + } + branchesByName.add(branch); + branches.set(node.key, branch); + } + return { + workspace, + graph, + branches, + }; +}; diff --git a/src/workspace-publication/publish.ts b/src/workspace-publication/publish.ts new file mode 100644 index 0000000..05d25b9 --- /dev/null +++ b/src/workspace-publication/publish.ts @@ -0,0 +1,161 @@ +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { createPublishRepository, type PublishRepository } from '../publish-repository/create.ts'; +import { preparePublishBranch } from '../publish-repository/prepare-branch.ts'; +import type { PublishRemote } from '../publish-repository/remote.ts'; +import { preparePackagePublication, type PackagePreparation } from '../package-publication/prepare.ts'; +import { planPackagePublicationPush, pushPackagePublications } from '../package-publication/push.ts'; +import type { PackageManager } from '../utils/detect-package-manager.ts'; +import { packPackage } from '../utils/pack-package.ts'; +import type { PublishGraphNode } from './graph.ts'; +import type { WorkspacePublicationPlan } from './plan.ts'; +import { runDependencyGraph, type GraphNode } from './run-graph.ts'; + +type WorkspacePublicationTask = { + node: PublishGraphNode; + tarball: string; + worktree: string; + branch: string; +}; + +export type WorkspacePublicationResult = { + preparations: ReadonlyMap; +}; + +const prepareWorkspaceBranches = async ({ + plan, + repository, + fresh, +}: { + plan: WorkspacePublicationPlan; + repository: PublishRepository; + fresh: boolean | undefined; +}): Promise> => { + const worktrees = new Map(); + plan.graph.nodes.forEach((node, index) => { + worktrees.set(node.key, path.join(repository.temporaryDirectory, `publish-worktree-${index}`)); + }); + for (const node of plan.graph.nodes) { + await preparePublishBranch({ + repository, + publishBranch: plan.branches.get(node.key)!, + localBranch: `git-publish-${randomBytes(16).toString('hex')}`, + fresh, + worktreePath: worktrees.get(node.key)!, + }); + } + return worktrees; +}; + +const packWorkspacePackages = async ({ + plan, + packageManager, + repository, + gitRootPath, +}: { + plan: WorkspacePublicationPlan; + packageManager: PackageManager; + repository: PublishRepository; + gitRootPath: string; +}): Promise> => { + const tarballs = new Map(); + for (const [index, node] of plan.graph.nodes.entries()) { + const tarball = await packPackage( + packageManager, + repository.packWorktreePath, + path.join(repository.packTemporaryDirectory, String(index)), + node.package.dir, + gitRootPath, + path.relative(gitRootPath, node.package.dir), + ); + tarballs.set(node.key, tarball); + } + return tarballs; +}; + +export const publishWorkspaceClosure = async ({ + plan, + packageManager, + sourceRepositoryPath, + gitRootPath, + publishRemote, + sourceName, + sourceCommit, + fresh, +}: { + plan: WorkspacePublicationPlan; + packageManager: PackageManager; + sourceRepositoryPath: string; + gitRootPath: string; + publishRemote: PublishRemote; + sourceName: string; + sourceCommit: string | undefined; + fresh: boolean | undefined; +}): Promise => { + const repository = await createPublishRepository({ + sourceRepositoryPath, + publishRemote, + }); + let primaryError: unknown; + try { + const pushPlan = await planPackagePublicationPush(repository, fresh); + const worktrees = await prepareWorkspaceBranches({ + plan, + repository, + fresh, + }); + const tarballs = await packWorkspacePackages({ + plan, + packageManager, + repository, + gitRootPath, + }); + const nodes: GraphNode[] = plan.graph.nodes.map(node => ({ + key: node.key, + value: { + node, + tarball: tarballs.get(node.key)!, + worktree: worktrees.get(node.key)!, + branch: plan.branches.get(node.key)!, + }, + dependencies: node.dependencies.map(edge => edge.target), + })); + const preparations = await runDependencyGraph(nodes, async ( + { key, value }, + dependencyPreparations, + ): Promise => { + const dependencyPublications = new Map(); + for (const [name, preparation] of dependencyPreparations) { + dependencyPublications.set(name, preparation.publication); + } + return preparePackagePublication({ + packageName: key, + packedTarball: value.tarball, + publishWorktree: value.worktree, + branch: value.branch, + fetchUrl: publishRemote.fetchUrl, + sourceName, + sourceCommit, + dependencyEdges: value.node.dependencies, + dependencyPublications, + gitOptions: repository.gitOptions, + }); + }); + await pushPackagePublications({ + repository, + preparations: preparations.values(), + pushPlan, + }); + return { preparations }; + } catch (error) { + primaryError = error; + throw error; + } finally { + await repository.dispose().catch((cleanupError: unknown) => { + if (primaryError) { + throw new AggregateError([primaryError, cleanupError], 'Failed to publish workspace closure.'); + } + throw cleanupError; + }); + } +}; diff --git a/src/publish-repository/run-graph.ts b/src/workspace-publication/run-graph.ts similarity index 100% rename from src/publish-repository/run-graph.ts rename to src/workspace-publication/run-graph.ts diff --git a/tests/index.ts b/tests/index.ts index db465b6..bed2253 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -3,6 +3,7 @@ import { describe } from 'manten'; describe('git-publish', () => { import('./specs/workspace-discovery.ts'); import('./specs/workspace-publication.ts'); + import('./specs/package-publication.ts'); import('./specs/run-graph.ts'); import('./specs/publish-graph.ts'); import('./specs/github-remotes.ts'); diff --git a/tests/specs/package-publication.ts b/tests/specs/package-publication.ts new file mode 100644 index 0000000..79ec727 --- /dev/null +++ b/tests/specs/package-publication.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +import path from 'node:path'; +import { createGzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; +import { describe, expect, test } from 'manten'; +import { createFixture } from 'fs-fixture'; +import tarFs from 'tar-fs'; +import { preparePackagePublication } from '../../src/package-publication/prepare.ts'; +import { createGitFixture } from '../utils/create-git.ts'; + +describe('Package publication', () => { + test('rewrites direct closure dependencies before committing the packed package', async () => { + await using packageFixture = await createFixture({ + package: { + 'package.json': JSON.stringify({ + name: '@test/adapter', + version: '1.0.0', + scripts: { + prepare: 'node prepare.js', + prepack: 'node prepack.js', + postpack: 'node postpack.js', + }, + dependencies: { + 'core-alias': 'workspace:@test/core@*', + }, + optionalDependencies: { + '@test/optional': 'workspace:*', + }, + }, null, 2), + 'index.js': 'module.exports = 1;', + }, + }); + const tarballPath = packageFixture.getPath('package.tgz'); + await pipeline( + tarFs.pack(packageFixture.getPath('package'), { + map: (header) => { + header.name = `package/${header.name}`; + return header; + }, + }), + createGzip(), + createWriteStream(tarballPath), + ); + await using publishFixture = await createGitFixture(); + await publishFixture.git('commit', ['--allow-empty', '-m', 'Initial commit']); + const preparation = await preparePackagePublication({ + packageName: '@test/adapter', + packedTarball: tarballPath, + publishWorktree: publishFixture.path, + branch: 'npm/adapter', + fetchUrl: '/remote.git', + sourceName: 'main', + sourceCommit: '1234567', + dependencyEdges: [ + { + key: 'core-alias', + field: 'dependencies', + target: '@test/core', + }, + { + key: '@test/optional', + field: 'optionalDependencies', + target: '@test/optional', + }, + ], + dependencyPublications: new Map([ + ['@test/core', { + packageName: '@test/core', + branch: 'npm/core', + commit: 'core-commit', + installSpecifier: 'git+file:///remote.git#core-commit', + refspec: 'core-commit:refs/heads/npm/core', + }], + ['@test/optional', { + packageName: '@test/optional', + branch: 'npm/optional', + commit: 'optional-commit', + installSpecifier: 'git+file:///remote.git#optional-commit', + refspec: 'optional-commit:refs/heads/npm/optional', + }], + ]), + gitOptions: {}, + }); + const manifest = JSON.parse(await fs.readFile(path.join(publishFixture.path, 'package.json'), 'utf8')); + expect(manifest.dependencies).toStrictEqual({ + 'core-alias': 'git+file:///remote.git#core-commit', + }); + expect(manifest.optionalDependencies).toStrictEqual({ + '@test/optional': 'git+file:///remote.git#optional-commit', + }); + expect(manifest.scripts).toStrictEqual({ postpack: 'node postpack.js' }); + expect(preparation.reusedExistingCommit).toBe(false); + expect(preparation.publication).toStrictEqual({ + packageName: '@test/adapter', + branch: 'npm/adapter', + commit: await publishFixture.git('rev-parse', ['HEAD']), + installSpecifier: `git+file://${path.resolve('/remote.git')}#${preparation.publication.commit}`, + refspec: `${preparation.publication.commit}:refs/heads/npm/adapter`, + }); + }); +}); diff --git a/tests/specs/publish-graph.ts b/tests/specs/publish-graph.ts index 1489b1b..a7507aa 100644 --- a/tests/specs/publish-graph.ts +++ b/tests/specs/publish-graph.ts @@ -1,11 +1,11 @@ import { describe, test, expect } from 'manten'; import { createFixture } from 'fs-fixture'; -import { discoverWorkspacePackages } from '../../src/publish-repository/workspace.ts'; +import { discoverWorkspacePackages } from '../../src/workspace-publication/discover.ts'; import { createPublishGraph, resolvePackageDirectory, selectWorkspacePackage, -} from '../../src/publish-repository/graph.ts'; +} from '../../src/workspace-publication/graph.ts'; const discoverTestWorkspace = async (packages: Record) => { await using fixture = await createFixture({ diff --git a/tests/specs/run-graph.ts b/tests/specs/run-graph.ts index 1421eee..7e500d3 100644 --- a/tests/specs/run-graph.ts +++ b/tests/specs/run-graph.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from 'manten'; import { runDependencyGraph, type GraphNode, -} from '../../src/publish-repository/run-graph.ts'; +} from '../../src/workspace-publication/run-graph.ts'; describe('Dependency graph runner', () => { test('relays dependency results up a linear chain', async () => { diff --git a/tests/specs/workspace-discovery.ts b/tests/specs/workspace-discovery.ts index e6618c2..285841b 100644 --- a/tests/specs/workspace-discovery.ts +++ b/tests/specs/workspace-discovery.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import { describe, test, expect } from 'manten'; import { createFixture } from 'fs-fixture'; -import { discoverWorkspacePackages, findWorkspacePackages } from '../../src/publish-repository/workspace.ts'; +import { discoverWorkspacePackages, findWorkspacePackages } from '../../src/workspace-publication/discover.ts'; describe('Workspace discovery', () => { test('discovers npm workspace packages', async () => { From cd23e75b85a2a5ac21ab9acb45db1b8093f83c27 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sat, 5 Sep 2026 17:14:06 +0900 Subject: [PATCH 05/44] feat: add publish branch templates --- README.md | 26 +++++++- src/index.ts | 24 +++++--- src/package-publication/branch.ts | 34 +++++++++++ src/utils/git.ts | 11 +++- src/workspace-publication/plan.ts | 23 ++++--- tests/index.ts | 2 + tests/specs/branch-template.ts | 53 ++++++++++++++++ tests/specs/standalone-branch-template.ts | 47 ++++++++++++++ tests/specs/workspace-publication.ts | 74 ++++++++++++++++++++--- 9 files changed, 269 insertions(+), 25 deletions(-) create mode 100644 src/package-publication/branch.ts create mode 100644 tests/specs/branch-template.ts create mode 100644 tests/specs/standalone-branch-template.ts diff --git a/README.md b/README.md index 4ff1160..803b16e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ git-publish | Flag | Description | | ----------------------- | ------------------------------------------------------------- | -| `-b, --branch ` | Target branch name. Workspace dependencies use this name as a prefix | +| `-b, --branch