Skip to content

Commit d219d71

Browse files
authored
Merge pull request #4 from dmno-dev/fix/tag-push-and-release
Fix tag pushing and GitHub release creation
2 parents dd7679b + 6b91526 commit d219d71

5 files changed

Lines changed: 362 additions & 8 deletions

File tree

.bumpy/fix-tag-push-and-release.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Fix git tag pushing and GitHub release creation
6+
7+
- Use `git push --tags` instead of `--follow-tags` so lightweight tags are actually pushed to the remote
8+
- Pass `--target` commit SHA to `gh release create` as a fallback in case tags haven't propagated

packages/bumpy/src/core/git.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ export function createTag(tag: string, opts?: { cwd?: string }): void {
77

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

1317
/** Check if there are uncommitted changes */

packages/bumpy/src/core/github-release.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { tryRunArgs, runArgsAsync } from '../utils/shell.ts';
22
import { log } from '../utils/logger.ts';
3+
import { listTags } from './git.ts';
34
import type { PlannedRelease, Changeset } from '../types.ts';
45

6+
/** Get the current HEAD commit SHA */
7+
function getHeadSha(rootDir: string): string | null {
8+
return tryRunArgs(['git', 'rev-parse', 'HEAD'], { cwd: rootDir });
9+
}
10+
511
export interface GitHubReleaseOptions {
612
dryRun?: boolean;
713
title?: string;
@@ -19,6 +25,8 @@ export async function createIndividualReleases(
1925
return;
2026
}
2127

28+
const headSha = getHeadSha(rootDir);
29+
2230
for (const release of releases) {
2331
const tag = `${release.name}@${release.newVersion}`;
2432
const body = buildReleaseBody(release, changesets);
@@ -30,7 +38,10 @@ export async function createIndividualReleases(
3038
}
3139

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

5768
const date = new Date().toISOString().split('T')[0];
58-
const titleTemplate = opts.title || 'Release {{date}}';
59-
const title = titleTemplate.replace('{{date}}', date!);
60-
61-
// Use the first release's tag as the release tag, or create a date-based tag
62-
const tag = `release-${date}`;
69+
const existing = listTags(`release-${date}*`, { cwd: rootDir });
70+
const { tag, title } = resolveAggregateTagAndTitle(date!, existing, opts.title);
6371
const body = buildAggregateBody(releases, changesets);
6472

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

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

150+
/** Compute the aggregate release tag and title, appending -n suffix if a tag for the same date already exists */
151+
export function resolveAggregateTagAndTitle(
152+
date: string,
153+
existingTags: string[],
154+
titleTemplate?: string,
155+
): { tag: string; title: string } {
156+
const baseTag = `release-${date}`;
157+
const suffix = existingTags.length === 0 ? '' : `-${existingTags.length + 1}`;
158+
const tag = `${baseTag}${suffix}`;
159+
const template = titleTemplate || 'Release {{date}}';
160+
const title = template.replace('{{date}}', `${date}${suffix}`);
161+
return { tag, title };
162+
}
163+
138164
function isGhAvailable(): boolean {
139165
return tryRunArgs(['gh', '--version']) !== null;
140166
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { test, expect, describe, beforeEach, afterEach } from 'bun:test';
2+
import { resolve } from 'node:path';
3+
import { mkdtemp, rm } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { runArgs } from '../../src/utils/shell.ts';
6+
import {
7+
createTag,
8+
tagExists,
9+
listTags,
10+
pushWithTags,
11+
hasUncommittedChanges,
12+
getCurrentBranch,
13+
commitFiles,
14+
} from '../../src/core/git.ts';
15+
import { writeText } from '../../src/utils/fs.ts';
16+
17+
function initRepo(dir: string) {
18+
runArgs(['git', 'init'], { cwd: dir });
19+
runArgs(['git', 'commit', '--allow-empty', '-m', 'init'], { cwd: dir });
20+
}
21+
22+
describe('git helpers', () => {
23+
let tmpDir: string;
24+
25+
beforeEach(async () => {
26+
tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-git-test-'));
27+
initRepo(tmpDir);
28+
});
29+
30+
afterEach(async () => {
31+
await rm(tmpDir, { recursive: true });
32+
});
33+
34+
// ---- createTag / tagExists ----
35+
36+
describe('createTag & tagExists', () => {
37+
test('creates a tag and detects it exists', () => {
38+
expect(tagExists('v1.0.0', { cwd: tmpDir })).toBe(false);
39+
createTag('v1.0.0', { cwd: tmpDir });
40+
expect(tagExists('v1.0.0', { cwd: tmpDir })).toBe(true);
41+
});
42+
43+
test('tagExists returns false for non-existent tag', () => {
44+
expect(tagExists('nope', { cwd: tmpDir })).toBe(false);
45+
});
46+
47+
test('scoped package tag with @ and /', () => {
48+
createTag('@scope/pkg@1.2.3', { cwd: tmpDir });
49+
expect(tagExists('@scope/pkg@1.2.3', { cwd: tmpDir })).toBe(true);
50+
expect(tagExists('@scope/pkg@1.2.4', { cwd: tmpDir })).toBe(false);
51+
});
52+
});
53+
54+
// ---- listTags ----
55+
56+
describe('listTags', () => {
57+
test('returns empty array when no tags match', () => {
58+
expect(listTags('v*', { cwd: tmpDir })).toEqual([]);
59+
});
60+
61+
test('lists tags matching a pattern', () => {
62+
createTag('v1.0.0', { cwd: tmpDir });
63+
createTag('v1.1.0', { cwd: tmpDir });
64+
createTag('other-tag', { cwd: tmpDir });
65+
66+
const result = listTags('v*', { cwd: tmpDir });
67+
expect(result).toContain('v1.0.0');
68+
expect(result).toContain('v1.1.0');
69+
expect(result).not.toContain('other-tag');
70+
});
71+
72+
test('glob matches date-based release tags for suffix logic', () => {
73+
// This is the pattern used by createAggregateRelease
74+
createTag('release-2026-04-14', { cwd: tmpDir });
75+
expect(listTags('release-2026-04-14*', { cwd: tmpDir })).toEqual(['release-2026-04-14']);
76+
77+
createTag('release-2026-04-14-2', { cwd: tmpDir });
78+
const tags = listTags('release-2026-04-14*', { cwd: tmpDir });
79+
expect(tags).toHaveLength(2);
80+
expect(tags).toContain('release-2026-04-14');
81+
expect(tags).toContain('release-2026-04-14-2');
82+
83+
// Different date should not match
84+
createTag('release-2026-04-15', { cwd: tmpDir });
85+
expect(listTags('release-2026-04-14*', { cwd: tmpDir })).toHaveLength(2);
86+
});
87+
});
88+
89+
// ---- hasUncommittedChanges ----
90+
91+
describe('hasUncommittedChanges', () => {
92+
test('returns false on clean repo', () => {
93+
expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(false);
94+
});
95+
96+
test('returns true with uncommitted files', async () => {
97+
await writeText(resolve(tmpDir, 'dirty.txt'), 'hello');
98+
expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(true);
99+
});
100+
});
101+
102+
// ---- getCurrentBranch ----
103+
104+
describe('getCurrentBranch', () => {
105+
test('returns current branch name', () => {
106+
// git init defaults to main or master depending on config
107+
const branch = getCurrentBranch({ cwd: tmpDir });
108+
expect(typeof branch).toBe('string');
109+
expect(branch!.length).toBeGreaterThan(0);
110+
});
111+
});
112+
113+
// ---- commitFiles ----
114+
115+
describe('commitFiles', () => {
116+
test('stages and commits specified files', async () => {
117+
await writeText(resolve(tmpDir, 'a.txt'), 'aaa');
118+
await writeText(resolve(tmpDir, 'b.txt'), 'bbb');
119+
120+
commitFiles(['a.txt', 'b.txt'], 'add files', { cwd: tmpDir });
121+
122+
// Verify clean working tree
123+
expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(false);
124+
});
125+
126+
test('only stages specified files', async () => {
127+
await writeText(resolve(tmpDir, 'staged.txt'), 'yes');
128+
await writeText(resolve(tmpDir, 'unstaged.txt'), 'no');
129+
130+
commitFiles(['staged.txt'], 'partial commit', { cwd: tmpDir });
131+
132+
// unstaged.txt should still be dirty
133+
expect(hasUncommittedChanges({ cwd: tmpDir })).toBe(true);
134+
});
135+
});
136+
137+
// ---- pushWithTags ----
138+
139+
describe('pushWithTags', () => {
140+
test('pushes commits and tags to remote', async () => {
141+
// Set up a bare remote and clone
142+
const remoteDir = await mkdtemp(resolve(tmpdir(), 'bumpy-remote-'));
143+
runArgs(['git', 'init', '--bare'], { cwd: remoteDir });
144+
runArgs(['git', 'remote', 'add', 'origin', remoteDir], { cwd: tmpDir });
145+
// Push once with -u to set up tracking before testing pushWithTags
146+
runArgs(['git', 'push', '-u', 'origin', 'HEAD'], { cwd: tmpDir });
147+
148+
createTag('v1.0.0', { cwd: tmpDir });
149+
pushWithTags({ cwd: tmpDir });
150+
151+
// Clone from remote and check the tag arrived
152+
const cloneDir = await mkdtemp(resolve(tmpdir(), 'bumpy-clone-'));
153+
runArgs(['git', 'clone', remoteDir, '.'], { cwd: cloneDir });
154+
expect(tagExists('v1.0.0', { cwd: cloneDir })).toBe(true);
155+
156+
await rm(remoteDir, { recursive: true });
157+
await rm(cloneDir, { recursive: true });
158+
});
159+
});
160+
});

0 commit comments

Comments
 (0)