From 178f8c3d12ce86f2f1f0c0b2e664655e0ad39daf Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 21:04:42 -0700 Subject: [PATCH 1/3] Fix tag pushing and GitHub release creation `git push --follow-tags` only pushes annotated tags reachable from pushed commits. Since bumpy creates lightweight tags and may have no new commits, tags were silently not being pushed, causing `gh release create` to fail with "tag exists locally but has not been pushed". - Switch to `git push` + `git push --tags` to ensure lightweight tags are pushed - Pass `--target ` to `gh release create` as a fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- .bumpy/fix-tag-push-and-release.md | 8 ++++++++ packages/bumpy/src/core/git.ts | 6 +++++- packages/bumpy/src/core/github-release.ts | 18 ++++++++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 .bumpy/fix-tag-push-and-release.md diff --git a/.bumpy/fix-tag-push-and-release.md b/.bumpy/fix-tag-push-and-release.md new file mode 100644 index 0000000..8b520b1 --- /dev/null +++ b/.bumpy/fix-tag-push-and-release.md @@ -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 diff --git a/packages/bumpy/src/core/git.ts b/packages/bumpy/src/core/git.ts index 94f8aca..05ff898 100644 --- a/packages/bumpy/src/core/git.ts +++ b/packages/bumpy/src/core/git.ts @@ -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 */ diff --git a/packages/bumpy/src/core/github-release.ts b/packages/bumpy/src/core/github-release.ts index 157c5c7..a3a973f 100644 --- a/packages/bumpy/src/core/github-release.ts +++ b/packages/bumpy/src/core/github-release.ts @@ -2,6 +2,11 @@ import { tryRunArgs, runArgsAsync } from '../utils/shell.ts'; import { log } from '../utils/logger.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; @@ -19,6 +24,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); @@ -30,7 +37,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}`); @@ -72,7 +82,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}`); From c0b1aeea48f63f59fb5e51dff058fea9208c04a4 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 22:12:43 -0700 Subject: [PATCH 2/3] Add aggregate release tag dedup and tests for git/github-release Append -n suffix to aggregate release tags when multiple releases happen on the same day. Extract resolveAggregateTagAndTitle as a pure function. Add test coverage for git helpers and github release logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/bumpy/src/core/github-release.ts | 22 ++- packages/bumpy/test/core/git.test.ts | 158 ++++++++++++++++++ .../bumpy/test/core/github-release.test.ts | 156 +++++++++++++++++ 3 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 packages/bumpy/test/core/git.test.ts create mode 100644 packages/bumpy/test/core/github-release.test.ts diff --git a/packages/bumpy/src/core/github-release.ts b/packages/bumpy/src/core/github-release.ts index a3a973f..51174cf 100644 --- a/packages/bumpy/src/core/github-release.ts +++ b/packages/bumpy/src/core/github-release.ts @@ -1,5 +1,6 @@ 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 */ @@ -65,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) { @@ -149,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; } diff --git a/packages/bumpy/test/core/git.test.ts b/packages/bumpy/test/core/git.test.ts new file mode 100644 index 0000000..168230e --- /dev/null +++ b/packages/bumpy/test/core/git.test.ts @@ -0,0 +1,158 @@ +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 }); + + 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 }); + }); + }); +}); diff --git a/packages/bumpy/test/core/github-release.test.ts b/packages/bumpy/test/core/github-release.test.ts new file mode 100644 index 0000000..89fe094 --- /dev/null +++ b/packages/bumpy/test/core/github-release.test.ts @@ -0,0 +1,156 @@ +import { test, expect, describe } 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 { listTags, tagExists } from '../../src/core/git.ts'; +import { + resolveAggregateTagAndTitle, + createAggregateRelease, + createIndividualReleases, +} from '../../src/core/github-release.ts'; +import type { PlannedRelease } from '../../src/types.ts'; + +function initRepo(dir: string) { + runArgs(['git', 'init'], { cwd: dir }); + runArgs(['git', 'commit', '--allow-empty', '-m', 'init'], { cwd: dir }); +} + +function makeRelease(name: string, version: string, type: 'major' | 'minor' | 'patch' = 'patch'): PlannedRelease { + return { + name, + type, + oldVersion: '0.0.0', + newVersion: version, + changesets: [], + isDependencyBump: false, + isCascadeBump: false, + }; +} + +// ---- Pure unit tests for tag/title resolution ---- + +describe('resolveAggregateTagAndTitle', () => { + test('first release of the day gets no suffix', () => { + const result = resolveAggregateTagAndTitle('2026-04-14', []); + expect(result.tag).toBe('release-2026-04-14'); + expect(result.title).toBe('Release 2026-04-14'); + }); + + test('second release of the day gets -2 suffix', () => { + const result = resolveAggregateTagAndTitle('2026-04-14', ['release-2026-04-14']); + expect(result.tag).toBe('release-2026-04-14-2'); + expect(result.title).toBe('Release 2026-04-14-2'); + }); + + test('third release of the day gets -3 suffix', () => { + const existing = ['release-2026-04-14', 'release-2026-04-14-2']; + const result = resolveAggregateTagAndTitle('2026-04-14', existing); + expect(result.tag).toBe('release-2026-04-14-3'); + expect(result.title).toBe('Release 2026-04-14-3'); + }); + + test('custom title template gets date+suffix substituted', () => { + const result = resolveAggregateTagAndTitle('2026-04-14', ['release-2026-04-14'], 'Deploy {{date}}'); + expect(result.title).toBe('Deploy 2026-04-14-2'); + }); + + test('custom title with no suffix', () => { + const result = resolveAggregateTagAndTitle('2026-04-14', [], 'v{{date}}'); + expect(result.title).toBe('v2026-04-14'); + }); +}); + +// ---- Integration tests using real git repos ---- + +describe('createAggregateRelease', () => { + let tmpDir: string; + + test('creates a date-based git tag (gh failure is caught)', async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); + initRepo(tmpDir); + + const releases = [makeRelease('pkg-a', '1.0.0', 'minor')]; + + // This will create the git tag, then fail at `gh release create` (no auth), + // but the error is caught — we verify the tag was created correctly + await createAggregateRelease(releases, [], tmpDir); + + const today = new Date().toISOString().split('T')[0]; + expect(tagExists(`release-${today}`, { cwd: tmpDir })).toBe(true); + + await rm(tmpDir, { recursive: true }); + }); + + test('second call on same day creates tag with -2 suffix', async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); + initRepo(tmpDir); + + const releases = [makeRelease('pkg-a', '1.0.0')]; + const today = new Date().toISOString().split('T')[0]; + + // First release + await createAggregateRelease(releases, [], tmpDir); + expect(tagExists(`release-${today}`, { cwd: tmpDir })).toBe(true); + + // Second release + await createAggregateRelease(releases, [], tmpDir); + expect(tagExists(`release-${today}-2`, { cwd: tmpDir })).toBe(true); + + // Third release + await createAggregateRelease(releases, [], tmpDir); + expect(tagExists(`release-${today}-3`, { cwd: tmpDir })).toBe(true); + + // All three tags exist + const tags = listTags(`release-${today}*`, { cwd: tmpDir }); + expect(tags).toHaveLength(3); + + await rm(tmpDir, { recursive: true }); + }); + + test('skips with empty releases array', async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); + initRepo(tmpDir); + + // Should return early, no tags created + await createAggregateRelease([], [], tmpDir); + + const tags = listTags('release-*', { cwd: tmpDir }); + expect(tags).toHaveLength(0); + + await rm(tmpDir, { recursive: true }); + }); +}); + +describe('createIndividualReleases', () => { + let tmpDir: string; + + test('dry run does not create tags or call gh', async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); + initRepo(tmpDir); + + const releases = [makeRelease('pkg-a', '1.0.0'), makeRelease('pkg-b', '2.0.0')]; + + await createIndividualReleases(releases, [], tmpDir, { dryRun: true }); + + // No tags should be created in dry-run mode + expect(tagExists('pkg-a@1.0.0', { cwd: tmpDir })).toBe(false); + expect(tagExists('pkg-b@2.0.0', { cwd: tmpDir })).toBe(false); + + await rm(tmpDir, { recursive: true }); + }); + + test('creates per-package releases (gh failure is caught)', async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-ghrel-')); + initRepo(tmpDir); + + const releases = [makeRelease('pkg-a', '1.0.0', 'minor'), makeRelease('pkg-b', '2.0.0', 'major')]; + + // gh will fail but errors are caught per-release — all releases attempted + await createIndividualReleases(releases, [], tmpDir); + + // Individual releases don't create git tags (that's done by publish-pipeline) + // but this verifies the function doesn't throw on gh failure + await rm(tmpDir, { recursive: true }); + }); +}); From 6b915263277597058bacc99360568e64b1f996b6 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 22:15:48 -0700 Subject: [PATCH 3/3] Fix pushWithTags test: set upstream before pushing The test failed in CI because `git push` requires a tracking branch. Add an initial `git push -u origin HEAD` to set up tracking. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/bumpy/test/core/git.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bumpy/test/core/git.test.ts b/packages/bumpy/test/core/git.test.ts index 168230e..5c3655e 100644 --- a/packages/bumpy/test/core/git.test.ts +++ b/packages/bumpy/test/core/git.test.ts @@ -142,6 +142,8 @@ describe('git helpers', () => { 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 });