diff --git a/packages/publish/src/pipeline/index.ts b/packages/publish/src/pipeline/index.ts index c55e6e8b..e72e8509 100644 --- a/packages/publish/src/pipeline/index.ts +++ b/packages/publish/src/pipeline/index.ts @@ -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); } diff --git a/packages/publish/src/stages/git-push.ts b/packages/publish/src/stages/git-push.ts index c782b3e5..a82ebbde 100644 --- a/packages/publish/src/stages/git-push.ts +++ b/packages/publish/src/stages/git-push.ts @@ -198,9 +198,10 @@ export async function runGitPushStage(ctx: PipelineContext): Promise { 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; diff --git a/packages/publish/src/stages/input.ts b/packages/publish/src/stages/input.ts index 80f925da..31a51ff7 100644 --- a/packages/publish/src/stages/input.ts +++ b/packages/publish/src/stages/input.ts @@ -3,7 +3,7 @@ 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(), @@ -11,7 +11,7 @@ const VersionChangelogEntrySchema = z.object({ originalType: z.string().optional(), }); -const VersionPackageChangelogSchema = z.object({ +const VersionPackageChangelogSchema = z.looseObject({ packageName: z.string(), version: z.string(), previousVersion: z.string().nullable(), @@ -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(), }); -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(), }); export async function parseInput(inputPath?: string): Promise { diff --git a/packages/publish/test/unit/pipeline.spec.ts b/packages/publish/test/unit/pipeline.spec.ts index 33358157..b1dbf84f 100644 --- a/packages/publish/test/unit/pipeline.spec.ts +++ b/packages/publish/test/unit/pipeline.spec.ts @@ -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'); @@ -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 () => { @@ -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; @@ -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 () => { diff --git a/packages/publish/test/unit/stages/git-push.spec.ts b/packages/publish/test/unit/stages/git-push.spec.ts index 9c529446..604129b2 100644 --- a/packages/publish/test/unit/stages/git-push.spec.ts +++ b/packages/publish/test/unit/stages/git-push.spec.ts @@ -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' } }); @@ -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 () => { @@ -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' }), ]); }); @@ -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; @@ -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(); }); diff --git a/packages/publish/test/unit/stages/input.spec.ts b/packages/publish/test/unit/stages/input.spec.ts index 59910320..6bfb3a0c 100644 --- a/packages/publish/test/unit/stages/input.spec.ts +++ b/packages/publish/test/unit/stages/input.spec.ts @@ -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'; @@ -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'); @@ -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 = { + type: 'feat', + description: 'Add per-package tags', + issueIds: ['12', '34'], + prNumber: '34', + scope: 'publish', + originalType: 'feature', + breaking: true, + synthetic: false, + }; + const update: Required = { + 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 = { + 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 = { + 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); + }); }); });