@@ -16,7 +16,7 @@ const rePrereleaseIdNum = /^([a-zA-Z0-9-]+)\.(\d+)$/;
1616const reDatePattern = / (?< = [ ^ 0 - 9 ] | ^ ) [ 0 - 9 ] { 4 } - [ 0 - 9 ] { 2 } - [ 0 - 9 ] { 2 } (? = [ ^ 0 - 9 ] | $ ) / g;
1717const reDate = new RegExp ( reDatePattern . source ) ;
1818const reReplaceString = / ^ s # ( [ ^ # ] + ) # ( [ ^ # ] + ) # ( .* ) $ / ;
19- const pyprojectVersionSections : readonly string [ ] = [ "project" , "tool.poetry" ] ;
19+ const pyprojectSections : readonly string [ ] = [ "project" , "tool.poetry" ] ;
2020
2121function stripV ( str : string ) : string {
2222 return str [ 0 ] === "v" ? str . slice ( 1 ) : str ;
@@ -87,13 +87,18 @@ export function readVersionFromPackageJson(projectRoot: string): string | null {
8787 return readVersionFile("package.json", projectRoot, content => JSON.parse(content).version);
8888}
8989
90+ function pyprojectGet(content: string, key: string): string | undefined {
91+ for (const section of pyprojectSections) {
92+ const v = tomlGetString(content, section, key);
93+ if (v) return v;
94+ }
95+ return undefined;
96+ }
97+
9098export function readVersionFromPyprojectToml(projectRoot: string): string | null {
9199 return readVersionFile("pyproject.toml", projectRoot, content => {
92- for (const section of pyprojectVersionSections) {
93- const v = tomlGetString(content, section, "version");
94- if (v && isSemver(v)) return v;
95- }
96- return undefined;
100+ const v = pyprojectGet(content, "version");
101+ return v && isSemver(v) ? v : undefined;
97102 });
98103}
99104
@@ -136,29 +141,33 @@ function updateHeadingDateInLines(lines: string[], index: number, date: string,
136141 return lines.join(eol);
137142}
138143
139- // Lenient about heading shape: matches "# 1.2.3", "## v1.2.3", "## [1.2.3]",
140- // "## [1.2.3] - 2024-01-15", "## 1.2.3 (2024-01-15)", etc.
141- export function readChangelogEntry (content: string, version: string): string | null {
144+ type ChangelogLocation = {lines: string[], head: {index: number, level: number}, eol: string};
145+
146+ function locateChangelogEntry (content: string, version: string): ChangelogLocation | null {
142147 const lines = content.split(reNewline);
143148 const head = findVersionHeading(lines, version);
144149 if (!head) return null;
145- return extractEntry(lines, head);
150+ return {lines, head, eol: detectEol(content)};
151+ }
152+
153+ // Lenient about heading shape: matches "# 1.2.3", "## v1.2.3", "## [1.2.3]",
154+ // "## [1.2.3] - 2024-01-15", "## 1.2.3 (2024-01-15)", etc.
155+ export function readChangelogEntry(content: string, version: string): string | null {
156+ const loc = locateChangelogEntry(content, version);
157+ return loc ? extractEntry(loc.lines, loc.head) : null;
146158}
147159
148160export function updateChangelogHeadingDate(content: string, version: string, date: string): string | null {
149- const lines = content.split(reNewline);
150- const head = findVersionHeading(lines, version);
151- if (!head) return null;
152- return updateHeadingDateInLines(lines, head.index, date, detectEol(content));
161+ const loc = locateChangelogEntry(content, version);
162+ return loc ? updateHeadingDateInLines(loc.lines, loc.head.index, date, loc.eol) : null;
153163}
154164
155165function processChangelog(content: string, version: string, date: string): {entry: string, updated: string | null} | null {
156- const lines = content.split(reNewline);
157- const head = findVersionHeading(lines, version);
158- if (!head) return null;
159- const entry = extractEntry(lines, head);
166+ const loc = locateChangelogEntry(content, version);
167+ if (!loc) return null;
168+ const entry = extractEntry(loc.lines, loc.head);
160169 if (!entry) return null;
161- return {entry, updated: updateHeadingDateInLines(lines, head.index, date, detectEol(content) )};
170+ return {entry, updated: updateHeadingDateInLines(loc. lines, loc. head.index, date, loc.eol )};
162171}
163172
164173export async function removeIgnoredFiles(files: Array<string>, cwd?: string): Promise<Array<string>> {
@@ -215,7 +224,7 @@ export function getFileChanges({file, baseVersion, newVersion, replacements, dat
215224 section = / ^ \[ ( [ ^ [ \] ] + ) \] / . exec ( trimmed ) ?. [ 1 ] . trim ( ) ?? null ;
216225 continue ;
217226 }
218- if ( ! section || ! pyprojectVersionSections . includes ( section ) ) continue ;
227+ if ( ! section || ! pyprojectSections . includes ( section ) ) continue ;
219228 const m = versionLine . exec ( lines [ i ] ) ;
220229 if ( m ) {
221230 lines [ i ] = `${ m [ 1 ] } ${ newVersion } ${ m [ 2 ] } ` ;
@@ -225,7 +234,7 @@ export function getFileChanges({file, baseVersion, newVersion, replacements, dat
225234 newData = lines . join ( eol ) ;
226235 } else if ( fileName === "uv.lock" ) {
227236 const projStr = readFileSync ( file . replace ( / u v \. l o c k $ / , "pyproject.toml" ) , "utf8" ) ;
228- const name = tomlGetString ( projStr , "project" , "name" ) ?? tomlGetString ( projStr , "tool.poetry" , "name" ) ;
237+ const name = pyprojectGet ( projStr , "name" ) ;
229238 if ( ! name ) throw new Error ( `Could not determine project name from pyproject.toml for ${ file } ` ) ;
230239 const re = new RegExp ( `(\\[\\[package\\]\\]\r?\nname = "${ esc ( name ) } "\r?\nversion = ").+?(")` ) ;
231240 newData = oldData . replace ( re , `$1${ newVersion } $2` ) ;
@@ -324,14 +333,19 @@ export async function getRepoInfo(cwd?: string, remote: string = "origin"): Prom
324333 }
325334}
326335
327- async function forgeFetch ( method : string , url : string , authHeader : string , jsonBody ?: string ) : Promise < Response > {
336+ async function forgeFetch ( method : string , url : string , authHeader : string , label : string , jsonBody ?: string ) : Promise < Response > {
328337 logVerbose ( `${ colorize ( method , "magenta" ) } ${ url } ` ) ;
329338 const init : RequestInit = { method, headers : { Authorization : authHeader } } ;
330339 if ( jsonBody !== undefined ) {
331340 ( init . headers as Record < string , string > ) [ "Content-Type" ] = "application/json" ;
332341 init . body = jsonBody ;
333342 }
334- const response = await fetch ( url , init ) ;
343+ let response : Response ;
344+ try {
345+ response = await fetch ( url , init ) ;
346+ } catch ( err : any ) {
347+ throw new Error ( `${ label } : ${ err . cause ?. message || err . message || "Unknown error" } ` ) ;
348+ }
335349 logVerbose ( `${ colorize ( String ( response . status ) , response . ok ? "green" : "red" ) } ${ url } ` ) ;
336350 return response ;
337351}
@@ -343,6 +357,16 @@ function authOrError(status: number, message: string): Error {
343357 return status === 401 || status === 403 ? new AuthRetryable ( message ) : new Error ( message ) ;
344358}
345359
360+ function forgeApiBase ( repoInfo : RepoInfo ) : string {
361+ const host = repoInfo . type === "github" ? "api.github.com" : `${ repoInfo . host } /api/v1` ;
362+ return `https://${ host } /repos/${ repoInfo . owner } /${ repoInfo . repo } ` ;
363+ }
364+
365+ async function ensureOk ( response : Response , label : string , allow404 = false ) : Promise < void > {
366+ if ( response . ok || ( allow404 && response . status === 404 ) ) return ;
367+ throw authOrError ( response . status , `${ label } : ${ response . status } ${ response . statusText } \n${ await response . text ( ) } ` ) ;
368+ }
369+
346370async function withTokens < T > (
347371 isGithub : boolean ,
348372 tokens : string [ ] ,
@@ -363,27 +387,15 @@ async function withTokens<T>(
363387}
364388
365389async function deleteMatchingDrafts ( apiUrl : string , authHeader : string , tagName : string ) : Promise < number > {
366- let listResponse : Response ;
367- try {
368- listResponse = await forgeFetch ( "GET" , `${ apiUrl } ?draft=true&limit=50&per_page=100` , authHeader ) ;
369- } catch ( err : any ) {
370- throw new Error ( `Failed to list releases: ${ err . cause ?. message || err . message || "Unknown error" } ` ) ;
371- }
372- if ( ! listResponse . ok ) {
373- throw authOrError ( listResponse . status , `Failed to list releases: ${ listResponse . status } ${ listResponse . statusText } \n${ await listResponse . text ( ) } ` ) ;
374- }
390+ const listLabel = "Failed to list releases" ;
391+ const listResponse = await forgeFetch ( "GET" , `${ apiUrl } ?draft=true&limit=50&per_page=100` , authHeader , listLabel ) ;
392+ await ensureOk ( listResponse , listLabel ) ;
375393 const releases = await listResponse . json ( ) as Array < { id : number ; tag_name: string ; draft: boolean } > ;
376394 const drafts = releases . filter ( r => r . draft && r . tag_name === tagName ) ;
377395 for ( const draft of drafts ) {
378- let deleteResponse : Response ;
379- try {
380- deleteResponse = await forgeFetch ( "DELETE" , `${ apiUrl } /${ draft . id } ` , authHeader ) ;
381- } catch ( err : any ) {
382- throw new Error ( `Failed to delete draft release ${ draft . id } : ${ err . cause ?. message || err . message || "Unknown error" } ` ) ;
383- }
384- if ( ! deleteResponse . ok && deleteResponse . status !== 404 ) {
385- throw authOrError ( deleteResponse . status , `Failed to delete draft release ${ draft . id } : ${ deleteResponse . status } ${ deleteResponse . statusText } \n${ await deleteResponse . text ( ) } ` ) ;
386- }
396+ const label = `Failed to delete draft release ${ draft . id } ` ;
397+ const deleteResponse = await forgeFetch ( "DELETE" , `${ apiUrl } /${ draft . id } ` , authHeader , label ) ;
398+ await ensureOk ( deleteResponse , label , true ) ;
387399 console . info ( `Deleted stale draft release for ${ tagName } ` ) ;
388400 }
389401 return drafts . length ;
@@ -392,27 +404,18 @@ async function deleteMatchingDrafts(apiUrl: string, authHeader: string, tagName:
392404export type CreatedRelease = { id : number ; html_url ? : string } ;
393405
394406export async function deleteForgeRelease ( repoInfo : RepoInfo , releaseId : number , tokens : string [ ] ) : Promise < void > {
395- const isGithub = repoInfo . type === "github" ;
396- const apiHost = isGithub ? "api.github.com" : `${ repoInfo . host } /api/v1` ;
397- const url = `https://${ apiHost } /repos/${ repoInfo . owner } /${ repoInfo . repo } /releases/${ releaseId } ` ;
407+ const url = `${ forgeApiBase ( repoInfo ) } /releases/${ releaseId } ` ;
408+ const label = `Failed to delete release ${ releaseId } ` ;
398409
399- await withTokens ( isGithub , tokens , async ( authHeader ) => {
400- let response : Response ;
401- try {
402- response = await forgeFetch ( "DELETE" , url , authHeader ) ;
403- } catch ( err : any ) {
404- throw new Error ( `Failed to delete release ${ releaseId } : ${ err . cause ?. message || err . message || "Unknown error" } ` ) ;
405- }
406- if ( response . ok || response . status === 404 ) return ;
407- throw authOrError ( response . status , `Failed to delete release ${ releaseId } : ${ response . status } ${ response . statusText } \n${ await response . text ( ) } ` ) ;
410+ await withTokens ( repoInfo . type === "github" , tokens , async ( authHeader ) => {
411+ const response = await forgeFetch ( "DELETE" , url , authHeader , label ) ;
412+ await ensureOk ( response , label , true ) ;
408413 } ) ;
409414}
410415
411416export async function createForgeRelease ( repoInfo : RepoInfo , tagName : string , body : string , tokens : string [ ] ) : Promise < CreatedRelease | null > {
412- const isGithub = repoInfo . type === "github" ;
413- const apiHost = isGithub ? "api.github.com" : `${ repoInfo . host } /api/v1` ;
414- const apiUrl = `https://${ apiHost } /repos/${ repoInfo . owner } /${ repoInfo . repo } /releases` ;
415-
417+ const apiUrl = `${ forgeApiBase ( repoInfo ) } /releases` ;
418+ const label = "Failed to create release" ;
416419 const releaseBody = JSON . stringify ( {
417420 tag_name : tagName ,
418421 name : tagName ,
@@ -421,15 +424,9 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo
421424 prerelease : tagName . includes ( "-" ) ,
422425 } ) ;
423426
424- const post = async ( authHeader : string ) => {
425- try {
426- return await forgeFetch ( "POST" , apiUrl , authHeader , releaseBody ) ;
427- } catch ( err : any ) {
428- throw new Error ( `Failed to create release: ${ err . cause ?. message || err . message || "Unknown error" } ` ) ;
429- }
430- } ;
427+ const post = ( authHeader : string ) => forgeFetch ( "POST" , apiUrl , authHeader , label , releaseBody ) ;
431428
432- return withTokens ( isGithub , tokens , async ( authHeader ) => {
429+ return withTokens ( repoInfo . type === "github" , tokens , async ( authHeader ) => {
433430 let response = await post ( authHeader ) ;
434431
435432 // Stale draft for the same tag blocks creation: Gitea returns 409 "Release is has no Tag",
@@ -439,13 +436,10 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo
439436 if ( cleaned > 0 ) response = await post ( authHeader ) ;
440437 }
441438
442- if ( response . ok ) {
443- const result = await response . json ( ) ;
444- console . info ( result . html_url ? `Created release: ${ result . html_url } ` : "Created release" ) ;
445- return typeof result . id === "number" ? { id : result . id , html_url : result . html_url } : null ;
446- }
447-
448- throw authOrError ( response . status , `Failed to create release: ${ response . status } ${ response . statusText } \n${ await response . text ( ) } ` ) ;
439+ await ensureOk ( response , label ) ;
440+ const result = await response . json ( ) ;
441+ console . info ( result . html_url ? `Created release: ${ result . html_url } ` : "Created release" ) ;
442+ return typeof result . id === "number" ? { id : result . id , html_url : result . html_url } : null ;
449443 } ) ;
450444}
451445
@@ -529,9 +523,10 @@ async function main(): Promise<void> {
529523 const gitDir = findUp ( ".git" , pwd ) ;
530524 const projectRoot = gitDir ? dirname ( gitDir ) : pwd ;
531525 const pushRemote = typeof args . remote === "string" ? args . remote : "origin" ;
532- const repoInfoPromise = ( ! args . gitless && args . release ) ? getRepoInfo ( undefined , pushRemote ) : null ;
533- const tokensPromise = repoInfoPromise ?. then ( info =>
534- ! info ? [ ] : info . type === "github" ? getGithubTokens ( ) : getGiteaTokens ( ) ) ;
526+ const wantRelease = ! args . gitless && Boolean ( args . release ) ;
527+ const repoInfoPromise = wantRelease ? getRepoInfo ( undefined , pushRemote ) : null ;
528+ const tokensPromise = wantRelease ? repoInfoPromise ! . then ( info =>
529+ ! info ? [ ] : info . type === "github" ? getGithubTokens ( ) : getGiteaTokens ( ) ) : null ;
535530
536531 files = files . map ( file => relative ( pwd , file ) ) ;
537532
@@ -546,49 +541,38 @@ async function main(): Promise<void> {
546541 }
547542
548543 const baseVersionPromise = ( async ( ) : Promise < { baseVersion : string , baseSource : string , describeTag : string } > => {
549- let baseVersion = "" ;
550- let baseSource = "" ;
551- let describeTag = "" ;
552544 if ( args . base ) {
553545 const raw = String ( args . base ) ;
554546 if ( ! isSemver ( raw ) ) throw new Error ( `Invalid base version: ${ raw } ` ) ;
555- return { baseVersion : stripV ( raw ) , baseSource : "--base" , describeTag} ;
547+ return { baseVersion : stripV ( raw ) , baseSource : "--base" , describeTag : "" } ;
556548 }
549+
550+ let describeTag = "";
557551 if ( ! args . gitless ) {
558552 try {
559553 const result = await exec ( "git" , [ "describe" , "--tags" , "--abbrev=0" ] ) ;
560554 describeTag = result . stdout . trim ( ) ;
561555 if ( isSemver ( describeTag ) ) {
562- baseVersion = stripV ( describeTag ) ;
563- baseSource = "git describe" ;
556+ return { baseVersion : stripV ( describeTag ) , baseSource : "git describe" , describeTag} ;
564557 }
565558 } catch { }
566- if ( ! baseVersion ) {
567- let stdout = "" ;
568- try {
569- ( { stdout} = await exec ( "git" , [ "tag" , "--list" , "--sort=-creatordate" ] ) ) ;
570- } catch { }
559+
560+ try {
561+ const { stdout} = await exec ( "git" , [ "tag" , "--list" , "--sort=-creatordate" ] ) ;
571562 const tag = stdout . split ( reNewline ) . map ( v => v . trim ( ) ) . find ( t => t && isSemver ( t ) ) ;
572- if ( tag ) {
573- baseVersion = stripV ( tag ) ;
574- baseSource = "git tag list" ;
575- }
576- }
577- }
578- if ( ! baseVersion ) {
579- baseVersion = readVersionFromPackageJson ( projectRoot ) || "" ;
580- if ( baseVersion ) {
581- baseSource = "package.json" ;
582- } else {
583- baseVersion = readVersionFromPyprojectToml ( projectRoot ) || "" ;
584- if ( baseVersion ) baseSource = "pyproject.toml" ;
585- }
586- if ( ! baseVersion && ! args . gitless ) {
587- baseVersion = "0.0.0" ;
588- baseSource = "default" ;
589- }
563+ if ( tag ) return { baseVersion : stripV ( tag ) , baseSource : "git tag list" , describeTag } ;
564+ } catch { }
590565 }
591- return { baseVersion, baseSource, describeTag} ;
566+
567+ const pkgVer = readVersionFromPackageJson ( projectRoot ) ;
568+ if ( pkgVer ) return { baseVersion : pkgVer , baseSource : "package.json" , describeTag } ;
569+
570+ const pyVer = readVersionFromPyprojectToml ( projectRoot ) ;
571+ if ( pyVer ) return { baseVersion : pyVer , baseSource : "pyproject.toml" , describeTag } ;
572+
573+ if ( ! args . gitless ) return { baseVersion : "0.0.0" , baseSource : "default" , describeTag } ;
574+
575+ return { baseVersion : "" , baseSource : "" , describeTag} ;
592576 } ) ( ) ;
593577
594578 // resolve push branch early so detached HEAD fails before commit/tag
@@ -628,7 +612,7 @@ async function main(): Promise<void> {
628612 }
629613
630614 const msgs = ( args . message || [ ] ) . filter ( msg => typeof msg === "string" ) ;
631- const tagName = args [ " prefix" ] ? `v${ newVersion } ` : newVersion ;
615+ const tagName = args . prefix ? `v${ newVersion } ` : newVersion ;
632616
633617 const changelogInfo = ( ( ) => {
634618 const path = findUp ( "CHANGELOG.md" , projectRoot ) ;
@@ -799,8 +783,8 @@ async function main(): Promise<void> {
799783 }
800784 }
801785
802- if ( repoInfoPromise ) {
803- const repoInfo = await repoInfoPromise ;
786+ if ( wantRelease ) {
787+ const repoInfo = await repoInfoPromise ! ;
804788 if ( ! repoInfo ) {
805789 throw new Error ( "Could not determine repository type from git remote. Only GitHub and Gitea repositories are supported for release creation." ) ;
806790 }
0 commit comments