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
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions packages/git/src/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export interface FakeGitSeed {
commitCounts?: Record<string, number>;
/** Working-tree status text returned by `status`. Defaults to empty (clean). */
status?: string;
/** Whether `hasStagedChanges` reports staged changes. Defaults to true. */
stagedChanges?: boolean;
}

function ancestryHas(ancestors: FakeGitSeed['ancestors'], ancestor: string, ref: string): boolean {
Expand Down Expand Up @@ -107,6 +109,7 @@ export class FakeGit implements Git {
private readonly refLines: string[];
private readonly commitCounts: Record<string, number>;
private statusText: string;
private readonly stagedChanges: boolean;

// Recorded mutations, for assertions.
readonly added: string[][] = [];
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -207,6 +211,10 @@ export class FakeGit implements Git {
return (this.remoteBranches[remote] ?? []).includes(branch);
}

async hasStagedChanges(_cwd?: string): Promise<boolean> {
return this.stagedChanges;
}

// — Mutations —

async addAll(_cwd?: string): Promise<void> {
Expand Down
12 changes: 12 additions & 0 deletions packages/git/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,18 @@ export class GitCli implements Git {
});
}

async hasStagedChanges(cwd?: string): Promise<boolean> {
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<void> {
Expand Down
2 changes: 2 additions & 0 deletions packages/git/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ export interface Git {
forEachRef(opts: GitForEachRefOptions): Promise<string[]>;
/** Whether `branch` exists on `remote` (`ls-remote --exit-code --heads`). */
remoteBranchExists(remote: string, branch: string, cwd?: string): Promise<boolean>;
/** Whether the index differs from `HEAD` (`diff --cached --quiet` exits 1). */
hasStagedChanges(cwd?: string): Promise<boolean>;

// — Mutations —
/** Stage the given pathspecs (`add -- <paths>`). To stage everything, use {@link Git.addAll}. */
Expand Down
6 changes: 6 additions & 0 deletions packages/git/test/unit/fake.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {
Expand All @@ -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);
});
Expand Down
18 changes: 18 additions & 0 deletions packages/git/test/unit/git.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
68 changes: 54 additions & 14 deletions packages/publish/src/stages/git-commit.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,44 @@
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<boolean> {
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)}`,
);
}
}

/** 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 !== '');
const paragraph = start === -1 ? [] : lines.slice(start);
const end = paragraph.indexOf('');
return (end === -1 ? paragraph : paragraph.slice(0, end)).join(' ').trim();
}

async function assertHeadIsReleaseCommit(git: Git, commitMessage: string, cwd: string): Promise<void> {
const subject = subjectOf(commitMessage);
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<void> {
const { input, config, cliOptions, cwd } = ctx;
Expand Down Expand Up @@ -39,20 +73,26 @@ export async function runGitCommitStage(ctx: PipelineContext): Promise<void> {
);
}

// 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 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}"`, () =>
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');
}
}

Expand Down
16 changes: 10 additions & 6 deletions packages/publish/src/stages/git-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,7 +41,7 @@ export interface PushSetup {
* Reuse this for all per-package pushes.
*/
export async function preparePushSetup(ctx: PipelineContext): Promise<PushSetup | null> {
const { config, cwd, output } = ctx;
const { config, cwd } = ctx;

if (!config.git.push) {
return null;
Expand Down Expand Up @@ -67,7 +71,7 @@ export async function preparePushSetup(ctx: PipelineContext): Promise<PushSetup
}

let branch: string | undefined;
if (output.git.committed) {
if (pushesBranch(ctx)) {
branch = config.git.branch;
if (!branch) {
branch = await git.currentBranch(cwd);
Expand Down Expand Up @@ -119,7 +123,7 @@ export async function pushPackageTag(tag: string, ctx: PipelineContext, setup?:
output.git.tags.push(tag);

// Push the branch (idempotent — no-op if remote is already up-to-date)
if (output.git.committed && branch) {
if (pushesBranch(ctx) && branch) {
await runGit(dryRun, `git push ${remote} ${branch}`, () => git.push({ remote: pushRemote, ref: branch, cwd }));
}

Expand All @@ -142,7 +146,7 @@ export async function runGitPushStage(ctx: PipelineContext): Promise<void> {
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;
}
Expand Down Expand Up @@ -176,15 +180,15 @@ export async function runGitPushStage(ctx: PipelineContext): Promise<void> {
}
}

// 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
// mode sets committed=false (the commit is already on the remote via the merge) and pushes tags
// 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);
Expand Down
2 changes: 2 additions & 0 deletions packages/publish/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export interface PipelineContext {
releaseNotes?: Record<string, string>;
/** Additional files to stage in the git commit (e.g., changelog files from the notes step). */
additionalFiles?: string[];
/** HEAD was already the release commit: nothing was committed this run, but the branch still needs pushing. */
releaseCommitReused?: boolean;
}

export function getDefaultConfig(): PublishConfig {
Expand Down
71 changes: 71 additions & 0 deletions packages/publish/test/unit/stages/git-commit.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -49,8 +50,10 @@ function createContext(overrides?: Partial<PipelineContext>): PipelineContext {
git: { committed: false, tags: [], pushed: false },
npm: [],
cargo: [],
pub: [],
verification: [],
githubReleases: [],
publishSucceeded: false,
},
...overrides,
};
Expand Down Expand Up @@ -210,6 +213,74 @@ 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.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 = message;

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({
Expand Down
Loading
Loading