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
5 changes: 5 additions & 0 deletions .bumpy/fix-release-tags-and-notes.md
Original file line number Diff line number Diff line change
@@ -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
87 changes: 17 additions & 70 deletions packages/bumpy/src/commands/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.<server>/.extraheader config
// 2. includeIf.gitdir entries pointing to credential config files
// that also sets http.<server>/.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.',
);
}
}

Expand Down
14 changes: 13 additions & 1 deletion packages/bumpy/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -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: [],
};
Expand Down
25 changes: 25 additions & 0 deletions packages/bumpy/src/core/bump-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const dir = getBumpyDir(rootDir);
Expand Down
26 changes: 26 additions & 0 deletions packages/bumpy/src/core/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<sub>...</sub> or _..._) and trim
return (
entryContent
.replace(/^\s*<sub>.+<\/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
Expand Down
83 changes: 77 additions & 6 deletions packages/bumpy/src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<server>/.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 */
Expand Down