From 9c7f6a15902821f5bb09263deba8db210969b5ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 22:17:59 +0100 Subject: [PATCH 1/3] fix(publish): keep dependency order and branch-first push in per-package mode Per-package mode gave each publish stage a single update, so the npm and cargo dependency sorts and every registry's publishOrder had nothing to order, and packages published in VersionOutput order. Merge each selected registry's own publish order into one sequence before the loop. pushPackageTag also pushed the tag before the branch, so a rejected branch push left an orphan tag on the remote. Push the branch first, as batch mode does. Refs #735 Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/pipeline/index.ts | 45 ++++- packages/publish/src/stages/git-push.ts | 11 +- .../test/unit/pipeline-per-package.spec.ts | 163 ++++++++++++++++++ .../publish/test/unit/stages/git-push.spec.ts | 13 +- 4 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 packages/publish/test/unit/pipeline-per-package.spec.ts diff --git a/packages/publish/src/pipeline/index.ts b/packages/publish/src/pipeline/index.ts index 499b4ccd..629ff7cf 100644 --- a/packages/publish/src/pipeline/index.ts +++ b/packages/publish/src/pipeline/index.ts @@ -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 } from '../registry/cargo.js'; +import { npmRegistry } 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'; @@ -34,6 +39,42 @@ function inferStageName(error: unknown): string { return 'unknown'; } +/** + * Per-package mode hands each stage one update, so no registry can order its targets. Merge each selected registry's + * own order (dependency sort or `publishOrder`) into one sequence that keeps all of them, since a hybrid update + * publishes to several registries in one step. + */ +async function orderPerPackageUpdates(ctx: PipelineContext): Promise { + const { config, cliOptions, input } = ctx; + const selected = (id: RegistryId): boolean => cliOptions.registry === 'all' || cliOptions.registry === id; + + const orders: RegistryTarget[][] = []; + if (selected('npm') && npmRegistry.isEnabled(config)) { + orders.push((await npmRegistry.discover(ctx)).filter((t) => t.filePath.endsWith('package.json'))); + } + if (selected('cargo') && cargoRegistry.isEnabled(config)) { + orders.push(await cargoRegistry.discover(ctx)); + } + if (selected('pub') && pubRegistry.isEnabled(config)) { + orders.push(await pubRegistry.discover(ctx)); + } + + const graph = buildDependencyGraph( + input.updates.map((u) => ({ + name: u.packageName, + dir: path.dirname(path.resolve(ctx.cwd, u.filePath)), + // topologicalOrder reads only deps. + ecosystem: 'npm', + deps: orders.flatMap((order) => { + const index = order.findIndex((t) => t.updatePackageName === u.packageName); + return order.slice(0, Math.max(index, 0)).map((t) => t.updatePackageName); + }), + })), + ); + 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, @@ -92,7 +133,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] }, diff --git a/packages/publish/src/stages/git-push.ts b/packages/publish/src/stages/git-push.ts index 5f5814be..7de788e7 100644 --- a/packages/publish/src/stages/git-push.ts +++ b/packages/publish/src/stages/git-push.ts @@ -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) { diff --git a/packages/publish/test/unit/pipeline-per-package.spec.ts b/packages/publish/test/unit/pipeline-per-package.spec.ts new file mode 100644 index 00000000..480e2d9f --- /dev/null +++ b/packages/publish/test/unit/pipeline-per-package.spec.ts @@ -0,0 +1,163 @@ +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()), + 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()), + 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 { + 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 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']); + }); +}); diff --git a/packages/publish/test/unit/stages/git-push.spec.ts b/packages/publish/test/unit/stages/git-push.spec.ts index 906d51d4..5c5641da 100644 --- a/packages/publish/test/unit/stages/git-push.spec.ts +++ b/packages/publish/test/unit/stages/git-push.spec.ts @@ -334,22 +334,22 @@ describe('pushPackageTag', () => { vi.mocked(detectGitPushMethod).mockResolvedValue('https'); }); - it('should push the specific tag ref and then the branch', async () => { + it('should push the branch and then the specific tag ref', async () => { const ctx = createContext(); await pushPackageTag('pkg-a@v1.0.0', ctx); expect(fakeGit.pushed).toEqual([ - expect.objectContaining({ remote: 'origin', ref: 'refs/tags/pkg-a@v1.0.0' }), expect.objectContaining({ remote: 'origin', ref: 'main' }), + expect.objectContaining({ remote: 'origin', ref: 'refs/tags/pkg-a@v1.0.0' }), ]); expect(ctx.output.git.tags).toContain('pkg-a@v1.0.0'); expect(ctx.output.git.pushed).toBe(true); }); - it('should wrap a branch-push failure as GIT_PUSH_ERROR, not unknown', async () => { + it('should wrap a branch-push failure as GIT_PUSH_ERROR without pushing the tag', async () => { // The branch push is rejected by a branch ruleset - a raw seam failure. - vi.spyOn(fakeGit, 'push').mockImplementation(async (opts) => { + const push = vi.spyOn(fakeGit, 'push').mockImplementation(async (opts) => { if (opts.ref === 'main') { throw new Error('Command failed: git push origin main\nremote: error: GH013: rule violations'); } @@ -365,6 +365,7 @@ describe('pushPackageTag', () => { expect(thrown).toBeInstanceOf(PublishError); expect((thrown as PublishError).code).toBe('GIT_PUSH_ERROR'); expect((thrown as PublishError).message).toContain('git push origin main'); + expect(push.mock.calls.map(([opts]) => opts.ref)).toEqual(['main']); }); it('should skip branch push when not committed', async () => { @@ -385,7 +386,7 @@ describe('pushPackageTag', () => { expect(ctx.output.git.pushed).toBe(true); }); - it('should push the branch after the tag for a reused release commit', async () => { + it('should push the branch before the tag for a reused release commit', async () => { const ctx = createContext({ releaseCommitReused: true, output: { @@ -404,8 +405,8 @@ describe('pushPackageTag', () => { await pushPackageTag('pkg-a@v1.0.0', ctx, setup ?? undefined); expect(fakeGit.pushed).toEqual([ - expect.objectContaining({ remote: 'origin', ref: 'refs/tags/pkg-a@v1.0.0' }), expect.objectContaining({ remote: 'origin', ref: 'main' }), + expect.objectContaining({ remote: 'origin', ref: 'refs/tags/pkg-a@v1.0.0' }), ]); }); From 63dc565ad3cda7c21448d1c2098e025d7d8370e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 23:13:37 +0100 Subject: [PATCH 2/3] fix(publish): order per-package updates by real dependencies only Turning each registry's whole sorted order into constraints also encoded npm's arbitrary order for unrelated packages. For two hybrids whose crates depend on each other but whose npm sides don't, that contradicted cargo's path dependency, the cycle fell back to input order, and a crate could publish before its dependency. Constrain only by npm workspace deps, cargo path deps, and a configured publishOrder. Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/pipeline/index.ts | 76 +++++++++++++++---- packages/publish/src/registry/cargo.ts | 22 ++++-- packages/publish/src/registry/npm.ts | 2 +- .../test/unit/pipeline-per-package.spec.ts | 33 ++++++++ 4 files changed, 110 insertions(+), 23 deletions(-) diff --git a/packages/publish/src/pipeline/index.ts b/packages/publish/src/pipeline/index.ts index 629ff7cf..2ef54731 100644 --- a/packages/publish/src/pipeline/index.ts +++ b/packages/publish/src/pipeline/index.ts @@ -1,8 +1,8 @@ import * as path from 'node:path'; import { buildDependencyGraph, type VersionOutput, type VersionPackageUpdate } from '@releasekit/core'; import { BasePublishError, PipelineError } from '../errors/index.js'; -import { cargoRegistry } from '../registry/cargo.js'; -import { npmRegistry } from '../registry/npm.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'; @@ -39,36 +39,80 @@ 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[], + 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. Merge each selected registry's - * own order (dependency sort or `publishOrder`) into one sequence that keeps all of them, since a hybrid update - * publishes to several registries in one step. + * 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 { - const { config, cliOptions, input } = ctx; + const { config, cliOptions, input, cwd } = ctx; const selected = (id: RegistryId): boolean => cliOptions.registry === 'all' || cliOptions.registry === id; + const edges: OrderEdge[] = []; - const orders: RegistryTarget[][] = []; if (selected('npm') && npmRegistry.isEnabled(config)) { - orders.push((await npmRegistry.discover(ctx)).filter((t) => t.filePath.endsWith('package.json'))); + 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)) { - orders.push(await cargoRegistry.discover(ctx)); + 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)) { - orders.push(await pubRegistry.discover(ctx)); + + 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(ctx.cwd, u.filePath)), + dir: path.dirname(path.resolve(cwd, u.filePath)), // topologicalOrder reads only deps. ecosystem: 'npm', - deps: orders.flatMap((order) => { - const index = order.findIndex((t) => t.updatePackageName === u.packageName); - return order.slice(0, Math.max(index, 0)).map((t) => t.updatePackageName); - }), + 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])); diff --git a/packages/publish/src/registry/cargo.ts b/packages/publish/src/registry/cargo.ts index 4008407c..6c79d32f 100644 --- a/packages/publish/src/registry/cargo.ts +++ b/packages/publish/src/registry/cargo.ts @@ -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[]; @@ -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 { // `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); diff --git a/packages/publish/src/registry/npm.ts b/packages/publish/src/registry/npm.ts index cf75344e..dab558c2 100644 --- a/packages/publish/src/registry/npm.ts +++ b/packages/publish/src/registry/npm.ts @@ -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 ?? {})]; diff --git a/packages/publish/test/unit/pipeline-per-package.spec.ts b/packages/publish/test/unit/pipeline-per-package.spec.ts index 480e2d9f..aa2a5b73 100644 --- a/packages/publish/test/unit/pipeline-per-package.spec.ts +++ b/packages/publish/test/unit/pipeline-per-package.spec.ts @@ -141,6 +141,39 @@ describe('per-package publish pipeline', () => { ]); }); + 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 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 }) => { From 6e9083d37ba4b570d8685004bd424a784a1d35e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 24 Oct 2026 02:09:01 +0100 Subject: [PATCH 3/3] test(publish): cover a configured npm publishOrder in per-package mode Co-Authored-By: Claude Opus 5.5 --- .../test/unit/pipeline-per-package.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/publish/test/unit/pipeline-per-package.spec.ts b/packages/publish/test/unit/pipeline-per-package.spec.ts index aa2a5b73..8b3a57af 100644 --- a/packages/publish/test/unit/pipeline-per-package.spec.ts +++ b/packages/publish/test/unit/pipeline-per-package.spec.ts @@ -174,6 +174,21 @@ describe('per-package publish pipeline', () => { 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 }) => {