From 7e92e5be4df32ab65d3b2ccb9312e16fb96fac40 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 13 May 2026 15:13:21 -0700 Subject: [PATCH 1/2] fix: recover bump files for release notes and fix git tag push auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recover deleted bump files from version commit (HEAD~1) so GitHub release bodies are populated with changelog content in the post-merge publish flow - Fix git tag push failure: switch from URL-embedded token to http.extraheader (same mechanism as actions/checkout), which takes priority over credential managers on CI runners - Remove redundant `git push` before `git push --tags` in pushWithTags — in the publish flow there are no new commits to push - Add extractChangelogEntry helper to extract changelog entries from CHANGELOG.md --- packages/bumpy/src/commands/ci.ts | 87 +++++--------------------- packages/bumpy/src/commands/publish.ts | 14 ++++- packages/bumpy/src/core/bump-file.ts | 25 ++++++++ packages/bumpy/src/core/changelog.ts | 26 ++++++++ packages/bumpy/src/core/git.ts | 83 ++++++++++++++++++++++-- 5 files changed, 158 insertions(+), 77 deletions(-) diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 96c0b04..13ee394 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -3,8 +3,8 @@ import { loadConfig } from '../core/config.ts'; import { findChangedPackages } from './check.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { DependencyGraph } from '../core/dep-graph.ts'; -import { readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts'; -import { getChangedFiles } from '../core/git.ts'; +import { readBumpFiles, filterBranchBumpFiles, recoverDeletedBumpFiles } from '../core/bump-file.ts'; +import { getChangedFiles, withGitToken } from '../core/git.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; import { runArgs, runArgsAsync, tryRunArgs } from '../utils/shell.ts'; import { randomName } from '../utils/names.ts'; @@ -396,8 +396,11 @@ export async function ciReleaseCommand(rootDir: string, opts: ReleaseOptions): P // No bump files — check if there are unpublished packages to publish // (this handles the case where a version PR was just merged) log.info('No pending bump files — checking for unpublished packages...'); + // Recover bump files deleted in the version commit so the formatter + // can generate proper GitHub release bodies + const recoveredBumpFiles = recoverDeletedBumpFiles(rootDir); const { publishCommand } = await import('./publish.ts'); - await publishCommand(rootDir, { tag: opts.tag }); + await publishCommand(rootDir, { tag: opts.tag, recoveredBumpFiles }); return; } @@ -456,75 +459,19 @@ function pushWithToken(rootDir: string, branch: string, config: BumpyConfig): vo throw new Error(`Refusing to force-push to "${branch}" — this looks like a base branch, not a version PR branch`); } - const token = process.env.BUMPY_GH_TOKEN; - const repo = process.env.GITHUB_REPOSITORY; // e.g. "owner/repo" - const server = process.env.GITHUB_SERVER_URL || 'https://github.com'; - - if (token && repo) { - const authedUrl = `${server.replace('://', `://x-access-token:${token}@`)}/${repo}.git`; - const originalUrl = tryRunArgs(['git', 'remote', 'get-url', 'origin'], { cwd: rootDir }); - - // `actions/checkout@v6` persists the default GITHUB_TOKEN in two ways: - // 1. Direct http./.extraheader config - // 2. includeIf.gitdir entries pointing to credential config files - // that also sets http./.extraheader - // Both must be cleared for our custom token to be used. - const extraHeaderKey = `http.${server}/.extraheader`; - const savedHeader = tryRunArgs(['git', 'config', '--local', extraHeaderKey], { cwd: rootDir }); - - // Collect includeIf entries that point to credential config files - // git config --get-regexp outputs keys in lowercase - const includeIfRaw = tryRunArgs(['git', 'config', '--local', '--get-regexp', '^includeif\\.gitdir:'], { - cwd: rootDir, - }); - const savedIncludeIfs: Array<{ key: string; value: string }> = []; - if (includeIfRaw) { - for (const line of includeIfRaw.split('\n').filter(Boolean)) { - const spaceIdx = line.indexOf(' '); - if (spaceIdx > 0) { - savedIncludeIfs.push({ key: line.slice(0, spaceIdx), value: line.slice(spaceIdx + 1) }); - } - } - } + withGitToken(rootDir, () => { + // --no-verify skips pre-push hooks (e.g. bumpy check) which would fail + // on the version branch since bump files are consumed during versioning + runArgs(['git', 'push', '-u', 'origin', branch, '--force', '--no-verify'], { cwd: rootDir }); + }); - try { - if (savedHeader) { - runArgs(['git', 'config', '--local', '--unset-all', extraHeaderKey], { cwd: rootDir }); - } - for (const entry of savedIncludeIfs) { - tryRunArgs(['git', 'config', '--local', '--unset', entry.key], { cwd: rootDir }); - } - runArgs(['git', 'remote', 'set-url', 'origin', authedUrl], { cwd: rootDir }); - try { - // --no-verify skips pre-push hooks (e.g. bumpy check) which would fail - // on the version branch since bump files are consumed during versioning - runArgs(['git', 'push', '-u', 'origin', branch, '--force', '--no-verify'], { cwd: rootDir }); - } catch (err) { - // Redact token from error messages to prevent leakage in CI logs - const msg = err instanceof Error ? err.message : String(err); - throw new Error(msg.replaceAll(token, '***')); - } - } finally { - // Restore original URL, extraheader, and includeIf entries - if (originalUrl) { - runArgs(['git', 'remote', 'set-url', 'origin', originalUrl], { cwd: rootDir }); - } - if (savedHeader) { - runArgs(['git', 'config', '--local', extraHeaderKey, savedHeader], { cwd: rootDir }); - } - for (const entry of savedIncludeIfs) { - tryRunArgs(['git', 'config', '--local', entry.key, entry.value], { cwd: rootDir }); - } - } + if (process.env.BUMPY_GH_TOKEN && process.env.GITHUB_REPOSITORY) { log.dim(' Pushed with custom token — PR workflows will be triggered'); - } else { - runArgs(['git', 'push', '-u', 'origin', branch, '--force', '--no-verify'], { cwd: rootDir }); - if (!token && repo) { - // Only warn on GitHub Actions — other CI providers don't have this limitation - log.warn( - 'BUMPY_GH_TOKEN is not set — PR checks will not trigger automatically.\n' + ' Run `bumpy ci setup` for help.', - ); - } + } else if (!process.env.BUMPY_GH_TOKEN && process.env.GITHUB_REPOSITORY) { + // Only warn on GitHub Actions — other CI providers don't have this limitation + log.warn( + 'BUMPY_GH_TOKEN is not set — PR checks will not trigger automatically.\n' + ' Run `bumpy ci setup` for help.', + ); } } diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index 9bc4ac3..3b214cd 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -16,6 +16,8 @@ interface PublishCommandOptions { noPush?: boolean; /** Filter to specific packages by name/glob (comma-separated) */ filter?: string; + /** Recovered bump files from a version commit — used for GitHub release body generation */ + recoveredBumpFiles?: import('../types.ts').BumpFile[]; } /** @@ -50,8 +52,18 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption } // Build a synthetic release plan from unpublished packages + // Use recovered bump files (from version commit) when available so that + // GitHub release bodies can be generated with the formatter + const recoveredBumpFiles = opts.recoveredBumpFiles || []; + if (recoveredBumpFiles.length > 0) { + for (const release of toPublish) { + release.bumpFiles = recoveredBumpFiles + .filter((bf) => bf.releases.some((r) => r.name === release.name)) + .map((bf) => bf.id); + } + } const releasePlan: ReleasePlan = { - bumpFiles: [], + bumpFiles: recoveredBumpFiles, releases: toPublish, warnings: [], }; diff --git a/packages/bumpy/src/core/bump-file.ts b/packages/bumpy/src/core/bump-file.ts index 5c49c53..e070169 100644 --- a/packages/bumpy/src/core/bump-file.ts +++ b/packages/bumpy/src/core/bump-file.ts @@ -201,6 +201,31 @@ export async function writeBumpFile( return filePath; } +/** + * Recover bump files that were deleted in the HEAD commit (version commit). + * Used during the publish-only flow (after version PR merge) to provide + * bump file context for GitHub release body generation. + */ +export function recoverDeletedBumpFiles(rootDir: string): BumpFile[] { + // Find .bumpy/*.md files deleted in the HEAD commit + const deleted = tryRunArgs(['git', 'diff', '--diff-filter=D', '--name-only', 'HEAD~1', 'HEAD', '--', '.bumpy/*.md'], { + cwd: rootDir, + }); + if (!deleted) return []; + + const bumpFiles: BumpFile[] = []; + for (const filePath of deleted.split('\n').filter(Boolean)) { + if (filePath.endsWith('README.md')) continue; + // Read the file content from the parent commit + const content = tryRunArgs(['git', 'show', `HEAD~1:${filePath}`], { cwd: rootDir }); + if (!content) continue; + const id = filePath.replace(/^\.bumpy\//, '').replace(/\.md$/, ''); + const { bumpFile } = parseBumpFile(content, id); + if (bumpFile) bumpFiles.push(bumpFile); + } + return bumpFiles; +} + /** Delete consumed bump files */ export async function deleteBumpFiles(rootDir: string, ids: string[]): Promise { const dir = getBumpyDir(rootDir); diff --git a/packages/bumpy/src/core/changelog.ts b/packages/bumpy/src/core/changelog.ts index b336a1a..cc6c6b4 100644 --- a/packages/bumpy/src/core/changelog.ts +++ b/packages/bumpy/src/core/changelog.ts @@ -177,6 +177,32 @@ export async function generateChangelogEntry( return formatter({ release, bumpFiles, date, target }); } +/** + * Extract the changelog entry for a specific version from a CHANGELOG.md string. + * Returns the content between the `## {version}` heading and the next `## ` heading + * (or end of file), with the heading and date sub-heading stripped. + */ +export function extractChangelogEntry(changelogContent: string, version: string): string | null { + // Match ## {version} heading (with or without leading whitespace) + const versionHeadingRe = new RegExp(`^## ${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm'); + const match = versionHeadingRe.exec(changelogContent); + if (!match || match.index === undefined) return null; + + // Find the next ## heading after this one + const afterHeading = match.index + match[0].length; + const nextHeading = changelogContent.indexOf('\n## ', afterHeading); + const entryContent = + nextHeading === -1 ? changelogContent.slice(afterHeading) : changelogContent.slice(afterHeading, nextHeading); + + // Strip the date sub-heading (... or _..._) and trim + return ( + entryContent + .replace(/^\s*.+<\/sub>\s*/m, '') + .replace(/^\s*_.+_\s*/m, '') + .trim() || null + ); +} + /** Prepend a new entry to an existing CHANGELOG.md content */ export function prependToChangelog(existingContent: string, newEntry: string): string { // Try to find the first ## heading and insert before it diff --git a/packages/bumpy/src/core/git.ts b/packages/bumpy/src/core/git.ts index d786fd1..b5dca68 100644 --- a/packages/bumpy/src/core/git.ts +++ b/packages/bumpy/src/core/git.ts @@ -5,13 +5,84 @@ export function createTag(tag: string, opts?: { cwd?: string }): void { runArgs(['git', 'tag', tag], opts); } -/** Push commits and tags to remote */ +/** Push tags to remote, using BUMPY_GH_TOKEN if available */ export function pushWithTags(opts?: { cwd?: string }): void { - // 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); + // Use `git push --tags` directly (no preceding `git push` for commits) because: + // - In the publish flow there are no new commits to push + // - `--follow-tags` only pushes annotated tags reachable from pushed commits, + // but we create lightweight tags, so we use `--tags` instead + withGitToken(opts?.cwd, () => { + runArgs(['git', 'push', '--tags'], opts); + }); +} + +/** + * Temporarily configure git credentials using BUMPY_GH_TOKEN (or GH_TOKEN), + * execute a callback, then restore the original config. + * + * Uses the http.extraheader approach (same as actions/checkout) rather than + * embedding tokens in the remote URL, because extraheader takes priority over + * any credential manager that may be installed on the runner. + * + * Also clears any existing credential config set by actions/checkout (extraheader + * or includeIf entries) so our token is used instead of the default GITHUB_TOKEN. + */ +export function withGitToken(cwd: string | undefined, fn: () => void): void { + const token = process.env.BUMPY_GH_TOKEN || process.env.GH_TOKEN; + const server = process.env.GITHUB_SERVER_URL || 'https://github.com'; + + if (!token) { + fn(); + return; + } + + const extraHeaderKey = `http.${server}/.extraheader`; + // Authorization: bearer works for both GitHub PATs and GITHUB_TOKEN + const authHeader = `Authorization: bearer ${token}`; + + // Save and clear any existing credential config set by actions/checkout: + // 1. Direct http./.extraheader in local config + // 2. includeIf.gitdir entries pointing to credential config files + const savedHeader = tryRunArgs(['git', 'config', '--local', extraHeaderKey], { cwd }); + + const includeIfRaw = tryRunArgs(['git', 'config', '--local', '--get-regexp', '^includeif\\.gitdir:'], { cwd }); + const savedIncludeIfs: Array<{ key: string; value: string }> = []; + if (includeIfRaw) { + for (const line of includeIfRaw.split('\n').filter(Boolean)) { + const spaceIdx = line.indexOf(' '); + if (spaceIdx > 0) { + savedIncludeIfs.push({ key: line.slice(0, spaceIdx), value: line.slice(spaceIdx + 1) }); + } + } + } + + try { + if (savedHeader) { + runArgs(['git', 'config', '--local', '--unset-all', extraHeaderKey], { cwd }); + } + for (const entry of savedIncludeIfs) { + tryRunArgs(['git', 'config', '--local', '--unset', entry.key], { cwd }); + } + // Set our token as the Authorization header — this takes priority over credential managers + runArgs(['git', 'config', '--local', extraHeaderKey, authHeader], { cwd }); + try { + fn(); + } catch (err) { + // Redact token from error messages to prevent leakage in CI logs + const msg = err instanceof Error ? err.message : String(err); + throw new Error(msg.replaceAll(token, '***')); + } + } finally { + // Remove our injected header + tryRunArgs(['git', 'config', '--local', '--unset-all', extraHeaderKey], { cwd }); + // Restore previous credential config + if (savedHeader) { + runArgs(['git', 'config', '--local', extraHeaderKey, savedHeader], { cwd }); + } + for (const entry of savedIncludeIfs) { + tryRunArgs(['git', 'config', '--local', entry.key, entry.value], { cwd }); + } + } } /** Check if there are uncommitted changes */ From 748963b1334ca8ec27dfe4762d534ecb73819184 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 13 May 2026 15:22:30 -0700 Subject: [PATCH 2/2] chore: add bump file for release fixes --- .bumpy/fix-release-tags-and-notes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .bumpy/fix-release-tags-and-notes.md diff --git a/.bumpy/fix-release-tags-and-notes.md b/.bumpy/fix-release-tags-and-notes.md new file mode 100644 index 0000000..55a9229 --- /dev/null +++ b/.bumpy/fix-release-tags-and-notes.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': patch +--- + +Fix git tag push auth using http.extraheader; recover deleted bump files for GitHub release notes in post-merge publish flow