From fb9e5237212b865fe7a7e08c51bd77b845612ca1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 15:21:10 +0100 Subject: [PATCH 1/4] fix(publish): resume a partial publish by reusing the release commit Re-running `publish` on the same tree after a failure past the commit stage failed at `git commit` (nothing to commit). The commit stage now reuses HEAD when nothing is staged and HEAD's subject matches the release commit message, and still throws GIT_COMMIT_ERROR otherwise. A reused commit leaves `git.committed` false, so `changed` reports only what the re-run did. The push stages still push the branch for a reused commit. Adds `hasStagedChanges` to the `@releasekit/git` seam. Closes #707 Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 2 +- packages/git/src/fake.ts | 8 ++ packages/git/src/git.ts | 12 +++ packages/git/src/types.ts | 2 + packages/git/test/unit/fake.spec.ts | 6 ++ packages/git/test/unit/git.spec.ts | 18 ++++ packages/publish/src/stages/git-commit.ts | 59 ++++++++++--- packages/publish/src/stages/git-push.ts | 16 ++-- packages/publish/src/types.ts | 2 + .../test/unit/stages/git-commit.spec.ts | 68 +++++++++++++++ .../publish/test/unit/stages/git-push.spec.ts | 48 ++++++++++- test/integration/resume-publish.spec.ts | 85 +++++++++++++++++++ 12 files changed, 304 insertions(+), 22 deletions(-) create mode 100644 test/integration/resume-publish.spec.ts diff --git a/docs/cli.md b/docs/cli.md index a54bda75..4fccb48d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,7 +57,7 @@ releasekit publish --input version.json A bare `VersionOutput` is still accepted. Feeding a failed stage's envelope forward fails as an input error (exit 3) whose message names that stage's code, rather than a schema error about missing fields. -When `publish` fails partway, its error envelope carries what already landed in `data`, reports `changed: true` if anything did, and names the failing stage in the error `message`. A re-run skips versions already on the registry, but not the release commit: the git-commit stage runs before the registry publishes and finds nothing to commit, so re-running `publish` on the same tree fails there. Resuming a partial `publish` isn't supported yet. +When `publish` fails partway, its error envelope carries what already landed in `data`, reports `changed: true` if anything did, and names the failing stage in the error `message`. Re-running `publish` on the same tree resumes it: it reuses the release commit and tags already at HEAD, skips versions already on the registry, and retries the rest. ### Error codes and exit codes diff --git a/packages/git/src/fake.ts b/packages/git/src/fake.ts index 07abc4e8..62cd24c3 100644 --- a/packages/git/src/fake.ts +++ b/packages/git/src/fake.ts @@ -74,6 +74,8 @@ export interface FakeGitSeed { commitCounts?: Record; /** Working-tree status text returned by `status`. Defaults to empty (clean). */ status?: string; + /** Whether `hasStagedChanges` reports staged changes. Defaults to true, as after adding changed files. */ + stagedChanges?: boolean; } function ancestryHas(ancestors: FakeGitSeed['ancestors'], ancestor: string, ref: string): boolean { @@ -107,6 +109,7 @@ export class FakeGit implements Git { private readonly refLines: string[]; private readonly commitCounts: Record; private statusText: string; + private readonly stagedChanges: boolean; // Recorded mutations, for assertions. readonly added: string[][] = []; @@ -136,6 +139,7 @@ export class FakeGit implements Git { this.refLines = [...(seed.refLines ?? [])]; this.commitCounts = seed.commitCounts ?? {}; this.statusText = seed.status ?? ''; + this.stagedChanges = seed.stagedChanges ?? true; } // — Queries — @@ -207,6 +211,10 @@ export class FakeGit implements Git { return (this.remoteBranches[remote] ?? []).includes(branch); } + async hasStagedChanges(_cwd?: string): Promise { + return this.stagedChanges; + } + // — Mutations — async addAll(_cwd?: string): Promise { diff --git a/packages/git/src/git.ts b/packages/git/src/git.ts index 03d287a4..b47102b5 100644 --- a/packages/git/src/git.ts +++ b/packages/git/src/git.ts @@ -283,6 +283,18 @@ export class GitCli implements Git { }); } + async hasStagedChanges(cwd?: string): Promise { + try { + await this.exec(['diff', '--cached', '--quiet'], { cwd: this.cwd(cwd), ignoreOutput: true }); + return false; + } catch (error) { + if (error instanceof GitError && error.exitCode === 1) { + return true; + } + throw error; + } + } + // — Mutations — async addAll(cwd?: string): Promise { diff --git a/packages/git/src/types.ts b/packages/git/src/types.ts index 208951e2..ae0aef99 100644 --- a/packages/git/src/types.ts +++ b/packages/git/src/types.ts @@ -146,6 +146,8 @@ export interface Git { forEachRef(opts: GitForEachRefOptions): Promise; /** Whether `branch` exists on `remote` (`ls-remote --exit-code --heads`). */ remoteBranchExists(remote: string, branch: string, cwd?: string): Promise; + /** Whether the index differs from `HEAD` (`diff --cached --quiet` exits 1). */ + hasStagedChanges(cwd?: string): Promise; // — Mutations — /** Stage the given pathspecs (`add -- `). To stage everything, use {@link Git.addAll}. */ diff --git a/packages/git/test/unit/fake.spec.ts b/packages/git/test/unit/fake.spec.ts index 027dd04b..b905caa9 100644 --- a/packages/git/test/unit/fake.spec.ts +++ b/packages/git/test/unit/fake.spec.ts @@ -11,6 +11,7 @@ describe('FakeGit queries', () => { remoteUrls: { origin: 'git@github.com:o/r.git' }, commitCounts: { 'v1..HEAD': 3 }, status: ' M a.ts\n', + stagedChanges: false, }); expect(await git.isRepository()).toBe(true); @@ -23,6 +24,7 @@ describe('FakeGit queries', () => { expect(await git.countCommits('v1..HEAD')).toBe(3); expect(await git.countCommits('absent..HEAD')).toBe(0); expect(await git.status({ porcelain: true })).toBe(' M a.ts\n'); + expect(await git.hasStagedChanges()).toBe(false); }); it('should default to a repo with main/zeroed-sha and empty/null reads when unseeded', async () => { @@ -35,6 +37,10 @@ describe('FakeGit queries', () => { expect(await git.status()).toBe(''); }); + it('should report staged changes unless seeded otherwise', async () => { + expect(await createFakeGit().hasStagedChanges()).toBe(true); + }); + it('should honour an isRepo: false seed', async () => { expect(await createFakeGit({ isRepo: false }).isRepository()).toBe(false); }); diff --git a/packages/git/test/unit/git.spec.ts b/packages/git/test/unit/git.spec.ts index c44da4dd..0130e759 100644 --- a/packages/git/test/unit/git.spec.ts +++ b/packages/git/test/unit/git.spec.ts @@ -216,6 +216,24 @@ describe('GitCli queries', () => { const no = makeRunner({ reject: exitError(2) }); expect(await new GitCli(no.run).remoteBranchExists('origin', 'absent')).toBe(false); }); + + it('should answer hasStagedChanges via diff --cached --quiet exit code', async () => { + const clean = makeRunner(); + expect(await new GitCli(clean.run).hasStagedChanges('/r')).toBe(false); + expect(clean.calls[0].args).toEqual(['diff', '--cached', '--quiet']); + expect(clean.calls[0].options).toEqual({ cwd: '/r', ignoreOutput: true }); + + const staged = makeRunner({ reject: exitError(1) }); + expect(await new GitCli(staged.run).hasStagedChanges()).toBe(true); + }); + + it('should throw from hasStagedChanges when git fails rather than report staged changes', async () => { + const failed = makeRunner({ reject: exitError(128, 'not a git repository') }); + await expect(new GitCli(failed.run).hasStagedChanges()).rejects.toBeInstanceOf(GitError); + + const missing = makeRunner({ reject: enoent() }); + await expect(new GitCli(missing.run).hasStagedChanges()).rejects.toBeInstanceOf(GitError); + }); }); describe('GitCli mutations', () => { diff --git a/packages/publish/src/stages/git-commit.ts b/packages/publish/src/stages/git-commit.ts index 58e09c78..7b218f12 100644 --- a/packages/publish/src/stages/git-commit.ts +++ b/packages/publish/src/stages/git-commit.ts @@ -1,10 +1,35 @@ import * as path from 'node:path'; import { info, success } from '@releasekit/core'; -import { createGitCli } from '@releasekit/git'; +import { createGitCli, type Git } from '@releasekit/git'; import { createPublishError, PublishErrorCode } from '../errors/index.js'; import type { PipelineContext } from '../types.js'; import { runGit } from '../utils/git.js'; +async function hasStagedChanges(git: Git, cwd: string): Promise { + try { + return await git.hasStagedChanges(cwd); + } catch (error) { + throw createPublishError( + PublishErrorCode.GIT_COMMIT_ERROR, + `git diff --cached failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function assertHeadIsReleaseCommit(git: Git, commitMessage: string, cwd: string): Promise { + const subject = commitMessage.split('\n')[0]?.trim(); + const headSubject = await git + .log({ range: 'HEAD', format: '%s', extraArgs: ['-1'], cwd }) + .then((out) => out.trim()) + .catch(() => ''); + if (headSubject !== subject) { + throw createPublishError( + PublishErrorCode.GIT_COMMIT_ERROR, + `Nothing to commit, and HEAD ("${headSubject}") is not the release commit ("${subject}")`, + ); + } +} + /** Error strategy: THROWS. Git is a prerequisite — failure halts pipeline. */ export async function runGitCommitStage(ctx: PipelineContext): Promise { const { input, config, cliOptions, cwd } = ctx; @@ -39,20 +64,26 @@ export async function runGitCommitStage(ctx: PipelineContext): Promise { ); } - // Create commit - try { - await runGit(dryRun, `git commit -m "${input.commitMessage}"`, () => - git.commit(input.commitMessage as string, { cwd, skipHooks }), - ); - ctx.output.git.committed = true; - if (!dryRun) { - success('Created git commit'); + // A dry run skipped the add, so the index can't say whether there is anything to commit. + if (dryRun || (await hasStagedChanges(git, cwd))) { + try { + await runGit(dryRun, `git commit -m "${input.commitMessage}"`, () => + git.commit(input.commitMessage as string, { cwd, skipHooks }), + ); + ctx.output.git.committed = true; + if (!dryRun) { + success('Created git commit'); + } + } catch (error) { + throw createPublishError( + PublishErrorCode.GIT_COMMIT_ERROR, + `git commit failed: ${error instanceof Error ? error.message : String(error)}`, + ); } - } catch (error) { - throw createPublishError( - PublishErrorCode.GIT_COMMIT_ERROR, - `git commit failed: ${error instanceof Error ? error.message : String(error)}`, - ); + } else { + await assertHeadIsReleaseCommit(git, input.commitMessage, cwd); + ctx.releaseCommitReused = true; + info('Nothing to commit: HEAD is already the release commit, reusing it'); } } diff --git a/packages/publish/src/stages/git-push.ts b/packages/publish/src/stages/git-push.ts index a82ebbde..6c573aaa 100644 --- a/packages/publish/src/stages/git-push.ts +++ b/packages/publish/src/stages/git-push.ts @@ -26,6 +26,10 @@ function toGithubAuthedUrl(remoteUrl: string, token: string): string | undefined } } +function pushesBranch(ctx: PipelineContext): boolean { + return ctx.output.git.committed || ctx.releaseCommitReused === true; +} + export interface PushSetup { remote: string; pushRemote: string; @@ -37,7 +41,7 @@ export interface PushSetup { * Reuse this for all per-package pushes. */ export async function preparePushSetup(ctx: PipelineContext): Promise { - const { config, cwd, output } = ctx; + const { config, cwd } = ctx; if (!config.git.push) { return null; @@ -67,7 +71,7 @@ export async function preparePushSetup(ctx: PipelineContext): Promise git.push({ remote: pushRemote, ref: branch, cwd })); } @@ -142,7 +146,7 @@ export async function runGitPushStage(ctx: PipelineContext): Promise { return; } - if (!output.git.committed && output.git.tags.length === 0) { + if (!pushesBranch(ctx) && output.git.tags.length === 0) { info('Nothing to push (no commits or tags created)'); return; } @@ -176,7 +180,7 @@ export async function runGitPushStage(ctx: PipelineContext): Promise { } } - // Push the branch first, then tags. When this runner created the release commit (direct mode), + // Push the branch first, then tags. When this runner created or reused the release commit (direct mode), // the branch must land before the tags so a tag never points at a commit absent from the branch // — and a branch rejected by a concurrent push / ruleset aborts before any tag is pushed, rather // than leaving orphan tags on the remote with `git.pushed` false (no GitHub release). Standing-PR @@ -184,7 +188,7 @@ export async function runGitPushStage(ctx: PipelineContext): Promise { // only. Branch resolution is deferred to here so a tags-only push never triggers the detached-HEAD // guard unnecessarily. let branch: string | undefined; - if (output.git.committed) { + if (pushesBranch(ctx)) { branch = config.git.branch; if (!branch) { branch = await git.currentBranch(cwd); diff --git a/packages/publish/src/types.ts b/packages/publish/src/types.ts index add8ef5c..80e31a78 100644 --- a/packages/publish/src/types.ts +++ b/packages/publish/src/types.ts @@ -150,6 +150,8 @@ export interface PipelineContext { releaseNotes?: Record; /** Additional files to stage in the git commit (e.g., changelog files from the notes step). */ additionalFiles?: string[]; + /** HEAD was already the release commit, so the branch is pushed though `output.git.committed` stays false. */ + releaseCommitReused?: boolean; } export function getDefaultConfig(): PublishConfig { diff --git a/packages/publish/test/unit/stages/git-commit.spec.ts b/packages/publish/test/unit/stages/git-commit.spec.ts index ed7b3ec0..f12ad268 100644 --- a/packages/publish/test/unit/stages/git-commit.spec.ts +++ b/packages/publish/test/unit/stages/git-commit.spec.ts @@ -1,6 +1,7 @@ import { createFakeGit, type FakeGit, type FakeGitSeed } from '@releasekit/git'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getDefaultConfig } from '../../../src/config.js'; +import { publishDidChange } from '../../../src/output.js'; import { runGitCommitStage } from '../../../src/stages/git-commit.js'; import type { PipelineContext } from '../../../src/types.js'; @@ -49,8 +50,10 @@ function createContext(overrides?: Partial): PipelineContext { git: { committed: false, tags: [], pushed: false }, npm: [], cargo: [], + pub: [], verification: [], githubReleases: [], + publishSucceeded: false, }, ...overrides, }; @@ -210,6 +213,71 @@ describe('git-commit stage', () => { }); }); + describe('release commit already at HEAD', () => { + it('should reuse the release commit and its tag on a re-run', async () => { + seedGit({ + stagedChanges: false, + existingRefs: ['refs/tags/foo@v1.0.0'], + headSha: 'abc123', + commits: { HEAD: 'chore: release foo@1.0.0\n', 'foo@v1.0.0': 'abc123\n' }, + }); + const ctx = createContext(); + + await runGitCommitStage(ctx); + + expect(fakeGit.committed).toHaveLength(0); + expect(fakeGit.tagged).toHaveLength(0); + expect(ctx.releaseCommitReused).toBe(true); + expect(ctx.output.git.tags).toEqual(['foo@v1.0.0']); + expect(ctx.output.git.committed).toBe(false); + expect(publishDidChange(ctx.output)).toBe(false); + }); + + it('should create the tag on the reused commit when the tag is missing', async () => { + seedGit({ stagedChanges: false, commits: { HEAD: 'chore: release foo@1.0.0\n' } }); + const ctx = createContext(); + + await runGitCommitStage(ctx); + + expect(fakeGit.committed).toHaveLength(0); + expect(fakeGit.tagged).toEqual([{ name: 'foo@v1.0.0', message: 'Release foo@v1.0.0' }]); + expect(ctx.releaseCommitReused).toBe(true); + }); + + it('should match a multi-line commit message on its subject line', async () => { + seedGit({ stagedChanges: false, commits: { HEAD: 'chore: release foo@1.0.0\n' } }); + const ctx = createContext(); + ctx.input.commitMessage = 'chore: release foo@1.0.0\n\n[skip ci]'; + + await runGitCommitStage(ctx); + + expect(ctx.releaseCommitReused).toBe(true); + }); + + it('should throw GIT_COMMIT_ERROR when nothing is staged and HEAD is not the release commit', async () => { + seedGit({ stagedChanges: false, commits: { HEAD: 'feat: unrelated\n' } }); + const ctx = createContext(); + + await expect(runGitCommitStage(ctx)).rejects.toMatchObject({ + code: 'GIT_COMMIT_ERROR', + message: expect.stringContaining('HEAD ("feat: unrelated") is not the release commit'), + }); + expect(fakeGit.committed).toHaveLength(0); + expect(fakeGit.tagged).toHaveLength(0); + expect(ctx.releaseCommitReused).toBeUndefined(); + }); + + it('should report a would-be commit in a dry run without consulting the index', async () => { + seedGit({ stagedChanges: false }); + const ctx = createContext({ cliOptions: { ...createContext().cliOptions, dryRun: true } }); + + await runGitCommitStage(ctx); + + expect(ctx.output.git.committed).toBe(true); + expect(ctx.releaseCommitReused).toBeUndefined(); + }); + }); + describe('skipGitCommit', () => { it('should skip git add and commit when skipGitCommit is true', async () => { const ctx = createContext({ diff --git a/packages/publish/test/unit/stages/git-push.spec.ts b/packages/publish/test/unit/stages/git-push.spec.ts index 5e92e668..c494993b 100644 --- a/packages/publish/test/unit/stages/git-push.spec.ts +++ b/packages/publish/test/unit/stages/git-push.spec.ts @@ -5,7 +5,7 @@ import { createFakeGit, type FakeGit, type FakeGitSeed } from '@releasekit/git'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getDefaultConfig, loadConfig } from '../../../src/config.js'; import { PublishError } from '../../../src/errors/index.js'; -import { pushPackageTag, runGitPushStage } from '../../../src/stages/git-push.js'; +import { preparePushSetup, pushPackageTag, runGitPushStage } from '../../../src/stages/git-push.js'; import type { PipelineContext } from '../../../src/types.js'; // Drive the stage through a seeded FakeGit. `createGitCli()` resolves to the fake we seed per test, @@ -269,6 +269,28 @@ describe('git-push stage', () => { expect(currentBranchSpy).not.toHaveBeenCalled(); }); + it('should push the branch and tags for a reused release commit', async () => { + const ctx = createContext({ + releaseCommitReused: true, + output: { + dryRun: false, + git: { committed: false, tags: ['v1.0.0'], pushed: false }, + npm: [], + cargo: [], + verification: [], + githubReleases: [], + }, + }); + + await runGitPushStage(ctx); + + expect(fakeGit.pushed).toEqual([ + expect.objectContaining({ remote: 'origin', ref: 'main' }), + expect.objectContaining({ remote: 'origin', ref: 'refs/tags/v1.0.0' }), + ]); + expect(ctx.output.git.pushed).toBe(true); + }); + it('should not throw when in detached HEAD state and there are only tags to push', async () => { seedGit({ currentBranch: 'HEAD' }); const ctx = createContext({ @@ -372,6 +394,30 @@ describe('pushPackageTag', () => { expect(ctx.output.git.pushed).toBe(true); }); + it('should push the branch after the tag for a reused release commit', async () => { + const ctx = createContext({ + releaseCommitReused: true, + output: { + dryRun: false, + git: { committed: false, tags: [], pushed: false }, + npm: [], + cargo: [], + verification: [], + githubReleases: [], + }, + }); + + const setup = await preparePushSetup(ctx); + expect(setup?.branch).toBe('main'); + + 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' }), + ]); + }); + it('should accumulate pushed tags on ctx.output.git.tags', async () => { const ctx = createContext({ output: { diff --git a/test/integration/resume-publish.spec.ts b/test/integration/resume-publish.spec.ts new file mode 100644 index 00000000..64e6ebbe --- /dev/null +++ b/test/integration/resume-publish.spec.ts @@ -0,0 +1,85 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { devNull, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const RELEASEKIT = fileURLToPath(new URL('../../packages/release/dist/dispatcher.js', import.meta.url)); + +// Ignore the developer's git config (signing, hooks) and any CI GitHub context. +const env = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GITHUB_'))), + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_NOSYSTEM: '1', +}; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', env }).trim(); +} + +const manifest = (version: string): string => `{ "name": "rk-resume", "version": "${version}" }\n`; + +describe('resuming a partial publish', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'rk-resume-publish-')); + }); + + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('should reuse the release commit and push the branch and tag after a failed push', () => { + const repo = join(dir, 'repo'); + const remote = join(dir, 'remote.git'); + const input = join(dir, 'version.json'); + const result = join(dir, 'result.json'); + + mkdirSync(repo); + writeFileSync(join(repo, 'package.json'), manifest('0.1.0')); + writeFileSync( + join(repo, 'releasekit.config.json'), + JSON.stringify({ publish: { npm: { enabled: false }, githubRelease: { enabled: false } } }), + ); + git(repo, 'init', '-q', '-b', 'main'); + git(repo, 'config', 'user.name', 'test'); + git(repo, 'config', 'user.email', 'test@example.com'); + git(repo, 'add', '-A'); + git(repo, 'commit', '-qm', 'chore: init'); + git(dir, 'init', '-q', '--bare', remote); + git(repo, 'remote', 'add', 'origin', join(dir, 'missing.git')); + + writeFileSync(join(repo, 'package.json'), manifest('0.2.0')); + writeFileSync( + input, + JSON.stringify({ + dryRun: false, + updates: [{ packageName: 'rk-resume', newVersion: '0.2.0', filePath: 'package.json' }], + changelogs: [], + commitMessage: 'chore: release rk-resume v0.2.0', + tags: ['v0.2.0'], + }), + ); + + const publish = () => + spawnSync(process.execPath, [RELEASEKIT, 'publish', '--input', input, '--output', result], { + cwd: repo, + encoding: 'utf-8', + env, + }); + + const failed = publish(); + expect(failed.status, `${failed.stdout}\n${failed.stderr}`).toBe(9); + expect(JSON.parse(readFileSync(result, 'utf-8'))).toMatchObject({ status: 'error', changed: true }); + + git(repo, 'remote', 'set-url', 'origin', remote); + const resumed = publish(); + expect(resumed.status, `${resumed.stdout}\n${resumed.stderr}`).toBe(0); + expect(JSON.parse(readFileSync(result, 'utf-8'))).toMatchObject({ status: 'success', changed: false }); + + expect(git(repo, 'log', '--format=%s')).toBe('chore: release rk-resume v0.2.0\nchore: init'); + const head = git(repo, 'rev-parse', 'HEAD'); + expect(git(remote, 'rev-parse', 'refs/heads/main')).toBe(head); + expect(git(remote, 'rev-parse', 'v0.2.0^{commit}')).toBe(head); + }); +}); From 59ccb825c875815a540d15a1193b821b41bd9812 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 15:27:26 +0100 Subject: [PATCH 2/4] fix(publish): compare the release commit by git's subject rules Git's `%s` joins the first paragraph's lines with spaces, so a release message with a second line directly under the subject never matched its own first line and couldn't resume. Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/stages/git-commit.ts | 10 +++++++++- packages/publish/test/unit/stages/git-commit.spec.ts | 9 ++++++--- test/integration/resume-publish.spec.ts | 9 ++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/publish/src/stages/git-commit.ts b/packages/publish/src/stages/git-commit.ts index 7b218f12..1df5edcf 100644 --- a/packages/publish/src/stages/git-commit.ts +++ b/packages/publish/src/stages/git-commit.ts @@ -16,8 +16,16 @@ async function hasStagedChanges(git: Git, cwd: string): Promise { } } +/** Git's `%s`: the first paragraph, each line's trailing whitespace dropped, joined with spaces. */ +function subjectOf(message: string): string { + const lines = message.split('\n').map((line) => line.trimEnd()); + const paragraph = lines.slice(Math.max(lines.findIndex((line) => line !== ''), 0)); + const end = paragraph.indexOf(''); + return (end === -1 ? paragraph : paragraph.slice(0, end)).join(' ').trim(); +} + async function assertHeadIsReleaseCommit(git: Git, commitMessage: string, cwd: string): Promise { - const subject = commitMessage.split('\n')[0]?.trim(); + const subject = subjectOf(commitMessage); const headSubject = await git .log({ range: 'HEAD', format: '%s', extraArgs: ['-1'], cwd }) .then((out) => out.trim()) diff --git a/packages/publish/test/unit/stages/git-commit.spec.ts b/packages/publish/test/unit/stages/git-commit.spec.ts index f12ad268..e3a174a5 100644 --- a/packages/publish/test/unit/stages/git-commit.spec.ts +++ b/packages/publish/test/unit/stages/git-commit.spec.ts @@ -244,10 +244,13 @@ describe('git-commit stage', () => { expect(ctx.releaseCommitReused).toBe(true); }); - it('should match a multi-line commit message on its subject line', async () => { - seedGit({ stagedChanges: false, commits: { HEAD: 'chore: release foo@1.0.0\n' } }); + it.each([ + ['a body after a blank line', 'chore: release foo@1.0.0\n\n[skip ci]', 'chore: release foo@1.0.0'], + ['a second subject line', 'chore: release foo@1.0.0\n[skip ci]', 'chore: release foo@1.0.0 [skip ci]'], + ])("should match a message with %s on git's subject", async (_label, message, subject) => { + seedGit({ stagedChanges: false, commits: { HEAD: `${subject}\n` } }); const ctx = createContext(); - ctx.input.commitMessage = 'chore: release foo@1.0.0\n\n[skip ci]'; + ctx.input.commitMessage = message; await runGitCommitStage(ctx); diff --git a/test/integration/resume-publish.spec.ts b/test/integration/resume-publish.spec.ts index 64e6ebbe..8e428af4 100644 --- a/test/integration/resume-publish.spec.ts +++ b/test/integration/resume-publish.spec.ts @@ -29,7 +29,10 @@ describe('resuming a partial publish', () => { afterEach(() => rmSync(dir, { recursive: true, force: true })); - it('should reuse the release commit and push the branch and tag after a failed push', () => { + it.each([ + ['a one-line release message', 'chore: release rk-resume v0.2.0'], + ['a release message with a second line', 'chore: release rk-resume v0.2.0\n[skip ci]'], + ])('should reuse the release commit and push the branch and tag after a failed push, with %s', (_label, commitMessage) => { const repo = join(dir, 'repo'); const remote = join(dir, 'remote.git'); const input = join(dir, 'version.json'); @@ -56,7 +59,7 @@ describe('resuming a partial publish', () => { dryRun: false, updates: [{ packageName: 'rk-resume', newVersion: '0.2.0', filePath: 'package.json' }], changelogs: [], - commitMessage: 'chore: release rk-resume v0.2.0', + commitMessage, tags: ['v0.2.0'], }), ); @@ -77,7 +80,7 @@ describe('resuming a partial publish', () => { expect(resumed.status, `${resumed.stdout}\n${resumed.stderr}`).toBe(0); expect(JSON.parse(readFileSync(result, 'utf-8'))).toMatchObject({ status: 'success', changed: false }); - expect(git(repo, 'log', '--format=%s')).toBe('chore: release rk-resume v0.2.0\nchore: init'); + expect(git(repo, 'rev-list', '--count', 'HEAD')).toBe('2'); const head = git(repo, 'rev-parse', 'HEAD'); expect(git(remote, 'rev-parse', 'refs/heads/main')).toBe(head); expect(git(remote, 'rev-parse', 'v0.2.0^{commit}')).toBe(head); From 09308dfd493e31024a91a8ede5bc8c6ab2b56e11 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 15:35:09 +0100 Subject: [PATCH 3/4] style: format the resume test and simplify the subject helper Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/stages/git-commit.ts | 3 ++- test/integration/resume-publish.spec.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/publish/src/stages/git-commit.ts b/packages/publish/src/stages/git-commit.ts index 1df5edcf..6311760f 100644 --- a/packages/publish/src/stages/git-commit.ts +++ b/packages/publish/src/stages/git-commit.ts @@ -19,7 +19,8 @@ async function hasStagedChanges(git: Git, cwd: string): Promise { /** Git's `%s`: the first paragraph, each line's trailing whitespace dropped, joined with spaces. */ function subjectOf(message: string): string { const lines = message.split('\n').map((line) => line.trimEnd()); - const paragraph = lines.slice(Math.max(lines.findIndex((line) => line !== ''), 0)); + const start = lines.findIndex((line) => line !== ''); + const paragraph = start === -1 ? [] : lines.slice(start); const end = paragraph.indexOf(''); return (end === -1 ? paragraph : paragraph.slice(0, end)).join(' ').trim(); } diff --git a/test/integration/resume-publish.spec.ts b/test/integration/resume-publish.spec.ts index 8e428af4..5dc78b97 100644 --- a/test/integration/resume-publish.spec.ts +++ b/test/integration/resume-publish.spec.ts @@ -32,7 +32,7 @@ describe('resuming a partial publish', () => { it.each([ ['a one-line release message', 'chore: release rk-resume v0.2.0'], ['a release message with a second line', 'chore: release rk-resume v0.2.0\n[skip ci]'], - ])('should reuse the release commit and push the branch and tag after a failed push, with %s', (_label, commitMessage) => { + ])('should resume a publish that failed at the push, with %s', (_label, commitMessage) => { const repo = join(dir, 'repo'); const remote = join(dir, 'remote.git'); const input = join(dir, 'version.json'); From 7e7fe3ac75b5568b4a6388af7e89a44411bdec0e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 15:53:28 +0100 Subject: [PATCH 4/4] refactor: reword the resume comments for clarity Co-Authored-By: Claude Opus 5.5 --- packages/git/src/fake.ts | 2 +- packages/publish/src/stages/git-commit.ts | 4 ++-- packages/publish/src/types.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/git/src/fake.ts b/packages/git/src/fake.ts index 62cd24c3..a98986c2 100644 --- a/packages/git/src/fake.ts +++ b/packages/git/src/fake.ts @@ -74,7 +74,7 @@ export interface FakeGitSeed { commitCounts?: Record; /** Working-tree status text returned by `status`. Defaults to empty (clean). */ status?: string; - /** Whether `hasStagedChanges` reports staged changes. Defaults to true, as after adding changed files. */ + /** Whether `hasStagedChanges` reports staged changes. Defaults to true. */ stagedChanges?: boolean; } diff --git a/packages/publish/src/stages/git-commit.ts b/packages/publish/src/stages/git-commit.ts index 6311760f..da7167b3 100644 --- a/packages/publish/src/stages/git-commit.ts +++ b/packages/publish/src/stages/git-commit.ts @@ -16,7 +16,7 @@ async function hasStagedChanges(git: Git, cwd: string): Promise { } } -/** Git's `%s`: the first paragraph, each line's trailing whitespace dropped, joined with spaces. */ +/** The subject `git log --format=%s` shows for this message: its first paragraph, with the lines joined by spaces. */ function subjectOf(message: string): string { const lines = message.split('\n').map((line) => line.trimEnd()); const start = lines.findIndex((line) => line !== ''); @@ -73,7 +73,7 @@ export async function runGitCommitStage(ctx: PipelineContext): Promise { ); } - // A dry run skipped the add, so the index can't say whether there is anything to commit. + // A dry run stages nothing, so the index can't be checked; assume there's something to commit. if (dryRun || (await hasStagedChanges(git, cwd))) { try { await runGit(dryRun, `git commit -m "${input.commitMessage}"`, () => diff --git a/packages/publish/src/types.ts b/packages/publish/src/types.ts index 80e31a78..418fbc42 100644 --- a/packages/publish/src/types.ts +++ b/packages/publish/src/types.ts @@ -150,7 +150,7 @@ export interface PipelineContext { releaseNotes?: Record; /** Additional files to stage in the git commit (e.g., changelog files from the notes step). */ additionalFiles?: string[]; - /** HEAD was already the release commit, so the branch is pushed though `output.git.committed` stays false. */ + /** HEAD was already the release commit: nothing was committed this run, but the branch still needs pushing. */ releaseCommitReused?: boolean; }