Skip to content

Commit f3a1ffe

Browse files
silverwindclaude
andcommitted
fix: nested version corruption, CHANGELOG double-write, auth retry
- package.json: lazy regex `("version":[^]*?")\d+\.\d+\.\d+` matched the first nested numeric `"version"` (npm overrides, yarn resolutions, scripts), leaving the top-level field untouched. Switch to JSON parse/stringify with indent detection. - pyproject.toml: `/gm` matched every `^version = "..."` line, including unrelated tool sections. Scope to [project] / [tool.poetry] via a line walker. - CHANGELOG.md: when the user passed it explicitly, the generic baseVersion replacement rewrote prior version headings, and the date-update block then overwrote that. Strip the user-passed entry before the generic loop. - tomlGetString: early return on the first non-matching section break dropped later re-occurrences of the target section. - deleteMatchingDrafts: 401/403 now throws AuthRetryable so withTokens falls through to the next token. Also: extract `authOrError` (4 call sites), hoist `pyprojectVersionSections`. Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
1 parent c8ad494 commit f3a1ffe

3 files changed

Lines changed: 66 additions & 17 deletions

File tree

index.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,27 @@ test("getFileChanges pyproject.toml", () => withTmpDir(async (tmpDir) => {
13641364
expect(content).toContain(`version = "1.1.0"`);
13651365
}));
13661366

1367+
test("getFileChanges package.json leaves nested version fields alone", () => withTmpDir(async (tmpDir) => {
1368+
const file = join(tmpDir, "package.json");
1369+
await writeFile(file, JSON.stringify({
1370+
name: "foo",
1371+
overrides: {"some-pkg": {version: "1.0.0"}},
1372+
version: "1.0.0",
1373+
}, null, 2));
1374+
const [content] = getFileChanges({file, baseVersion: "1.0.0", newVersion: "2.0.0"});
1375+
const parsed = JSON.parse(content!);
1376+
expect(parsed.version).toEqual("2.0.0");
1377+
expect(parsed.overrides["some-pkg"].version).toEqual("1.0.0");
1378+
}));
1379+
1380+
test("getFileChanges pyproject.toml leaves unrelated section version alone", () => withTmpDir(async (tmpDir) => {
1381+
const file = join(tmpDir, "pyproject.toml");
1382+
await writeFile(file, `[project]\nname = "test"\nversion = "1.0.0"\n\n[tool.someplugin]\nversion = "1.0.0"\n`);
1383+
const [content] = getFileChanges({file, baseVersion: "1.0.0", newVersion: "2.0.0"});
1384+
expect(content).toContain(`[project]\nname = "test"\nversion = "2.0.0"`);
1385+
expect(content).toContain(`[tool.someplugin]\nversion = "1.0.0"`);
1386+
}));
1387+
13671388
test("getFileChanges uv.lock", () => withTmpDir(async (tmpDir) => {
13681389
await writeFile(join(tmpDir, "pyproject.toml"), `[project]\nname = "myapp"\nversion = "1.0.0"\n`);
13691390
const file = join(tmpDir, "uv.lock");

index.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const rePrereleaseIdNum = /^([a-zA-Z0-9-]+)\.(\d+)$/;
1616
const reDatePattern = /(?<=[^0-9]|^)[0-9]{4}-[0-9]{2}-[0-9]{2}(?=[^0-9]|$)/g;
1717
const reDate = new RegExp(reDatePattern.source);
1818
const reReplaceString = /^s#([^#]+)#([^#]+)#(.*)$/;
19+
const pyprojectVersionSections: readonly string[] = ["project", "tool.poetry"];
1920

2021
function stripV(str: string): string {
2122
return str[0] === "v" ? str.slice(1) : str;
@@ -88,9 +89,11 @@ export function readVersionFromPackageJson(projectRoot: string): string | null {
8889
8990
export function readVersionFromPyprojectToml(projectRoot: string): string | null {
9091
return readVersionFile("pyproject.toml", projectRoot, content => {
91-
const project = tomlGetString(content, "project", "version");
92-
if (project && isSemver(project)) return project;
93-
return tomlGetString(content, "tool.poetry", "version");
92+
for (const section of pyprojectVersionSections) {
93+
const v = tomlGetString(content, section, "version");
94+
if (v && isSemver(v)) return v;
95+
}
96+
return undefined;
9497
});
9598
}
9699
@@ -189,17 +192,38 @@ export function getFileChanges({file, baseVersion, newVersion, replacements, dat
189192
190193
let newData: string;
191194
if (fileName === "package.json") {
192-
newData = oldData.replace(/("version":[^]*?")\d+\.\d+\.\d+(?:[^"\d][^"]*)?(")/,
193-
(_, p1, p2) => `${p1}${newVersion}${p2}`);
195+
// regex replace would corrupt nested "version" fields (overrides, resolutions, scripts.version)
196+
const pkg = JSON.parse(oldData);
197+
pkg.version = newVersion;
198+
const indent = /^\{\r?\n([ \t]+)/.exec(oldData)?.[1] ?? " ";
199+
newData = JSON.stringify(pkg, null, indent) + (oldData.endsWith("\n") ? "\n" : "");
194200
} else if (fileName === "package-lock.json") {
195201
// regex replace would corrupt nested dependency versions
196202
const lockFile = JSON.parse(oldData);
197203
if (lockFile.version) lockFile.version = newVersion; // v1 and v2
198204
if (lockFile?.packages?.[""]?.version) lockFile.packages[""].version = newVersion; // v2 and v3
199205
newData = `${JSON.stringify(lockFile, null, 2)}\n`;
200206
} else if (fileName === "pyproject.toml") {
201-
newData = oldData.replace(/(^version ?= ?["'])\d+\.\d+\.\d+(?:[^"'\d][^"']*)?(["'].*)/gm,
202-
(_, p1, p2) => `${p1}${newVersion}${p2}`);
207+
// scope to [project] / [tool.poetry] — other sections may have unrelated `version` keys
208+
const eol = /\r?\n/.exec(oldData)?.[0] ?? "\n";
209+
const lines = oldData.split(reNewline);
210+
const versionLine = /^(version ?= ?["'])\d+\.\d+\.\d+(?:[^"'\d][^"']*)?(["'].*)$/;
211+
let section: string | null = null;
212+
for (let i = 0; i < lines.length; i++) {
213+
const trimmed = lines[i].trim();
214+
if (!trimmed || trimmed[0] === "#") continue;
215+
if (trimmed[0] === "[") {
216+
section = /^\[([^[\]]+)\]/.exec(trimmed)?.[1].trim() ?? null;
217+
continue;
218+
}
219+
if (!section || !pyprojectVersionSections.includes(section)) continue;
220+
const m = versionLine.exec(lines[i]);
221+
if (m) {
222+
lines[i] = `${m[1]}${newVersion}${m[2]}`;
223+
break;
224+
}
225+
}
226+
newData = lines.join(eol);
203227
} else if (fileName === "uv.lock") {
204228
const projStr = readFileSync(file.replace(/uv\.lock$/, "pyproject.toml"), "utf8");
205229
const name = tomlGetString(projStr, "project", "name") ?? tomlGetString(projStr, "tool.poetry", "name");
@@ -316,6 +340,10 @@ async function forgeFetch(method: string, url: string, authHeader: string, jsonB
316340
// Thrown by attempt callbacks of withTokens to signal that the next token should be tried.
317341
class AuthRetryable extends Error {}
318342

343+
function authOrError(status: number, message: string): Error {
344+
return status === 401 || status === 403 ? new AuthRetryable(message) : new Error(message);
345+
}
346+
319347
async function withTokens<T>(
320348
isGithub: boolean,
321349
tokens: string[],
@@ -343,7 +371,7 @@ async function deleteMatchingDrafts(apiUrl: string, authHeader: string, tagName:
343371
throw new Error(`Failed to list releases: ${err.cause?.message || err.message || "Unknown error"}`);
344372
}
345373
if (!listResponse.ok) {
346-
throw new Error(`Failed to list releases: ${listResponse.status} ${listResponse.statusText}\n${await listResponse.text()}`);
374+
throw authOrError(listResponse.status, `Failed to list releases: ${listResponse.status} ${listResponse.statusText}\n${await listResponse.text()}`);
347375
}
348376
const releases = await listResponse.json() as Array<{id: number; tag_name: string; draft: boolean}>;
349377
const drafts = releases.filter(r => r.draft && r.tag_name === tagName);
@@ -355,7 +383,7 @@ async function deleteMatchingDrafts(apiUrl: string, authHeader: string, tagName:
355383
throw new Error(`Failed to delete draft release ${draft.id}: ${err.cause?.message || err.message || "Unknown error"}`);
356384
}
357385
if (!deleteResponse.ok && deleteResponse.status !== 404) {
358-
throw new Error(`Failed to delete draft release ${draft.id}: ${deleteResponse.status} ${deleteResponse.statusText}\n${await deleteResponse.text()}`);
386+
throw authOrError(deleteResponse.status, `Failed to delete draft release ${draft.id}: ${deleteResponse.status} ${deleteResponse.statusText}\n${await deleteResponse.text()}`);
359387
}
360388
console.info(`Deleted stale draft release for ${tagName}`);
361389
}
@@ -377,8 +405,7 @@ export async function deleteForgeRelease(repoInfo: RepoInfo, releaseId: number,
377405
throw new Error(`Failed to delete release ${releaseId}: ${err.cause?.message || err.message || "Unknown error"}`);
378406
}
379407
if (response.ok || response.status === 404) return;
380-
const message = `Failed to delete release ${releaseId}: ${response.status} ${response.statusText}\n${await response.text()}`;
381-
throw response.status === 401 || response.status === 403 ? new AuthRetryable(message) : new Error(message);
408+
throw authOrError(response.status, `Failed to delete release ${releaseId}: ${response.status} ${response.statusText}\n${await response.text()}`);
382409
});
383410
}
384411

@@ -419,8 +446,7 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo
419446
return typeof result.id === "number" ? {id: result.id, html_url: result.html_url} : null;
420447
}
421448

422-
const message = `Failed to create release: ${response.status} ${response.statusText}\n${await response.text()}`;
423-
throw response.status === 401 || response.status === 403 ? new AuthRetryable(message) : new Error(message);
449+
throw authOrError(response.status, `Failed to create release: ${response.status} ${response.statusText}\n${await response.text()}`);
424450
});
425451
}
426452

@@ -619,7 +645,11 @@ async function main(): Promise<void> {
619645
return {path, original, entry: processed.entry, updated: processed.updated};
620646
})();
621647

622-
const allFiles = changelogInfo?.updated ? [...files, relative(pwd, changelogInfo.path)] : files;
648+
// generic baseVersion replacement would rewrite prior version headings in CHANGELOG.md
649+
const changelogRel = changelogInfo ? relative(pwd, changelogInfo.path) : null;
650+
if (changelogRel) files = files.filter(file => file !== changelogRel);
651+
652+
const allFiles = changelogInfo?.updated ? [...files, changelogRel!] : files;
623653
const filesToAddPromise = (!args.gitless && !args.all && allFiles.length) ? removeIgnoredFiles(allFiles) : null;
624654
const changelogPromise = (!args.gitless && !args.dry) ? (async () => {
625655
if (changelogInfo) {

utils.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,7 @@ export function tomlGetString(content: string, section: string, key: string): st
6464
if (!trimmed || trimmed[0] === "#") continue;
6565
if (trimmed[0] === "[") {
6666
const m = /^\[([^[\]]+)\]/.exec(trimmed);
67-
const matches = m ? m[1].trim() === section : false;
68-
if (inSection && !matches) return undefined;
69-
inSection = matches;
67+
inSection = m ? m[1].trim() === section : false;
7068
continue;
7169
}
7270
if (inSection) {

0 commit comments

Comments
 (0)