From dbf24ac25124b9a36c250b3edb92d3f0f8113a6a Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 28 May 2026 08:52:45 +0200 Subject: [PATCH 1/5] Pre-validate before mutation; atomic push prevents orphan tags Splits main() into gather, validate, and execute. Validate runs read-only checks (file would change, identity available, remote tag absent, local HEAD is FF-able, forge reachable with push permission) and prints + exits before any mutation if any fail. Execute uses `git push --atomic` so a partial push (branch rejected, tag accepted) can no longer leave an orphan tag on the remote. Co-Authored-By: Claude (Opus 4.7) --- index.test.ts | 52 ++++++- index.ts | 421 ++++++++++++++++++++++++++++---------------------- 2 files changed, 283 insertions(+), 190 deletions(-) diff --git a/index.test.ts b/index.test.ts index b1c762f..6696c15 100644 --- a/index.test.ts +++ b/index.test.ts @@ -801,6 +801,51 @@ test("rollback - push failure reverts local commit, tag, and file", () => withTm expect(status.trim()).toEqual(""); })); +test("validate - aborts when remote branch has advanced beyond local", () => withTmpDir(async (tmpDir) => { + // Regression: a prior failed run left orphan tags because remote master had advanced + // (locally invisible without fetch) and the next run pushed only the tag while branch + // push was rejected. Validate now catches this before any commit/tag. + const pkgContent = JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2); + await writeFile(join(tmpDir, "package.json"), pkgContent); + + const {env, bareDir} = await setupReleaseRepo(tmpDir); + const opts = {cwd: tmpDir, env: {...process.env, ...env}}; + + // Simulate "someone else pushed to origin/master": advance remote HEAD without + // updating the local tracking ref. Local repo is now behind without knowing it. + await writeFile(join(tmpDir, "other.txt"), "remote work"); + await exec("git", ["add", "other.txt"], opts); + await exec("git", ["commit", "-m", "remote work"], opts); + await exec("git", ["push", "origin", "master"], opts); + await exec("git", ["reset", "--hard", "HEAD^"], opts); + // Now: local HEAD is at the initial commit, but origin's master is one commit ahead. + await writeFile(join(tmpDir, "package.json"), pkgContent); + + const {stdout: preLocalHead} = await exec("git", ["rev-parse", "HEAD"], opts); + const {stdout: preRemoteHead} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir}); + const {stdout: preTags} = await exec("git", ["tag", "--list"], {cwd: bareDir}); + + try { + await exec("node", [distPath, "patch", "package.json"], opts); + throw new Error("should have thrown"); + } catch (err: any) { + expect(err).toBeInstanceOf(SubprocessError); + expect(err.exitCode).toEqual(1); + expect(err.output).toMatch(/not a descendant/); + } + + // No mutation must have happened — neither locally nor on the remote. + expect(await readFile(join(tmpDir, "package.json"), "utf8")).toEqual(pkgContent); + const {stdout: postLocalHead} = await exec("git", ["rev-parse", "HEAD"], opts); + expect(postLocalHead).toEqual(preLocalHead); + const {stdout: localTags} = await exec("git", ["tag", "--list"], opts); + expect(localTags.trim().split("\n").filter(Boolean)).not.toContain("1.0.1"); + const {stdout: postRemoteHead} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir}); + expect(postRemoteHead).toEqual(preRemoteHead); + const {stdout: postTags} = await exec("git", ["tag", "--list"], {cwd: bareDir}); + expect(postTags).toEqual(preTags); +})); + test("rollback - -c failure restores file writes (gitless)", () => withTmpDir(async (tmpDir) => { await writeFile(join(tmpDir, "testfile.txt"), "version 1.0.0"); @@ -1075,8 +1120,11 @@ test("--remote with --release uses that remote for forge detection", () => withT } expect(err).toBeInstanceOf(SubprocessError); expect(err.exitCode).toEqual(1); - expect(err.output).toMatch(/Failed to (create|list) release/); - expect(err.output).not.toContain("Could not determine repository type"); + // The forge ping during validate hits the upstream host; if --remote were ignored, + // getRepoInfo would have returned null for file:/// and the error would be "could not + // detect a forge" instead. The mention of gitea.invalid proves upstream's URL was used. + expect(err.output).toContain("gitea.invalid"); + expect(err.output).not.toContain("could not detect a forge"); })); test("--branch pushes specified branch", () => withTmpDir(async (tmpDir) => { diff --git a/index.ts b/index.ts index 9ab48a9..3b12b6d 100755 --- a/index.ts +++ b/index.ts @@ -308,6 +308,14 @@ export function getGiteaTokens(): string[] { return envTokens(["VERSIONS_FORGE_TOKEN", "GITEA_API_TOKEN", "GITEA_AUTH_TOKEN", "GITEA_TOKEN"]); } +function forgeName(repoInfo: RepoInfo): "GitHub" | "Gitea" { + return repoInfo.type === "github" ? "GitHub" : "Gitea"; +} + +async function getForgeTokens(repoInfo: RepoInfo): Promise { + return repoInfo.type === "github" ? getGithubTokens() : getGiteaTokens(); +} + export type RepoInfo = { owner: string; repo: string; @@ -454,82 +462,47 @@ export function writeResult(result: Result): void { } } -type RemoteProbe = {branch: string | null, tag: string | null, ok: true} | {branch: null, tag: null, ok: false}; - -type CommitTagPushOpts = { - tagName: string; - msgs: string[]; - changelog: string | undefined; - all: boolean; - noPush: boolean; - filesToAdd: string[]; - pushRemote: string; - pushBranch: string; - branchRef: string; - tagRef: string; - remoteProbe: RemoteProbe | null; - rollbacks: Array<() => Promise | void>; -}; - -async function commitTagPush({ - tagName, msgs, changelog, all, noPush, filesToAdd, - pushRemote, pushBranch, branchRef, tagRef, remoteProbe, rollbacks, -}: CommitTagPushOpts): Promise { - // preserve user's staged hunks on rollback (--soft would leave our changes staged) - const [preIndexTreeOid, priorLocalTagOid] = await Promise.all([ - exec("git", ["write-tree"]).then(r => r.stdout.trim()).catch(() => null), - exec("git", ["rev-parse", "--verify", tagRef]).then(r => r.stdout.trim()).catch(() => null), - ]); - - const commitMsg = joinStrings([tagName, ...msgs, changelog], "\n\n"); - const commitArgs = all ? - ["commit", "-a", "--allow-empty", "-F", "-"] : - filesToAdd.length ? - ["commit", "-i", "-F", "-", "--", ...filesToAdd] : - ["commit", "--allow-empty", "-F", "-"]; - writeResult(await exec("git", commitArgs, {stdin: {string: commitMsg}})); - rollbacks.push(async () => { - const hasParent = await exec("git", ["rev-parse", "HEAD^"]).then(() => true, () => false); - if (hasParent) await exec("git", ["reset", "--soft", "HEAD^"]); - else await exec("git", ["update-ref", "-d", "HEAD"]); - if (preIndexTreeOid) await exec("git", ["read-tree", preIndexTreeOid]); - }); - - const tagMsg = joinStrings([...msgs, changelog], "\n\n"); - // adding explicit -a here seems to make git no longer sign the tag - writeResult(await exec("git", ["tag", "-f", "-F", "-", tagName], {stdin: {string: tagMsg}})); - rollbacks.push(async () => { - // update-ref preserves the prior tag's type (annotated vs lightweight); `tag -f ` - // would create a lightweight tag pointing at the prior tag-object OID. - if (priorLocalTagOid) await exec("git", ["update-ref", tagRef, priorLocalTagOid]); - else await exec("git", ["tag", "-d", tagName]); - }); +type RemoteState = {branch: string | null; tag: string | null}; - if (noPush) return; - const headOid = (await exec("git", ["rev-parse", "HEAD"])).stdout.trim(); - writeResult(await exec("git", ["push", pushRemote, pushBranch, tagName])); +// ls-remote needs the push URL explicitly: the default fetch URL can differ from the push URL. +async function probeRemote(pushRemote: string, branchRef: string, tagRef: string): Promise { + try { + const {stdout: pushUrl} = await exec("git", ["remote", "get-url", "--push", pushRemote]); + const {stdout} = await exec("git", ["ls-remote", pushUrl.trim(), branchRef, tagRef]); + let branch: string | null = null, tag: string | null = null; + for (const line of stdout.split(reNewline)) { + const [oid, ref] = line.split(/\s+/); + if (ref === branchRef) branch = oid; + else if (ref === tagRef) tag = oid; + } + return {branch, tag}; + } catch { + return null; + } +} - if (remoteProbe?.ok) { - // --force-with-lease guards against concurrent pushes overwriting work - rollbacks.push(async () => { - if (remoteProbe.branch) { - await exec("git", ["push", `--force-with-lease=${branchRef}:${headOid}`, pushRemote, `${remoteProbe.branch}:${branchRef}`]); - } else { - await exec("git", ["push", pushRemote, `:${branchRef}`]); - } - }); - rollbacks.push(async () => { - if (remoteProbe.tag) { - await exec("git", ["push", "--force", pushRemote, `${remoteProbe.tag}:${tagRef}`]); - } else { - await exec("git", ["push", pushRemote, `:${tagRef}`]); +// Authenticated GET on the forge repo endpoint — verifies host reachability, token validity, +// and (where the forge exposes it) the token's push permission. Catches the common failure +// modes before the push so create-release after a successful push is unlikely to fail. +async function pingForge(repoInfo: RepoInfo, tokens: string[]): Promise { + const url = forgeApiBase(repoInfo); + const label = "forge ping"; + try { + await withTokens(repoInfo.type === "github", tokens, async (authHeader) => { + const response = await forgeFetch("GET", url, authHeader, label); + await ensureOk(response, label); + // Both GitHub and Gitea return `permissions: {push, admin, pull, ...}` on authenticated + // repo GETs. If the field is present and push/admin are both false, release creation + // will 403 — abort now rather than after the push has landed. + const body = await response.json().catch(() => null); + const perms = body?.permissions; + if (perms && perms.push !== true && perms.admin !== true) { + throw new Error(`${label}: token lacks push permission on ${repoInfo.owner}/${repoInfo.repo}`); } }); - } else { - // probe failed — guessing the prior remote state could destroy refs we don't own - rollbacks.push(() => { - console.error(`rollback skipped: could not capture remote state for ${pushRemote} before push; verify branch ${pushBranch} and tag ${tagName} manually`); - }); + return null; + } catch (err: any) { + return err?.message || "unknown error"; } } @@ -600,16 +573,6 @@ async function main(): Promise { end(); } - const today = new Date().toISOString().substring(0, 10); - const date = args.date ? today : ""; - - const pwd = cwd(); - const gitDir = findUp(".git", pwd); - const projectRoot = gitDir ? dirname(gitDir) : pwd; - const pushRemote = typeof args.remote === "string" ? args.remote : "origin"; - - files = files.map(file => relative(pwd, file)); - if (level === "prerelease" && !args.preid) { throw new Error("prerelease requires --preid option"); } @@ -620,50 +583,62 @@ async function main(): Promise { throw new Error("--no-push and --release are mutually exclusive"); } + // === GATHER === pure reads + computation; no side effects. + const today = new Date().toISOString().substring(0, 10); + const date = args.date ? today : ""; + + const pwd = cwd(); + const gitDir = findUp(".git", pwd); + const projectRoot = gitDir ? dirname(gitDir) : pwd; + const pushRemote = typeof args.remote === "string" ? args.remote : "origin"; + + files = files.map(file => relative(pwd, file)); + const wantRelease = Boolean(args.release); const willCommit = !args.gitless && !args.dry; const willPush = willCommit && !args["no-push"]; - const repoInfoPromise = wantRelease ? getRepoInfo(undefined, pushRemote) : null; - const tokensPromise = wantRelease ? repoInfoPromise!.then(info => - !info ? [] : info.type === "github" ? getGithubTokens() : getGiteaTokens()) : null; - - const baseVersionPromise = resolveBaseVersion(typeof args.base === "string" ? args.base : undefined, Boolean(args.gitless), projectRoot); - // resolve push branch early so detached HEAD fails before commit/tag - const pushBranchPromise = willPush ? (async () => { - if (typeof args.branch === "string") return args.branch; - const {stdout: branchOut} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]); - return branchOut.trim(); - })() : Promise.resolve(""); + // Resolve push branch up front so detached HEAD fails before any other work. + let pushBranch = ""; + if (willPush) { + if (typeof args.branch === "string") { + pushBranch = args.branch; + } else { + const {stdout: branchOut} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]); + pushBranch = branchOut.trim(); + } + if (pushBranch === "HEAD") { + throw new Error("Cannot push from detached HEAD. Pass --branch or --no-push."); + } + } - const {baseVersion, baseSource, describeTag} = await baseVersionPromise; + const {baseVersion, baseSource, describeTag} = await resolveBaseVersion( + typeof args.base === "string" ? args.base : undefined, + Boolean(args.gitless), + projectRoot, + ); if (args.gitless && !baseVersion) { throw new Error(`--gitless requires --base to be set or a version in package.json or pyproject.toml`); } logVerbose(`base version ${baseVersion} from ${baseSource}`); - const pushBranch = await pushBranchPromise; - if (pushBranch === "HEAD") { - throw new Error("Cannot push from detached HEAD. Pass --branch or --no-push."); - } - const newVersion = incrementSemver(baseVersion, level, typeof args.preid === "string" ? args.preid : undefined); logVerbose(`new version ${newVersion}`); const replacements: Array<{re: RegExp, replacement: string}> = []; for (const replaceStr of stringArgs(args.replace)) { let [, re, replacement, flags] = (reReplaceString.exec(replaceStr) || []); - if (!re || !replacement) { throw new Error(`Invalid replace string: ${replaceStr}`); } - replacement = replaceTokens(replacement, newVersion); replacements.push({re: new RegExp(re, flags || undefined), replacement}); } const msgs = stringArgs(args.message); const tagName = args.prefix ? `v${newVersion}` : newVersion; + const branchRef = `refs/heads/${pushBranch}`; + const tagRef = `refs/tags/${tagName}`; const changelogInfo = (() => { const path = findUp("CHANGELOG.md", projectRoot); @@ -683,81 +658,110 @@ async function main(): Promise { const changelogRel = changelogInfo ? relative(pwd, changelogInfo.path) : null; if (changelogRel) files = files.filter(file => file !== changelogRel); - const allFiles = changelogInfo?.updated ? [...files, changelogRel!] : files; - const filesToAddPromise = (!args.gitless && !args.all && allFiles.length) ? removeIgnoredFiles(allFiles) : null; - const changelogPromise = willCommit ? (async () => { - if (changelogInfo) { - logVerbose(`using changelog entry from ${changelogInfo.path}`); - return changelogInfo.entry; + // Compute file changes WITHOUT writing — pure dry-run of the replacement pipeline. + type FileChange = {path: string; oldData: string; newData: string; changed: boolean}; + const fileChanges: FileChange[] = []; + for (const file of files) { + const [newData, oldData] = getFileChanges({file, baseVersion, newVersion, replacements, date}); + if (newData === null) { + logVerbose(`skipping ${file} (unhandled lockfile)`); + continue; } + fileChanges.push({path: file, oldData: oldData!, newData, changed: newData !== oldData}); + } - let range = ""; - const tagExists = await exec("git", ["rev-parse", "--verify", `refs/tags/${tagName}`]).then(() => true, () => false); - if (tagExists) { - range = `${tagName}..HEAD`; - } else if (describeTag) { - range = `${describeTag}..HEAD`; - } - try { - const logArgs = ["log"]; - if (range) logArgs.push(range); - // https://git-scm.com/docs/pretty-formats - const {stdout} = await exec("git", [...logArgs, `--pretty=format:* %s (%aN)`]); - return stdout?.length ? stdout : undefined; - } catch { - return undefined; + const allFiles = changelogInfo?.updated ? [...files, changelogRel!] : files; + + // Probe remote + forge in parallel — both feed validate, neither has side effects. + const [remoteState, repoInfo] = await Promise.all([ + willPush ? probeRemote(pushRemote, branchRef, tagRef) : null, + wantRelease && willCommit ? getRepoInfo(undefined, pushRemote) : null, + ]); + const tokens = repoInfo ? await getForgeTokens(repoInfo) : []; + + // === VALIDATE === all checks must pass before any mutation happens. + const errors: string[] = []; + + // If files were specified (and not -a), at least one must produce a diff — otherwise + // the commit would be empty and the user's intent (bump these files) is impossible. + if (fileChanges.length > 0 && !args.all && !fileChanges.some(f => f.changed)) { + errors.push(`bumping ${baseVersion} → ${newVersion} would not change any of the specified files; the base version is likely wrong`); + } + + if (willCommit) { + // `git var` resolves env vars + config + system fallbacks the same way commit/tag will. + const identityOk = await exec("git", ["var", "GIT_AUTHOR_IDENT"]).then(() => true).catch(() => false); + if (!identityOk) { + errors.push("git author identity unavailable; configure user.name + user.email or set GIT_AUTHOR_NAME + GIT_AUTHOR_EMAIL"); } - })() : null; - // probe remote refs in parallel with file processing and commit; ls-remote needs the push URL - // explicitly (defaults to fetch URL, which can differ for github.com fetch + local bare push). - const branchRef = `refs/heads/${pushBranch}`; - const tagRef = `refs/tags/${tagName}`; - const remoteProbePromise = willPush ? (async () => { - try { - const {stdout: pushUrl} = await exec("git", ["remote", "get-url", "--push", pushRemote]); - const {stdout} = await exec("git", ["ls-remote", pushUrl.trim(), branchRef, tagRef]); - let branch: string | null = null, tag: string | null = null; - for (const line of stdout.split(reNewline)) { - const [oid, ref] = line.split(/\s+/); - if (ref === branchRef) branch = oid; - else if (ref === tagRef) tag = oid; + } + + if (willPush) { + if (!remoteState) { + errors.push(`could not query remote ${pushRemote} (not configured or unreachable)`); + } else { + if (remoteState.tag) { + errors.push(`tag ${tagName} already exists on remote ${pushRemote} at ${remoteState.tag.slice(0, 8)}; delete it or choose a different version`); } - return {branch, tag, ok: true as const}; - } catch { - return {branch: null, tag: null, ok: false as const}; + if (remoteState.branch) { + const isAncestor = await exec("git", ["merge-base", "--is-ancestor", remoteState.branch, "HEAD"]).then(() => true).catch(() => false); + if (!isAncestor) { + errors.push(`local HEAD is not a descendant of ${pushRemote}/${pushBranch} (${remoteState.branch.slice(0, 8)}); fetch and integrate before bumping`); + } + } + } + } + + if (wantRelease && willCommit) { + if (!repoInfo) { + errors.push("--release: could not detect a forge from the git remote URL"); + } else if (!tokens.length) { + errors.push(`--release: no ${forgeName(repoInfo)} token found in environment`); + } else { + const forgeErr = await pingForge(repoInfo, tokens); + if (forgeErr) errors.push(`--release: forge unreachable or token rejected: ${forgeErr}`); } - })() : null; + } + + if (errors.length > 0) { + for (const e of errors) console.error(`error: ${e}`); + exit(1); + } + + // === EXECUTE === mutations only — every realistic failure mode was caught above. + // preserve user's staged hunks on rollback (--soft would leave our changes staged) + const [preIndexTreeOid, priorLocalTagOid] = willCommit ? await Promise.all([ + exec("git", ["write-tree"]).then(r => r.stdout.trim()).catch(() => null), + exec("git", ["rev-parse", "--verify", tagRef]).then(r => r.stdout.trim()).catch(() => null), + ]) : [null, null]; - // drained in reverse on failure to restore working tree, local refs, and remote refs + // Pre-push rollback only — once the atomic push lands, we leave the remote alone. const rollbacks: Array<() => Promise | void> = []; + let pushed = false; try { const originals = new Map(); rollbacks.push(() => { - for (const [file, content] of originals) write(file, content); + for (const [path, content] of originals) write(path, content); }); - for (const file of files) { - const [newData, oldData] = getFileChanges({file, baseVersion, newVersion, replacements, date}); - if (newData !== null) { - if (!originals.has(file)) originals.set(file, oldData!); - logVerbose(`writing ${file}`); - write(file, newData); - } else { - logVerbose(`skipping ${file} (unhandled lockfile)`); - } - } + for (const f of fileChanges) { + if (!f.changed) continue; + originals.set(f.path, f.oldData); + logVerbose(`writing ${f.path}`); + write(f.path, f.newData); + } if (changelogInfo?.updated) { - const {path, original, updated} = changelogInfo; - if (!originals.has(path)) originals.set(path, original); - logVerbose(`updating heading date in ${path}`); - write(path, updated); + originals.set(changelogInfo.path, changelogInfo.original); + logVerbose(`updating heading date in ${changelogInfo.path}`); + write(changelogInfo.path, changelogInfo.updated); } if (typeof args.command === "string") { logVerbose(`running command: ${args.command}`); writeResult(await exec(args.command, [], {shell: true})); } + if (args.gitless) { logVerbose("gitless — skipping commit, tag, and release"); return; @@ -765,46 +769,87 @@ async function main(): Promise { if (args.dry) { logVerbose("dry run — skipping commit and tag"); - return console.info(`Would create new tag and commit: ${tagName}`); + console.info(`Would create new tag and commit: ${tagName}`); + return; } - const changelog = (await changelogPromise) ?? undefined; - await commitTagPush({ - tagName, msgs, changelog, - all: Boolean(args.all), - noPush: Boolean(args["no-push"]), - filesToAdd: (await filesToAddPromise) ?? [], - pushRemote, pushBranch, branchRef, tagRef, - remoteProbe: (await remoteProbePromise) ?? null, - rollbacks, + // Commit-specific data — resolved here so dry/gitless paths skip the work entirely. + const filesToAdd = !args.all && allFiles.length ? await removeIgnoredFiles(allFiles) : []; + const changelogBody = await (async () => { + if (changelogInfo) { + logVerbose(`using changelog entry from ${changelogInfo.path}`); + return changelogInfo.entry; + } + let range = ""; + const tagExists = await exec("git", ["rev-parse", "--verify", tagRef]).then(() => true, () => false); + if (tagExists) { + range = `${tagName}..HEAD`; + } else if (describeTag) { + range = `${describeTag}..HEAD`; + } + try { + const logArgs = ["log"]; + if (range) logArgs.push(range); + // https://git-scm.com/docs/pretty-formats + const {stdout} = await exec("git", [...logArgs, `--pretty=format:* %s (%aN)`]); + return stdout?.length ? stdout : undefined; + } catch { + return undefined; + } + })(); + const commitMsg = joinStrings([tagName, ...msgs, changelogBody], "\n\n"); + const tagMsg = joinStrings([...msgs, changelogBody], "\n\n"); + const commitArgs = args.all ? + ["commit", "-a", "--allow-empty", "-F", "-"] : + filesToAdd.length ? + ["commit", "-i", "-F", "-", "--", ...filesToAdd] : + ["commit", "--allow-empty", "-F", "-"]; + + writeResult(await exec("git", commitArgs, {stdin: {string: commitMsg}})); + rollbacks.push(async () => { + const hasParent = await exec("git", ["rev-parse", "HEAD^"]).then(() => true, () => false); + if (hasParent) await exec("git", ["reset", "--soft", "HEAD^"]); + else await exec("git", ["update-ref", "-d", "HEAD"]); + if (preIndexTreeOid) await exec("git", ["read-tree", preIndexTreeOid]); + }); + + // adding explicit -a here seems to make git no longer sign the tag + writeResult(await exec("git", ["tag", "-f", "-F", "-", tagName], {stdin: {string: tagMsg}})); + rollbacks.push(async () => { + // update-ref preserves the prior tag's type (annotated vs lightweight); `tag -f ` + // would create a lightweight tag pointing at the prior tag-object OID. + if (priorLocalTagOid) await exec("git", ["update-ref", tagRef, priorLocalTagOid]); + else await exec("git", ["tag", "-d", tagName]); }); + if (!willPush) return; + + // --atomic: server-side all-or-nothing. Either both refs update or neither does; + // partial state (the orphan-tag bug) is impossible. + writeResult(await exec("git", ["push", "--atomic", pushRemote, pushBranch, tagName])); + pushed = true; + if (wantRelease) { - const repoInfo = await repoInfoPromise!; - if (!repoInfo) { - throw new Error("Could not determine repository type from git remote. Only GitHub and Gitea repositories are supported for release creation."); - } - const forgeName = repoInfo.type === "github" ? "GitHub" : "Gitea"; - const tokens = await tokensPromise!; - if (!tokens.length) { - throw new Error(`${forgeName} release requested but no token found in environment`); - } - logVerbose(`creating ${forgeName} release for ${tagName} (${tokens.length} token${tokens.length === 1 ? "" : "s"} to try)`); - const created = await createForgeRelease(repoInfo, tagName, changelog || tagName, tokens); - if (created) { - // Pushed last so it runs first on rollback (LIFO): deleting the release before the - // tag-delete push prevents Gitea from converting the release into a draft. - rollbacks.push(async () => { - await deleteForgeRelease(repoInfo, created.id, tokens); - }); + logVerbose(`creating ${forgeName(repoInfo!)} release for ${tagName} (${tokens.length} token${tokens.length === 1 ? "" : "s"} to try)`); + try { + await createForgeRelease(repoInfo!, tagName, changelogBody || tagName, tokens); + } catch (err: any) { + // Validate confirmed the forge was reachable with push permission, so reaching here + // means a transient failure during create. The tag is pushed and shared — leave it + // and tell the user how to recover rather than force-pushing remote history. + console.error(`Tag ${tagName} was pushed to ${pushRemote} but release creation failed: ${err.message}`); + console.error(`To finish the release, create it manually on ${forgeName(repoInfo!)} for the existing tag (e.g. via the web UI, \`gh release create ${tagName}\`, or \`tea release create --tag ${tagName}\`). Rerunning versions for this version would be rejected because the tag already exists on the remote.`); + throw err; } } } catch (err) { - for (const rollback of rollbacks.reverse()) { - try { - await rollback(); - } catch (cleanupErr: any) { - console.error(`rollback failed: ${cleanupErr.message}`); + if (!pushed) { + for (const rollback of rollbacks.reverse()) { + try { + await rollback(); + } catch (cleanupErr: any) { + console.error(`rollback failed: ${cleanupErr.message}`); + } } } throw err; From bec7d1f392fe1ddef7160164c8c41852b63c17a4 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 28 May 2026 09:06:47 +0200 Subject: [PATCH 2/5] Parallelize gather + validate I/O Fires every independent probe up front (resolveBaseVersion, pushBranch, identity check, getRepoInfo + tokens + pingForge, later probeRemote + merge-base ancestor check). validate collects everything in one Promise.all instead of awaiting each git/HTTPS call sequentially. The critical path drops from ~450ms to whichever single network call is slowest. Co-Authored-By: Claude (Opus 4.7) --- index.ts | 84 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 38 deletions(-) diff --git a/index.ts b/index.ts index 3b12b6d..430ec7e 100755 --- a/index.ts +++ b/index.ts @@ -598,28 +598,41 @@ async function main(): Promise { const willCommit = !args.gitless && !args.dry; const willPush = willCommit && !args["no-push"]; - // Resolve push branch up front so detached HEAD fails before any other work. - let pushBranch = ""; - if (willPush) { - if (typeof args.branch === "string") { - pushBranch = args.branch; - } else { - const {stdout: branchOut} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]); - pushBranch = branchOut.trim(); - } - if (pushBranch === "HEAD") { - throw new Error("Cannot push from detached HEAD. Pass --branch or --no-push."); - } - } - - const {baseVersion, baseSource, describeTag} = await resolveBaseVersion( + // Fire every independent I/O probe in parallel. Each resolves to a value validate awaits; + // the chain repoInfo → tokens → pingForge is the only inherently sequential one. + const baseVersionP = resolveBaseVersion( typeof args.base === "string" ? args.base : undefined, Boolean(args.gitless), projectRoot, ); + const pushBranchP: Promise = willPush ? (async () => { + if (typeof args.branch === "string") return args.branch; + const {stdout} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]); + return stdout.trim(); + })() : Promise.resolve(""); + const identityOkP: Promise = willCommit ? + exec("git", ["var", "GIT_AUTHOR_IDENT"]).then(() => true, () => false) : + Promise.resolve(true); + const repoInfoP: Promise = wantRelease && willCommit ? + getRepoInfo(undefined, pushRemote) : + Promise.resolve(null); + const tokensP: Promise = repoInfoP.then(info => info ? getForgeTokens(info) : []); + const pingResultP: Promise = (async () => { + const [info, toks] = await Promise.all([repoInfoP, tokensP]); + if (!info || !toks.length) return null; + return pingForge(info, toks); + })(); + + // baseVersion + pushBranch unblock tagRef/branchRef computation; throw the two fatal + // configuration errors that can't sensibly be deferred to validate (incrementSemver + // would otherwise blow up on an empty base). + const [{baseVersion, baseSource, describeTag}, pushBranch] = await Promise.all([baseVersionP, pushBranchP]); if (args.gitless && !baseVersion) { throw new Error(`--gitless requires --base to be set or a version in package.json or pyproject.toml`); } + if (willPush && pushBranch === "HEAD") { + throw new Error("Cannot push from detached HEAD. Pass --branch or --no-push."); + } logVerbose(`base version ${baseVersion} from ${baseSource}`); const newVersion = incrementSemver(baseVersion, level, typeof args.preid === "string" ? args.preid : undefined); @@ -640,6 +653,15 @@ async function main(): Promise { const branchRef = `refs/heads/${pushBranch}`; const tagRef = `refs/tags/${tagName}`; + // probeRemote + the ancestor check are the second slow chain; kick them off now and + // do the sync work below in the meantime. + const remoteStateP = willPush ? probeRemote(pushRemote, branchRef, tagRef) : Promise.resolve(null); + const mergeBaseOkP: Promise = (async () => { + const state = await remoteStateP; + if (!state || !state.branch) return true; + return exec("git", ["merge-base", "--is-ancestor", state.branch, "HEAD"]).then(() => true, () => false); + })(); + const changelogInfo = (() => { const path = findUp("CHANGELOG.md", projectRoot); if (!path) return null; @@ -672,14 +694,11 @@ async function main(): Promise { const allFiles = changelogInfo?.updated ? [...files, changelogRel!] : files; - // Probe remote + forge in parallel — both feed validate, neither has side effects. - const [remoteState, repoInfo] = await Promise.all([ - willPush ? probeRemote(pushRemote, branchRef, tagRef) : null, - wantRelease && willCommit ? getRepoInfo(undefined, pushRemote) : null, + // === VALIDATE === single await collects every probe; checks below are pure. + const [remoteState, repoInfo, tokens, identityOk, pingResult, mergeBaseOk] = await Promise.all([ + remoteStateP, repoInfoP, tokensP, identityOkP, pingResultP, mergeBaseOkP, ]); - const tokens = repoInfo ? await getForgeTokens(repoInfo) : []; - // === VALIDATE === all checks must pass before any mutation happens. const errors: string[] = []; // If files were specified (and not -a), at least one must produce a diff — otherwise @@ -687,15 +706,9 @@ async function main(): Promise { if (fileChanges.length > 0 && !args.all && !fileChanges.some(f => f.changed)) { errors.push(`bumping ${baseVersion} → ${newVersion} would not change any of the specified files; the base version is likely wrong`); } - - if (willCommit) { - // `git var` resolves env vars + config + system fallbacks the same way commit/tag will. - const identityOk = await exec("git", ["var", "GIT_AUTHOR_IDENT"]).then(() => true).catch(() => false); - if (!identityOk) { - errors.push("git author identity unavailable; configure user.name + user.email or set GIT_AUTHOR_NAME + GIT_AUTHOR_EMAIL"); - } + if (willCommit && !identityOk) { + errors.push("git author identity unavailable; configure user.name + user.email or set GIT_AUTHOR_NAME + GIT_AUTHOR_EMAIL"); } - if (willPush) { if (!remoteState) { errors.push(`could not query remote ${pushRemote} (not configured or unreachable)`); @@ -703,23 +716,18 @@ async function main(): Promise { if (remoteState.tag) { errors.push(`tag ${tagName} already exists on remote ${pushRemote} at ${remoteState.tag.slice(0, 8)}; delete it or choose a different version`); } - if (remoteState.branch) { - const isAncestor = await exec("git", ["merge-base", "--is-ancestor", remoteState.branch, "HEAD"]).then(() => true).catch(() => false); - if (!isAncestor) { - errors.push(`local HEAD is not a descendant of ${pushRemote}/${pushBranch} (${remoteState.branch.slice(0, 8)}); fetch and integrate before bumping`); - } + if (remoteState.branch && !mergeBaseOk) { + errors.push(`local HEAD is not a descendant of ${pushRemote}/${pushBranch} (${remoteState.branch.slice(0, 8)}); fetch and integrate before bumping`); } } } - if (wantRelease && willCommit) { if (!repoInfo) { errors.push("--release: could not detect a forge from the git remote URL"); } else if (!tokens.length) { errors.push(`--release: no ${forgeName(repoInfo)} token found in environment`); - } else { - const forgeErr = await pingForge(repoInfo, tokens); - if (forgeErr) errors.push(`--release: forge unreachable or token rejected: ${forgeErr}`); + } else if (pingResult) { + errors.push(`--release: forge unreachable or token rejected: ${pingResult}`); } } From 39ebb9f5b9650e3ddcd4d028708db7e5f330699a Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 28 May 2026 09:21:00 +0200 Subject: [PATCH 3/5] pingForge: retry next token when one lacks push permission Reviewer feedback: throwing a plain Error from the push-permission check short-circuits withTokens, which only retries on AuthRetryable. A user with two tokens where the first lacks push would have validate fail even though the second token would succeed. Switch to AuthRetryable so withTokens falls through. Co-Authored-By: Claude (Opus 4.7) --- index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 430ec7e..d482cc6 100755 --- a/index.ts +++ b/index.ts @@ -493,11 +493,12 @@ async function pingForge(repoInfo: RepoInfo, tokens: string[]): Promise null); const perms = body?.permissions; if (perms && perms.push !== true && perms.admin !== true) { - throw new Error(`${label}: token lacks push permission on ${repoInfo.owner}/${repoInfo.repo}`); + throw new AuthRetryable(`${label}: token lacks push permission on ${repoInfo.owner}/${repoInfo.repo}`); } }); return null; From e9d690d01912dc470a5687d025aa5629ece903e0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 28 May 2026 09:28:51 +0200 Subject: [PATCH 4/5] Address Copilot review: skipped-only files + 404 retry * Use raw `files` count for the no-op check so a run that only specified unhandled lockfiles aborts instead of failing later at "nothing to commit". Gated on !args.gitless to preserve the existing "lockfile silently skipped" behavior in gitless mode. * pingForge treats 404 as auth-retryable: GitHub returns 404 (not 403) for private repos when the token lacks read access, so withTokens needs to fall through to the next token. Co-Authored-By: Claude (Opus 4.7) --- index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index d482cc6..4884507 100755 --- a/index.ts +++ b/index.ts @@ -490,6 +490,11 @@ async function pingForge(repoInfo: RepoInfo, tokens: string[]): Promise { const response = await forgeFetch("GET", url, authHeader, label); + // GitHub returns 404 for private repos when the token lacks read access (info hiding); + // treat it like 401/403 so withTokens falls through to the next token. + if (response.status === 404) { + throw new AuthRetryable(`${label}: 404 (token may lack access to ${repoInfo.owner}/${repoInfo.repo})`); + } await ensureOk(response, label); // Both GitHub and Gitea return `permissions: {push, admin, pull, ...}` on authenticated // repo GETs. If the field is present and push/admin are both false, release creation @@ -703,8 +708,10 @@ async function main(): Promise { const errors: string[] = []; // If files were specified (and not -a), at least one must produce a diff — otherwise - // the commit would be empty and the user's intent (bump these files) is impossible. - if (fileChanges.length > 0 && !args.all && !fileChanges.some(f => f.changed)) { + // git commit -i with unchanged files would fail "nothing to commit". Use the raw input + // count (`files`), not `fileChanges`, so a run that only specified unhandled lockfiles + // also aborts. Skipped in --gitless because nothing will commit anyway. + if (!args.gitless && files.length > 0 && !args.all && !fileChanges.some(f => f.changed)) { errors.push(`bumping ${baseVersion} → ${newVersion} would not change any of the specified files; the base version is likely wrong`); } if (willCommit && !identityOk) { From 8bdc3ed3b0bbb080a9e46132ff59d86cb7f68df5 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 28 May 2026 09:30:41 +0200 Subject: [PATCH 5/5] pingForge: clarify 404 behavior covers Gitea too Co-Authored-By: Claude (Opus 4.7) --- index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 4884507..e30f859 100755 --- a/index.ts +++ b/index.ts @@ -490,8 +490,9 @@ async function pingForge(repoInfo: RepoInfo, tokens: string[]): Promise { const response = await forgeFetch("GET", url, authHeader, label); - // GitHub returns 404 for private repos when the token lacks read access (info hiding); - // treat it like 401/403 so withTokens falls through to the next token. + // Both GitHub and Gitea return 404 (not 403) for private repos when the token + // lacks read access, to avoid leaking repo existence; treat it like 401/403 so + // withTokens falls through to the next token. if (response.status === 404) { throw new AuthRetryable(`${label}: 404 (token may lack access to ${repoInfo.owner}/${repoInfo.repo})`); }