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
4 changes: 2 additions & 2 deletions packages/publish/src/pipeline/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ export async function runPipeline(
await runVerifyStage(ctx);
}

// Stage 7: Git push (batch mode only — per-package mode pushed inline above)
if (!options.skipGit && !perPackageMode && (options.skipPublish || ctx.output.publishSucceeded)) {
// Stage 7: Git push — in per-package mode, whatever wasn't pushed inline above.
if (!options.skipGit && (options.skipPublish || ctx.output.publishSucceeded)) {
await runGitPushStage(ctx);
Comment thread
goosewobbler marked this conversation as resolved.
Resolved
}

Expand Down
7 changes: 4 additions & 3 deletions packages/publish/src/stages/git-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,10 @@ export async function runGitPushStage(ctx: PipelineContext): Promise<void> {
await runGit(dryRun, `git push ${remote} ${branch}`, () => git.push({ remote: pushRemote, ref: branch, cwd }));
}

// Push tags
if (output.git.tags.length > 0) {
await runGit(dryRun, `git push ${remote} --tags`, () => git.push({ remote: pushRemote, tags: true, cwd }));
for (const tag of new Set(output.git.tags)) {
await runGit(dryRun, `git push ${remote} refs/tags/${tag}`, () =>
git.push({ remote: pushRemote, ref: `refs/tags/${tag}`, cwd }),
);
}

ctx.output.git.pushed = true;
Expand Down
10 changes: 6 additions & 4 deletions packages/publish/src/stages/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { info, type VersionOutput } from '@releasekit/core';
import { z } from 'zod';
import { createPublishError, PublishErrorCode } from '../errors/index.js';

const VersionChangelogEntrySchema = z.object({
const VersionChangelogEntrySchema = z.looseObject({
type: z.string(),
description: z.string(),
issueIds: z.array(z.string()).optional(),
scope: z.string().optional(),
originalType: z.string().optional(),
});

const VersionPackageChangelogSchema = z.object({
const VersionPackageChangelogSchema = z.looseObject({
packageName: z.string(),
version: z.string(),
previousVersion: z.string().nullable(),
Expand All @@ -20,18 +20,20 @@ const VersionPackageChangelogSchema = z.object({
entries: z.array(VersionChangelogEntrySchema),
});

const VersionPackageUpdateSchema = z.object({
const VersionPackageUpdateSchema = z.looseObject({
packageName: z.string(),
newVersion: z.string(),
filePath: z.string(),
tag: z.string().optional(),
Comment thread
goosewobbler marked this conversation as resolved.
Resolved
});

const VersionOutputSchema = z.object({
const VersionOutputSchema = z.looseObject({
dryRun: z.boolean(),
updates: z.array(VersionPackageUpdateSchema),
changelogs: z.array(VersionPackageChangelogSchema),
commitMessage: z.string().optional(),
tags: z.array(z.string()),
baselineTags: z.array(z.string()).optional(),
Comment thread
goosewobbler marked this conversation as resolved.
Resolved
});

export async function parseInput(inputPath?: string): Promise<VersionOutput> {
Expand Down
24 changes: 21 additions & 3 deletions packages/publish/test/unit/pipeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ describe('pipeline', () => {
tags: ['pkg-a@v1.1.0', 'pkg-b@v2.0.0'],
};

it('should call pushPackageTag once per update instead of runGitPushStage', async () => {
it('should push each package tag as it publishes, then the remaining tags once all succeed', async () => {
const { runGitPushStage, pushPackageTag } = await import('../../src/stages/git-push.js');
const { runNpmPublishStage } = await import('../../src/stages/npm-publish.js');

Expand All @@ -349,7 +349,24 @@ describe('pipeline', () => {
const calls = vi.mocked(pushPackageTag).mock.calls;
expect(calls[0]?.[0]).toBe('pkg-a@v1.1.0');
expect(calls[1]?.[0]).toBe('pkg-b@v2.0.0');
expect(runGitPushStage).not.toHaveBeenCalled();
expect(runGitPushStage).toHaveBeenCalledTimes(1);
expect(vi.mocked(runGitPushStage).mock.invocationCallOrder[0]).toBeGreaterThan(
vi.mocked(pushPackageTag).mock.invocationCallOrder[1] ?? Number.POSITIVE_INFINITY,
);
});

it('should still push and create GitHub releases when registry publishing is skipped', async () => {
const { runGitPushStage, pushPackageTag } = await import('../../src/stages/git-push.js');
const { runGithubReleaseStage } = await import('../../src/stages/github-release.js');
vi.mocked(runGitPushStage).mockImplementation(async (ctx) => {
ctx.output.git.pushed = true;
});

await runPipeline(perPackageInput, getDefaultConfig(), { ...defaultOptions, skipPublish: true });

expect(pushPackageTag).not.toHaveBeenCalled();
expect(runGitPushStage).toHaveBeenCalledTimes(1);
expect(runGithubReleaseStage).toHaveBeenCalledTimes(1);
});

it('should call npm-publish once per update with only that update in ctx.input', async () => {
Expand Down Expand Up @@ -406,7 +423,7 @@ describe('pipeline', () => {
});

it('should stop at the failing package and leave subsequent tags unpushed', async () => {
const { pushPackageTag } = await import('../../src/stages/git-push.js');
const { pushPackageTag, runGitPushStage } = await import('../../src/stages/git-push.js');
const { runNpmPublishStage } = await import('../../src/stages/npm-publish.js');

let callCount = 0;
Expand All @@ -432,6 +449,7 @@ describe('pipeline', () => {
expect(pushPackageTag).toHaveBeenCalledTimes(1);
const callsB = vi.mocked(pushPackageTag).mock.calls;
expect(callsB[0]?.[0]).toBe('pkg-a@v1.1.0');
expect(runGitPushStage).not.toHaveBeenCalled();
});

it('should use batch mode when no updates have a tag (sync mode with shared tag)', async () => {
Expand Down
31 changes: 26 additions & 5 deletions packages/publish/test/unit/stages/git-push.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,32 @@ describe('git-push stage', () => {
// branch push then tags push, both to the plain remote name.
expect(fakeGit.pushed).toEqual([
expect.objectContaining({ remote: 'origin', ref: 'main' }),
expect.objectContaining({ remote: 'origin', tags: true }),
expect.objectContaining({ remote: 'origin', ref: 'refs/tags/v1.0.0' }),
]);
expect(ctx.output.git.pushed).toBe(true);
});

it("should push only this release's tags, each once", async () => {
const ctx = createContext({
output: {
dryRun: false,
git: { committed: false, tags: ['pkg-a@v1.1.0', 'release/pkg-a@v1.1.0', 'pkg-a@v1.1.0'], pushed: false },
npm: [],
cargo: [],
verification: [],
githubReleases: [],
},
});

await runGitPushStage(ctx);

expect(fakeGit.pushed).toEqual([
expect.objectContaining({ ref: 'refs/tags/pkg-a@v1.1.0' }),
expect.objectContaining({ ref: 'refs/tags/release/pkg-a@v1.1.0' }),
]);
expect(fakeGit.pushed.some((push) => push.tags)).toBe(false);
});

it('should push using authed GitHub URL when httpsTokenEnv is configured', async () => {
process.env.GITHUB_TOKEN = 'gh_test_token';
seedGit({ remoteUrls: { origin: 'https://github.com/org/repo.git' } });
Expand All @@ -98,7 +119,7 @@ describe('git-push stage', () => {
const authedUrl = 'https://x-access-token:gh_test_token@github.com/org/repo.git';
// Both pushes target the authed URL as the remote.
expect(fakeGit.pushed[0]).toEqual(expect.objectContaining({ remote: authedUrl, ref: 'main' }));
expect(fakeGit.pushed[1]).toEqual(expect.objectContaining({ remote: authedUrl, tags: true }));
expect(fakeGit.pushed[1]).toEqual(expect.objectContaining({ remote: authedUrl, ref: 'refs/tags/v1.0.0' }));
});

it('should not attempt token auth when httpsTokenEnv is not configured', async () => {
Expand All @@ -110,7 +131,7 @@ describe('git-push stage', () => {
// No remoteUrl read happened (no authed rewrite); pushes target the plain remote name.
expect(fakeGit.pushed).toEqual([
expect.objectContaining({ remote: 'origin', ref: 'main' }),
expect.objectContaining({ remote: 'origin', tags: true }),
expect.objectContaining({ remote: 'origin', ref: 'refs/tags/v1.0.0' }),
]);
});

Expand All @@ -125,7 +146,7 @@ describe('git-push stage', () => {

expect(fakeGit.pushed).toEqual([
expect.objectContaining({ remote: 'origin', ref: 'main' }),
expect.objectContaining({ remote: 'origin', tags: true }),
expect.objectContaining({ remote: 'origin', ref: 'refs/tags/v1.0.0' }),
]);

delete process.env.MY_TOKEN;
Expand Down Expand Up @@ -222,7 +243,7 @@ describe('git-push stage', () => {

await runGitPushStage(ctx);

expect(fakeGit.pushed).toEqual([expect.objectContaining({ remote: 'origin', tags: true })]);
expect(fakeGit.pushed).toEqual([expect.objectContaining({ remote: 'origin', ref: 'refs/tags/v1.0.0' })]);
expect(currentBranchSpy).not.toHaveBeenCalled();
});

Expand Down
89 changes: 89 additions & 0 deletions packages/publish/test/unit/stages/input.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type {
VersionChangelogEntry,
VersionOutput,
VersionPackageChangelog,
VersionPackageUpdate,
} from '@releasekit/core';
import { afterEach, describe, expect, it } from 'vitest';
import { PublishErrorCode } from '../../../src/errors/index.js';
import { parseInput } from '../../../src/stages/input.js';
Expand All @@ -21,6 +27,12 @@ describe('input stage', () => {
tmpDirs.length = 0;
});

function writeInput(input: unknown): string {
const filePath = path.join(createTmpDir(), 'version-output.json');
fs.writeFileSync(filePath, JSON.stringify(input));
return filePath;
}

describe('parseInput from file', () => {
it('should parse valid version output from file', async () => {
const fixturePath = path.resolve(__dirname, '../../fixtures/version-output.json');
Expand Down Expand Up @@ -80,5 +92,82 @@ describe('input stage', () => {

expect(result.updates).toHaveLength(0);
});

it('should keep per-package tags and baseline tags', async () => {
const result = await parseInput(
writeInput({
dryRun: false,
updates: [
{ packageName: 'pkg-a', newVersion: '1.1.0', filePath: 'packages/a/package.json', tag: 'pkg-a@v1.1.0' },
{ packageName: 'pkg-b', newVersion: '2.0.0', filePath: 'packages/b/package.json', tag: 'pkg-b@v2.0.0' },
],
changelogs: [],
tags: ['pkg-a@v1.1.0', 'pkg-b@v2.0.0'],
baselineTags: ['release/pkg-a@v1.1.0', 'release/pkg-b@v2.0.0'],
}),
);

expect(result.updates.map((u) => u.tag)).toEqual(['pkg-a@v1.1.0', 'pkg-b@v2.0.0']);
expect(result.baselineTags).toEqual(['release/pkg-a@v1.1.0', 'release/pkg-b@v2.0.0']);
});

it('should reject a non-string per-package tag', async () => {
const filePath = writeInput({
dryRun: false,
updates: [{ packageName: 'pkg-a', newVersion: '1.1.0', filePath: 'packages/a/package.json', tag: 42 }],
changelogs: [],
tags: [],
});

await expect(parseInput(filePath)).rejects.toHaveProperty('code', PublishErrorCode.INPUT_VALIDATION_ERROR);
});

it('should round-trip every VersionOutput field unchanged', async () => {
// Required<> makes a new VersionOutput field a type error here until the fixture carries it.
const entry: Required<VersionChangelogEntry> = {
type: 'feat',
description: 'Add per-package tags',
issueIds: ['12', '34'],
prNumber: '34',
scope: 'publish',
originalType: 'feature',
breaking: true,
synthetic: false,
};
const update: Required<VersionPackageUpdate> = {
packageName: '@scope/a',
newVersion: '2.0.0',
previousVersion: '1.4.0',
filePath: 'packages/a/package.json',
tag: '@scope/a@v2.0.0',
isRoot: false,
group: 'core',
role: 'prerequisite',
prerequisiteOf: ['@scope/b'],
action: 'bumped',
actionReason: 'Bumped by commits.',
channel: 'stable',
};
const changelog: Required<VersionPackageChangelog> = {
packageName: '@scope/a',
version: '2.0.0',
previousVersion: '1.4.0',
revisionRange: '@scope/a@v1.4.0..HEAD',
repoUrl: 'https://github.com/example/repo',
entries: [entry],
};
const output: Required<VersionOutput> = {
dryRun: false,
strategy: 'group',
updates: [update],
changelogs: [changelog],
sharedEntries: [{ ...entry, scope: 'ci' }],
commitMessage: 'chore: release @scope/a@v2.0.0 [skip ci]',
tags: ['@scope/a@v2.0.0'],
baselineTags: ['release/@scope/a@v2.0.0'],
};

await expect(parseInput(writeInput(output))).resolves.toStrictEqual(output);
});
});
});
Loading