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
8 changes: 8 additions & 0 deletions .bumpy/fix-tag-push-and-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@varlock/bumpy': patch
---

Fix git tag pushing and GitHub release creation

- Use `git push --tags` instead of `--follow-tags` so lightweight tags are actually pushed to the remote
- Pass `--target` commit SHA to `gh release create` as a fallback in case tags haven't propagated
6 changes: 5 additions & 1 deletion packages/bumpy/src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export function createTag(tag: string, opts?: { cwd?: string }): void {

/** Push commits and tags to remote */
export function pushWithTags(opts?: { cwd?: string }): void {
runArgs(['git', 'push', '--follow-tags'], opts);
// Use `--tags` instead of `--follow-tags` because:
// - `--follow-tags` only pushes *annotated* tags reachable from pushed commits
// - We create lightweight tags and may have no new commits to push
runArgs(['git', 'push'], opts);
runArgs(['git', 'push', '--tags'], opts);
}

/** Check if there are uncommitted changes */
Expand Down
40 changes: 33 additions & 7 deletions packages/bumpy/src/core/github-release.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { tryRunArgs, runArgsAsync } from '../utils/shell.ts';
import { log } from '../utils/logger.ts';
import { listTags } from './git.ts';
import type { PlannedRelease, Changeset } from '../types.ts';

/** Get the current HEAD commit SHA */
function getHeadSha(rootDir: string): string | null {
return tryRunArgs(['git', 'rev-parse', 'HEAD'], { cwd: rootDir });
}

export interface GitHubReleaseOptions {
dryRun?: boolean;
title?: string;
Expand All @@ -19,6 +25,8 @@ export async function createIndividualReleases(
return;
}

const headSha = getHeadSha(rootDir);

for (const release of releases) {
const tag = `${release.name}@${release.newVersion}`;
const body = buildReleaseBody(release, changesets);
Expand All @@ -30,7 +38,10 @@ export async function createIndividualReleases(
}

try {
await runArgsAsync(['gh', 'release', 'create', tag, '--title', title, '--notes', body], {
// Use --target so gh can create the tag on the remote if it wasn't pushed yet
const args = ['gh', 'release', 'create', tag, '--title', title, '--notes', body];
if (headSha) args.push('--target', headSha);
await runArgsAsync(args, {
cwd: rootDir,
});
log.dim(` Created GitHub release: ${title}`);
Expand All @@ -55,11 +66,8 @@ export async function createAggregateRelease(
if (releases.length === 0) return;

const date = new Date().toISOString().split('T')[0];
const titleTemplate = opts.title || 'Release {{date}}';
const title = titleTemplate.replace('{{date}}', date!);

// Use the first release's tag as the release tag, or create a date-based tag
const tag = `release-${date}`;
const existing = listTags(`release-${date}*`, { cwd: rootDir });
const { tag, title } = resolveAggregateTagAndTitle(date!, existing, opts.title);
const body = buildAggregateBody(releases, changesets);

if (opts.dryRun) {
Expand All @@ -72,7 +80,11 @@ export async function createAggregateRelease(
// Create the tag if it doesn't exist
tryRunArgs(['git', 'tag', tag], { cwd: rootDir });

await runArgsAsync(['gh', 'release', 'create', tag, '--title', title, '--notes', body], {
// Use --target so gh can create the tag on the remote if it wasn't pushed yet
const headSha = getHeadSha(rootDir);
const args = ['gh', 'release', 'create', tag, '--title', title, '--notes', body];
if (headSha) args.push('--target', headSha);
await runArgsAsync(args, {
cwd: rootDir,
});
log.success(`Created aggregate GitHub release: ${title}`);
Expand Down Expand Up @@ -135,6 +147,20 @@ function buildAggregateBody(releases: PlannedRelease[], changesets: Changeset[])
return lines.join('\n').trim() || 'No changelog entries.';
}

/** Compute the aggregate release tag and title, appending -n suffix if a tag for the same date already exists */
export function resolveAggregateTagAndTitle(
date: string,
existingTags: string[],
titleTemplate?: string,
): { tag: string; title: string } {
const baseTag = `release-${date}`;
const suffix = existingTags.length === 0 ? '' : `-${existingTags.length + 1}`;
const tag = `${baseTag}${suffix}`;
const template = titleTemplate || 'Release {{date}}';
const title = template.replace('{{date}}', `${date}${suffix}`);
return { tag, title };
}

function isGhAvailable(): boolean {
return tryRunArgs(['gh', '--version']) !== null;
}
160 changes: 160 additions & 0 deletions packages/bumpy/test/core/git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
import { resolve } from 'node:path';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { runArgs } from '../../src/utils/shell.ts';
import {
createTag,
tagExists,
listTags,
pushWithTags,
hasUncommittedChanges,
getCurrentBranch,
commitFiles,
} from '../../src/core/git.ts';
import { writeText } from '../../src/utils/fs.ts';

function initRepo(dir: string) {
runArgs(['git', 'init'], { cwd: dir });
runArgs(['git', 'commit', '--allow-empty', '-m', 'init'], { cwd: dir });
}

describe('git helpers', () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-git-test-'));
initRepo(tmpDir);
});

afterEach(async () => {
await rm(tmpDir, { recursive: true });
});

// ---- createTag / tagExists ----

describe('createTag & tagExists', () => {
test('creates a tag and detects it exists', () => {
expect(tagExists('v1.0.0', { cwd: tmpDir })).toBe(false);
createTag('v1.0.0', { cwd: tmpDir });
expect(tagExists('v1.0.0', { cwd: tmpDir })).toBe(true);
});

test('tagExists returns false for non-existent tag', () => {
expect(tagExists('nope', { cwd: tmpDir })).toBe(false);
});

test('scoped package tag with @ and /', () => {
createTag('@scope/pkg@1.2.3', { cwd: tmpDir });
expect(tagExists('@scope/pkg@1.2.3', { cwd: tmpDir })).toBe(true);
expect(tagExists('@scope/pkg@1.2.4', { cwd: tmpDir })).toBe(false);
});
});

// ---- listTags ----

describe('listTags', () => {
test('returns empty array when no tags match', () => {
expect(listTags('v*', { cwd: tmpDir })).toEqual([]);
});

test('lists tags matching a pattern', () => {
createTag('v1.0.0', { cwd: tmpDir });
createTag('v1.1.0', { cwd: tmpDir });
createTag('other-tag', { cwd: tmpDir });

const result = listTags('v*', { cwd: tmpDir });
expect(result).toContain('v1.0.0');
expect(result).toContain('v1.1.0');
expect(result).not.toContain('other-tag');
});

test('glob matches date-based release tags for suffix logic', () => {
// This is the pattern used by createAggregateRelease
createTag('release-2026-04-14', { cwd: tmpDir });
expect(listTags('release-2026-04-14*', { cwd: tmpDir })).toEqual(['release-2026-04-14']);

createTag('release-2026-04-14-2', { cwd: tmpDir });
const tags = listTags('release-2026-04-14*', { cwd: tmpDir });
expect(tags).toHaveLength(2);
expect(tags).toContain('release-2026-04-14');
expect(tags).toContain('release-2026-04-14-2');

// Different date should not match
createTag('release-2026-04-15', { cwd: tmpDir });
expect(listTags('release-2026-04-14*', { cwd: tmpDir })).toHaveLength(2);
});
});

// ---- hasUncommittedChanges ----

describe('hasUncommittedChanges', () => {
test('returns false on clean repo', () => {
expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(false);
});

test('returns true with uncommitted files', async () => {
await writeText(resolve(tmpDir, 'dirty.txt'), 'hello');
expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(true);
});
});

// ---- getCurrentBranch ----

describe('getCurrentBranch', () => {
test('returns current branch name', () => {
// git init defaults to main or master depending on config
const branch = getCurrentBranch({ cwd: tmpDir });
expect(typeof branch).toBe('string');
expect(branch!.length).toBeGreaterThan(0);
});
});

// ---- commitFiles ----

describe('commitFiles', () => {
test('stages and commits specified files', async () => {
await writeText(resolve(tmpDir, 'a.txt'), 'aaa');
await writeText(resolve(tmpDir, 'b.txt'), 'bbb');

commitFiles(['a.txt', 'b.txt'], 'add files', { cwd: tmpDir });

// Verify clean working tree
expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(false);
});

test('only stages specified files', async () => {
await writeText(resolve(tmpDir, 'staged.txt'), 'yes');
await writeText(resolve(tmpDir, 'unstaged.txt'), 'no');

commitFiles(['staged.txt'], 'partial commit', { cwd: tmpDir });

// unstaged.txt should still be dirty
expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(true);
});
});

// ---- pushWithTags ----

describe('pushWithTags', () => {
test('pushes commits and tags to remote', async () => {
// Set up a bare remote and clone
const remoteDir = await mkdtemp(resolve(tmpdir(), 'bumpy-remote-'));
runArgs(['git', 'init', '--bare'], { cwd: remoteDir });
runArgs(['git', 'remote', 'add', 'origin', remoteDir], { cwd: tmpDir });
// Push once with -u to set up tracking before testing pushWithTags
runArgs(['git', 'push', '-u', 'origin', 'HEAD'], { cwd: tmpDir });

createTag('v1.0.0', { cwd: tmpDir });
pushWithTags({ cwd: tmpDir });

// Clone from remote and check the tag arrived
const cloneDir = await mkdtemp(resolve(tmpdir(), 'bumpy-clone-'));
runArgs(['git', 'clone', remoteDir, '.'], { cwd: cloneDir });
expect(tagExists('v1.0.0', { cwd: cloneDir })).toBe(true);

await rm(remoteDir, { recursive: true });
await rm(cloneDir, { recursive: true });
});
});
});
Loading