From 30ad59e005a9ddeae036eb99ffb32afec2e2970d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 22:37:53 +0100 Subject: [PATCH 1/5] fix(publish): keep per-package and baseline tags in --input The publish input schema used z.object, which strips undeclared keys, so `releasekit publish --input` dropped updates[].tag (per-package mode) and baselineTags (baseline tag creation), along with every other VersionOutput field the schema didn't list. Switch the schemas to z.looseObject, declare tag and baselineTags, and add a round-trip test over a fully populated VersionOutput so the schema can't fall behind the type again. Closes #675 Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/stages/input.ts | 11 ++- .../publish/test/unit/stages/input.spec.ts | 89 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/publish/src/stages/input.ts b/packages/publish/src/stages/input.ts index 80f925da..e569897a 100644 --- a/packages/publish/src/stages/input.ts +++ b/packages/publish/src/stages/input.ts @@ -3,7 +3,8 @@ import { info, type VersionOutput } from '@releasekit/core'; import { z } from 'zod'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; -const VersionChangelogEntrySchema = z.object({ +// Loose objects: z.object would strip VersionOutput fields not declared here before the pipeline sees them. +const VersionChangelogEntrySchema = z.looseObject({ type: z.string(), description: z.string(), issueIds: z.array(z.string()).optional(), @@ -11,7 +12,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 +21,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/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); + }); }); }); From 50f83f506a0dffea64385eea0fd0efc6cb9aa652 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 23:11:12 +0100 Subject: [PATCH 2/5] fix(publish): push baseline tags, and push at all under --skip-publish, in per-package mode Per-package mode pushed each package's own tag inside the publish loop and skipped the final push, so baseline tags never reached the remote, and with --skip-publish nothing was pushed and no GitHub releases were created. Now that publish --input keeps per-package tags, that path is reachable from the CLI too. Run the final push in per-package mode as well: after every package publishes it adds the remaining tags, and under --skip-publish it pushes everything as batch mode does. Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/pipeline/index.ts | 5 +++-- packages/publish/test/unit/pipeline.spec.ts | 24 ++++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/publish/src/pipeline/index.ts b/packages/publish/src/pipeline/index.ts index c55e6e8b..c3163602 100644 --- a/packages/publish/src/pipeline/index.ts +++ b/packages/publish/src/pipeline/index.ts @@ -170,8 +170,9 @@ 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. Per-package mode already pushed each package tag; this pushes what's left + // (baseline tags), or everything when registry publishing was skipped. + if (!options.skipGit && (options.skipPublish || ctx.output.publishSucceeded)) { await runGitPushStage(ctx); } 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 () => { From b8a7dadcc194044e168a76b2607ce5afb2c369b2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 23:20:04 +0100 Subject: [PATCH 3/5] fix(publish): push only this release's tags The push stage ran `git push --tags`, publishing every local tag. Push each tag the run recorded by explicit ref instead, so an unrelated or conflicting local tag can't be published or fail the push after packages are out. Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/stages/git-push.ts | 8 +++-- .../publish/test/unit/stages/git-push.spec.ts | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/publish/src/stages/git-push.ts b/packages/publish/src/stages/git-push.ts index c782b3e5..498fe28e 100644 --- a/packages/publish/src/stages/git-push.ts +++ b/packages/publish/src/stages/git-push.ts @@ -198,9 +198,11 @@ 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 })); + // Only this release's tags: `--tags` would also publish any unrelated local tag. + 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/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(); }); From 47962dfd016dc5493141373de7a457081bf9fa6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 23:20:39 +0100 Subject: [PATCH 4/5] fix(publish): drop redundant schema comment The round-trip test fails if the schemas go back to stripping fields. Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/stages/input.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/publish/src/stages/input.ts b/packages/publish/src/stages/input.ts index e569897a..31a51ff7 100644 --- a/packages/publish/src/stages/input.ts +++ b/packages/publish/src/stages/input.ts @@ -3,7 +3,6 @@ import { info, type VersionOutput } from '@releasekit/core'; import { z } from 'zod'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; -// Loose objects: z.object would strip VersionOutput fields not declared here before the pipeline sees them. const VersionChangelogEntrySchema = z.looseObject({ type: z.string(), description: z.string(), From 098076eea2a5602b3216b5444c2959dadcfe0383 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 23:21:03 +0100 Subject: [PATCH 5/5] fix(publish): trim push-stage comments Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/pipeline/index.ts | 3 +-- packages/publish/src/stages/git-push.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/publish/src/pipeline/index.ts b/packages/publish/src/pipeline/index.ts index c3163602..e72e8509 100644 --- a/packages/publish/src/pipeline/index.ts +++ b/packages/publish/src/pipeline/index.ts @@ -170,8 +170,7 @@ export async function runPipeline( await runVerifyStage(ctx); } - // Stage 7: Git push. Per-package mode already pushed each package tag; this pushes what's left - // (baseline tags), or everything when registry publishing was skipped. + // 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 498fe28e..a82ebbde 100644 --- a/packages/publish/src/stages/git-push.ts +++ b/packages/publish/src/stages/git-push.ts @@ -198,7 +198,6 @@ export async function runGitPushStage(ctx: PipelineContext): Promise { await runGit(dryRun, `git push ${remote} ${branch}`, () => git.push({ remote: pushRemote, ref: branch, cwd })); } - // Only this release's tags: `--tags` would also publish any unrelated local tag. 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 }),