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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ usage: versions [options] patch|minor|major|prerelease [files...]
-g, --gitless Do not perform any git action like creating commit and tag
-D, --dry Do not create a tag or commit, just print what would be done
-R, --release Create a GitHub or Gitea release with the changelog as body
-V, --verbose Print verbose output to stderr
-v, --version Print the version
-h, --help Print this help

Expand Down
41 changes: 35 additions & 6 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import {SubprocessError, type Result, exec, reNewline, tomlGetString} from "./utils.ts";
import {SubprocessError, type Result, colorize, exec, logVerbose, reNewline, setVerbose, tomlGetString} from "./utils.ts";
import {parseArgs} from "node:util";
import {basename, dirname, join, relative, resolve} from "node:path";
import {cwd, exit, stdout} from "node:process";
Expand Down Expand Up @@ -304,6 +304,7 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo
let lastError: Error | undefined;
for (const token of tokens) {
let response: Response;
logVerbose(`${colorize("POST", "magenta")} ${apiUrl}`);
try {
response = await fetch(apiUrl, {
method: "POST",
Expand All @@ -316,6 +317,7 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo
} catch (err: any) {
throw new Error(`Failed to create release: ${err.cause?.message || err.message || "Unknown error"}`);
}
logVerbose(`${colorize(String(response.status), response.ok ? "green" : "red")} ${apiUrl}`);

if (response.ok) {
const result = await response.json();
Expand All @@ -330,6 +332,7 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo
const errorText = await response.text();
lastError = new Error(`Failed to create release: ${response.status} ${response.statusText}\n${errorText}`);
if (response.status !== 401 && response.status !== 403) throw lastError;
logVerbose(`auth failed (${response.status}), trying next token`);
}
throw lastError ?? new Error("No tokens provided");
}
Expand Down Expand Up @@ -358,12 +361,15 @@ async function main(): Promise<void> {
replace: {short: "r", type: "string", multiple: true},
message: {short: "m", type: "string", multiple: true},
preid: {short: "i", type: "string"},
verbose: {short: "V", type: "boolean"},
},
});
const args = result.values;
let [level, ...files] = result.positionals;
files = Array.from(new Set(files));

setVerbose(Boolean(args.verbose));

if (args.version) {
console.info(pkg.version || "0.0.0");
end();
Expand All @@ -384,6 +390,7 @@ async function main(): Promise<void> {
-g, --gitless Do not perform any git action like creating commit and tag
-D, --dry Do not create a tag or commit, just print what would be done
-R, --release Create a GitHub or Gitea release, push commit and tag to origin
-V, --verbose Print verbose output to stderr
-v, --version Print the version
-h, --help Print this help

Expand Down Expand Up @@ -419,6 +426,7 @@ async function main(): Promise<void> {
// obtain old version
let baseVersion: string = "";
let cachedDescribeTag: string = "";
let baseSource: string = "";
if (!args.base) {
let stdout: string = "";
if (!args.gitless) {
Expand All @@ -428,6 +436,7 @@ async function main(): Promise<void> {
cachedDescribeTag = result.stdout.trim();
if (isSemver(cachedDescribeTag)) {
baseVersion = cachedDescribeTag.replace(reVersionPrefix, "");
baseSource = "git describe";
}
} catch {}
// Fall back to full tag list if describe didn't yield a semver tag
Expand All @@ -438,25 +447,33 @@ async function main(): Promise<void> {
for (const tag of stdout.split(reNewline).map(v => v.trim()).filter(Boolean)) {
if (isSemver(tag)) {
baseVersion = tag.replace(reVersionPrefix, "");
baseSource = "git tag list";
break;
}
}
}
}
if (!baseVersion) {
// Try to get version from package.json first, then pyproject.toml as fallback
// package.json takes precedence for JavaScript/TypeScript projects
baseVersion = readVersionFromPackageJson(projectRoot) || readVersionFromPyprojectToml(projectRoot) || "";
baseVersion = readVersionFromPackageJson(projectRoot) || "";
if (baseVersion) {
baseSource = "package.json";
} else {
baseVersion = readVersionFromPyprojectToml(projectRoot) || "";
if (baseVersion) baseSource = "pyproject.toml";
}
if (!baseVersion && args.gitless) {
return end(new Error(`--gitless requires --base to be set or a version in package.json or pyproject.toml`));
}
if (!baseVersion) {
baseVersion = "0.0.0";
baseSource = "default";
}
}
} else {
baseVersion = String(args.base);
baseSource = "--base";
}
logVerbose(`base version ${baseVersion} from ${baseSource}`);

// chop off "v"
if (baseVersion.startsWith("v")) baseVersion = baseVersion.substring(1);
Expand All @@ -479,6 +496,7 @@ async function main(): Promise<void> {

// set new version
const newVersion = incrementSemver(baseVersion, level, typeof args.preid === "string" ? args.preid : undefined);
logVerbose(`new version ${newVersion}`);

const replacements: Array<{re: RegExp, replacement: string}> = [];
if (args.replace?.length) {
Expand Down Expand Up @@ -531,16 +549,26 @@ async function main(): Promise<void> {
// update files
for (const file of files) {
const [filePath, newData] = getFileChanges({file, baseVersion, newVersion, replacements, date});
if (newData !== null) write(filePath, newData);
if (newData !== null) {
logVerbose(`writing ${filePath}`);
write(filePath, newData);
} else {
logVerbose(`skipping ${file} (unhandled lockfile)`);
}
}
}

if (typeof args.command === "string") {
logVerbose(`running command: ${args.command}`);
writeResult(await exec(args.command, [], {shell: true}));
}
if (args.gitless) return; // nothing else to do
if (args.gitless) {
logVerbose("gitless — skipping commit, tag, and release");
return;
}

if (args.dry) {
logVerbose("dry run — skipping commit and tag");
return console.info(`Would create new tag and commit: ${tagName}`);
}

Expand Down Expand Up @@ -582,6 +610,7 @@ async function main(): Promise<void> {
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)`);
await createForgeRelease(repoInfo, tagName, releaseBody, tokens);
}
}
Expand Down
30 changes: 30 additions & 0 deletions utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,36 @@
import {execFile as execFileCb} from "node:child_process";
import {stderr} from "node:process";
import {styleText} from "node:util";

export type Result = {stdout: string; stderr: string};

let verbose = false;
const useColor = stderr.isTTY;

export function setVerbose(value: boolean): void {
verbose = value;
}

const pad = (value: number, len = 2) => String(value).padStart(len, "0");

function timestamp(): string {
const date = new Date();
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
}

export function logVerbose(message: string): void {
if (!verbose) return;
console.error(`${timestamp()} ${message}`);
}

export function colorize(text: string, color: "magenta" | "green" | "red"): string {
return useColor ? styleText(color, text) : text;
}

function quoteArg(arg: string): string {
return /[\s"']/.test(arg) ? JSON.stringify(arg) : arg;
}

export class SubprocessError extends Error {
stdout: string;
stderr: string;
Expand Down Expand Up @@ -47,6 +76,7 @@ export function tomlGetString(content: string, section: string, key: string): st
}

export function exec(file: string, args: readonly string[], options?: ExecOptions): Promise<Result> {
if (verbose) logVerbose(`$ ${args.length ? `${file} ${args.map(quoteArg).join(" ")}` : file}`);
return new Promise((resolve, reject) => {
const child = execFileCb(file, args as string[], {encoding: "utf8", shell: options?.shell, windowsHide: true, cwd: options?.cwd, env: options?.env}, (error, stdout, stderr) => {
if (error) {
Expand Down