Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions packages/publish/src/pipeline/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { VersionOutput } from '@releasekit/core';
import * as path from 'node:path';
import { buildDependencyGraph, type VersionOutput, type VersionPackageUpdate } from '@releasekit/core';
import { BasePublishError, PipelineError } from '../errors/index.js';
import { cargoRegistry, cratePathDeps } from '../registry/cargo.js';
import { npmRegistry, readNpmWorkspaceDeps } from '../registry/npm.js';
import { pubRegistry } from '../registry/pub.js';
import type { RegistryId, RegistryTarget } from '../registry/types.js';
import { runCargoPublishStage } from '../stages/cargo-publish.js';
import { runGitCommitStage } from '../stages/git-commit.js';
import { preparePushSetup, pushPackageTag, runGitPushStage } from '../stages/git-push.js';
Expand Down Expand Up @@ -34,6 +39,86 @@ function inferStageName(error: unknown): string {
return 'unknown';
}

type OrderEdge = [before: string, after: string];

/** A configured `publishOrder`, as batch mode applies it: listed targets in order, then the rest. */
function configuredOrderEdges(
targets: Pick<RegistryTarget, 'packageName' | 'updatePackageName'>[],
explicitOrder: string[],
): OrderEdge[] {
const listed = explicitOrder.flatMap((name) => targets.filter((t) => t.packageName === name));
const edges: OrderEdge[] = [];
let previous: string | undefined;
for (const target of listed) {
if (previous !== undefined) {
edges.push([previous, target.updatePackageName]);
}
previous = target.updatePackageName;
}
const last = previous;
if (last !== undefined) {
for (const target of targets.filter((t) => !listed.includes(t))) {
edges.push([last, target.updatePackageName]);
}
}
return edges;
}

/**
* Per-package mode hands each stage one update, so no registry can order its targets. Order the updates by every
* selected registry's constraints at once, since a hybrid update publishes to several registries in one step. Only
* real dependencies and configured `publishOrder`s constrain: an arbitrary order would contradict another registry's.
*/
async function orderPerPackageUpdates(ctx: PipelineContext): Promise<VersionPackageUpdate[]> {
const { config, cliOptions, input, cwd } = ctx;
const selected = (id: RegistryId): boolean => cliOptions.registry === 'all' || cliOptions.registry === id;
const edges: OrderEdge[] = [];

if (selected('npm') && npmRegistry.isEnabled(config)) {
const npmUpdates = input.updates.filter((u) => u.filePath.endsWith('package.json'));
if (config.npm.publishOrder.length > 0) {
const targets = npmUpdates.map((u) => ({ packageName: u.packageName, updatePackageName: u.packageName }));
edges.push(...configuredOrderEdges(targets, config.npm.publishOrder));
} else {
for (const u of npmUpdates) {
for (const dep of readNpmWorkspaceDeps(path.resolve(cwd, u.filePath))) {
edges.push([dep, u.packageName]);
}
}
}
}

if (selected('cargo') && cargoRegistry.isEnabled(config)) {
const crates = await cargoRegistry.discover(ctx);
if (config.cargo.publishOrder.length > 0) {
edges.push(...configuredOrderEdges(crates, config.cargo.publishOrder));
} else {
const updateOf = new Map(crates.map((c) => [c.packageName, c.updatePackageName]));
for (const [crate, deps] of cratePathDeps(crates)) {
for (const dep of deps) {
edges.push([updateOf.get(dep) ?? dep, updateOf.get(crate) ?? crate]);
}
}
}
}

if (selected('pub') && pubRegistry.isEnabled(config) && config.pub.publishOrder.length > 0) {
edges.push(...configuredOrderEdges(await pubRegistry.discover(ctx), config.pub.publishOrder));
}

const graph = buildDependencyGraph(
input.updates.map((u) => ({
name: u.packageName,
dir: path.dirname(path.resolve(cwd, u.filePath)),
// topologicalOrder reads only deps.
ecosystem: 'npm',
deps: edges.filter(([, after]) => after === u.packageName).map(([before]) => before),
})),
);
const rank = new Map(graph.topologicalOrder(input.updates.map((u) => u.packageName)).map((name, i) => [name, i]));
return [...input.updates].sort((a, b) => (rank.get(a.packageName) ?? 0) - (rank.get(b.packageName) ?? 0));
}

export async function runPipeline(
input: VersionOutput,
config: PublishConfig,
Expand Down Expand Up @@ -92,7 +177,7 @@ export async function runPipeline(
} else if (!options.skipPublish) {
const pushSetup = !options.skipGit ? await preparePushSetup(ctx) : null;

for (const update of input.updates) {
for (const update of await orderPerPackageUpdates(ctx)) {
const singleCtx: PipelineContext = {
...ctx,
input: { ...ctx.input, updates: [update] },
Expand Down
22 changes: 16 additions & 6 deletions packages/publish/src/registry/cargo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { Registry, RegistryTarget } from './types.js';

const ALREADY_PUBLISHED_PATTERN = /already exists on crates\.io index|already uploaded/i;

interface CargoTarget extends RegistryTarget {
export interface CargoTarget extends RegistryTarget {
dir: string;
manifestPath: string;
pathDeps: string[];
Expand Down Expand Up @@ -173,18 +173,28 @@ function orderCrates(crates: CargoTarget[], explicitOrder: string[]): CargoTarge
return topologicalSort(crates);
}

function topologicalSort(crates: CargoTarget[]): CargoTarget[] {
const byName = new Map(crates.map((c) => [c.packageName, c]));
export function cratePathDeps(crates: CargoTarget[]): Map<string, string[]> {
// `path` deps are directories, so map each crate's directory back to its name.
const nameByDir = new Map(crates.map((c) => [path.resolve(c.dir), c.packageName]));
return new Map(
crates.map((c) => [
c.packageName,
c.pathDeps
.map((depPath) => nameByDir.get(path.resolve(c.dir, depPath)))
.filter((name): name is string => name !== undefined),
]),
);
}

function topologicalSort(crates: CargoTarget[]): CargoTarget[] {
const byName = new Map(crates.map((c) => [c.packageName, c]));
const deps = cratePathDeps(crates);

const graphPackages: GraphPackage[] = crates.map((c) => ({
name: c.packageName,
dir: c.dir,
ecosystem: 'cargo',
deps: c.pathDeps
.map((depPath) => nameByDir.get(path.resolve(c.dir, depPath)))
.filter((name): name is string => name !== undefined),
deps: deps.get(c.packageName) ?? [],
}));

const graph = buildDependencyGraph(graphPackages);
Expand Down
2 changes: 1 addition & 1 deletion packages/publish/src/registry/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface NpmSession {
isolation: NpmEnvIsolation;
}

function readNpmWorkspaceDeps(pkgJsonPath: string): string[] {
export function readNpmWorkspaceDeps(pkgJsonPath: string): string[] {
try {
const json = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
return [...Object.keys(json.dependencies ?? {}), ...Object.keys(json.peerDependencies ?? {})];
Expand Down
11 changes: 5 additions & 6 deletions packages/publish/src/stages/git-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,16 @@ export async function pushPackageTag(tag: string, ctx: PipelineContext, setup?:
const { remote, pushRemote, branch } = resolvedSetup;

try {
// Pushing the tag ref carries its commit with it.
// Branch before tag, as in runGitPushStage. A no-op once the remote is up to date, so safe to repeat per package.
if (pushesBranch(ctx) && branch) {
await runGit(dryRun, `git push ${remote} ${branch}`, () => git.push({ remote: pushRemote, ref: branch, cwd }));
}

await runGit(dryRun, `git push ${remote} refs/tags/${tag}`, () =>
git.push({ remote: pushRemote, ref: `refs/tags/${tag}`, cwd }),
);
output.git.tags.push(tag);

// A no-op once the remote is up to date, so safe to repeat per package.
if (pushesBranch(ctx) && branch) {
await runGit(dryRun, `git push ${remote} ${branch}`, () => git.push({ remote: pushRemote, ref: branch, cwd }));
}

output.git.pushed = true;
} catch (error) {
if (error instanceof PublishError) {
Expand Down
211 changes: 211 additions & 0 deletions packages/publish/test/unit/pipeline-per-package.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { VersionOutput, VersionPackageUpdate } from '@releasekit/core';
import { createFakeGit, type FakeGit } from '@releasekit/git';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getDefaultConfig } from '../../src/config.js';
import { runPipeline } from '../../src/pipeline/index.js';
import type { PublishCliOptions } from '../../src/types.js';
import { execCommand, execCommandSafe } from '../../src/utils/exec.js';

vi.mock('../../src/utils/exec.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/utils/exec.js')>()),
execCommand: vi.fn(),
execCommandSafe: vi.fn(),
}));

vi.mock('../../src/utils/auth.js', () => ({
detectNpmAuth: () => 'token',
hasCargoAuth: () => true,
hasPubTokenAuth: () => false,
detectGitPushMethod: async () => 'https',
}));

let fakeGit: FakeGit;
vi.mock('@releasekit/git', async (importOriginal) => ({
...(await importOriginal<typeof import('@releasekit/git')>()),
createGitCli: () => fakeGit,
}));

const options: PublishCliOptions = {
registry: 'all',
npmAuth: 'auto',
dryRun: false,
skipGit: true,
skipPublish: false,
skipGithubRelease: true,
skipVerification: true,
json: false,
verbose: false,
};

const originalCwd = process.cwd();
let workspace: string;

function write(file: string, contents: string): void {
fs.mkdirSync(path.dirname(path.join(workspace, file)), { recursive: true });
fs.writeFileSync(path.join(workspace, file), contents);
}

function packageJson(name: string, dependencies: Record<string, string> = {}): string {
return JSON.stringify({ name, version: '0.1.0', dependencies });
}

const CORE_PATH_DEP = 'core-crate = { path = "../core", version = "0.1.0" }\n';

function cargoToml(name: string, dependencies = ''): string {
return `[package]\nname = "${name}"\nversion = "0.1.0"\n\n[dependencies]\n${dependencies}`;
}

function update(packageName: string, filePath: string): VersionPackageUpdate {
return { packageName, newVersion: '0.2.0', filePath, tag: `${packageName}@v0.2.0` };
}

function perPackageInput(updates: VersionPackageUpdate[]): VersionOutput {
return { dryRun: false, updates, changelogs: [], tags: updates.map((u) => u.tag ?? '') };
}

function publishedLabels(): string[] {
return vi
.mocked(execCommand)
.mock.calls.map(([, , opts]) => opts?.label ?? '')
.filter((label) => /^(npm|cargo) publish /.test(label));
}

describe('per-package publish pipeline', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(execCommand).mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
vi.mocked(execCommandSafe).mockResolvedValue({ stdout: '', stderr: '', exitCode: 1 });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404 } as Response));
fakeGit = createFakeGit({ currentBranch: 'main' });
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'releasekit-per-package-'));
process.chdir(workspace);
});

afterEach(() => {
process.chdir(originalCwd);
vi.unstubAllGlobals();
fs.rmSync(workspace, { recursive: true, force: true });
});

it('should publish a crate after its path dependency when it is listed first', async () => {
write('crates/app/Cargo.toml', cargoToml('app-crate', CORE_PATH_DEP));
write('crates/core/Cargo.toml', cargoToml('core-crate'));

await runPipeline(
perPackageInput([update('app-crate', 'crates/app/Cargo.toml'), update('core-crate', 'crates/core/Cargo.toml')]),
getDefaultConfig(),
options,
);

expect(publishedLabels()).toEqual(['cargo publish core-crate@0.2.0', 'cargo publish app-crate@0.2.0']);
});

it('should publish an npm package after its dependency when it is listed first', async () => {
write('packages/app/package.json', packageJson('app', { lib: '^0.1.0' }));
write('packages/lib/package.json', packageJson('lib'));

await runPipeline(
perPackageInput([update('app', 'packages/app/package.json'), update('lib', 'packages/lib/package.json')]),
getDefaultConfig(),
options,
);

expect(publishedLabels()).toEqual(['npm publish lib@0.2.0', 'npm publish app@0.2.0']);
});

it("should keep both registries' orders when a hybrid package spans npm and cargo", async () => {
// web depends on the hybrid's npm side; the hybrid's crate depends on a crate-only package.
write('packages/web/package.json', packageJson('web', { hybrid: '^0.1.0' }));
write('packages/hybrid/package.json', packageJson('hybrid'));
write('packages/hybrid/Cargo.toml', cargoToml('hybrid-crate', CORE_PATH_DEP));
write('packages/core/Cargo.toml', cargoToml('core-crate'));

await runPipeline(
perPackageInput([
update('web', 'packages/web/package.json'),
update('hybrid', 'packages/hybrid/package.json'),
update('core-crate', 'packages/core/Cargo.toml'),
]),
getDefaultConfig(),
options,
);

expect(publishedLabels()).toEqual([
'cargo publish core-crate@0.2.0',
'npm publish hybrid@0.2.0',
'cargo publish hybrid-crate@0.2.0',
'npm publish web@0.2.0',
]);
});

it('should publish a hybrid crate after its path dependency when the npm sides are unrelated', async () => {
write('packages/a/package.json', packageJson('a'));
write('packages/a/Cargo.toml', cargoToml('a-crate', 'b-crate = { path = "../b", version = "0.1.0" }\n'));
write('packages/b/package.json', packageJson('b'));
write('packages/b/Cargo.toml', cargoToml('b-crate'));

await runPipeline(
perPackageInput([update('a', 'packages/a/package.json'), update('b', 'packages/b/package.json')]),
getDefaultConfig(),
options,
);

expect(publishedLabels().filter((l) => l.startsWith('cargo'))).toEqual([
'cargo publish b-crate@0.2.0',
'cargo publish a-crate@0.2.0',
]);
});

it('should follow a configured cargo publishOrder over input order', async () => {
write('crates/a/Cargo.toml', cargoToml('a-crate'));
write('crates/b/Cargo.toml', cargoToml('b-crate'));
const config = getDefaultConfig();
config.cargo.publishOrder = ['b-crate', 'a-crate'];

await runPipeline(
perPackageInput([update('a-crate', 'crates/a/Cargo.toml'), update('b-crate', 'crates/b/Cargo.toml')]),
config,
options,
);

expect(publishedLabels()).toEqual(['cargo publish b-crate@0.2.0', 'cargo publish a-crate@0.2.0']);
});

it('should follow a configured npm publishOrder over input order', async () => {
write('packages/a/package.json', packageJson('a'));
write('packages/b/package.json', packageJson('b'));
const config = getDefaultConfig();
config.npm.publishOrder = ['b', 'a'];

await runPipeline(
perPackageInput([update('a', 'packages/a/package.json'), update('b', 'packages/b/package.json')]),
config,
options,
);

expect(publishedLabels()).toEqual(['npm publish b@0.2.0', 'npm publish a@0.2.0']);
});

it("should push the branch before a package's tag, so a rejected branch push leaves no tag on the remote", async () => {
write('packages/lib/package.json', packageJson('lib'));
const push = vi.spyOn(fakeGit, 'push').mockImplementation(async ({ ref }) => {
if (ref === 'main') {
throw new Error('remote: error: GH013: Repository rule violations found for refs/heads/main');
}
});

await expect(
runPipeline(
{ ...perPackageInput([update('lib', 'packages/lib/package.json')]), commitMessage: 'chore: release lib' },
getDefaultConfig(),
{ ...options, skipGit: false },
),
).rejects.toMatchObject({ failedStage: 'git-push' });

expect(publishedLabels()).toEqual(['npm publish lib@0.2.0']);
expect(push.mock.calls.map(([opts]) => opts.ref)).toEqual(['main']);
});
});
Loading
Loading