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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ 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
-n, --no-push Skip pushing commit and tag
-o, --remote <name> Git remote to push to. Default is "origin"
-B, --branch <name> Git branch to push. Default is the current branch
-V, --verbose Print verbose output to stderr
-v, --version Print the version
-h, --help Print this help
Expand Down Expand Up @@ -54,9 +57,13 @@ To automatically sign commits and tags created by `versions` with GPG add this t
gpgSign = if-asked
```

## Pushing

By default, `versions` pushes the commit and tag to `origin` after creating them. Pass `--no-push` to skip the push and keep changes local. Use `--remote` and `--branch` to override the target remote and branch.

## Creating releases

When using the `--release` option, `versions` will automatically create a GitHub or Gitea release after creating the tag. The release body will contain the same changelog as the commit message.
When using the `--release` option, `versions` will automatically create a GitHub or Gitea release after pushing the tag. The release body will contain the same changelog as the commit message. `--release` requires the push and is incompatible with `--no-push`.

The tool will automatically detect whether you're using GitHub or Gitea based on your git remote URL.

Expand Down
121 changes: 121 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,127 @@ test("release integration - tag already exists on remote (different commit)", ()
}
}));

test("default push - pushes commit and tag without --release", () => withTmpDir(async (tmpDir) => {
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));

await initGitRepo(tmpDir);
const env = getIsolatedGitEnv(tmpDir);
const bareDir = await createBareRemote(tmpDir);
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});

await exec("node", [distPath, "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}});

const {stdout: localHead} = await exec("git", ["rev-parse", "HEAD"], {cwd: tmpDir, env: {...process.env, ...env}});
const {stdout: remoteHead} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir});
expect(remoteHead.trim()).toEqual(localHead.trim());
const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir});
expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1");
}));

test("--no-push skips push", () => withTmpDir(async (tmpDir) => {
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));

await initGitRepo(tmpDir);
const env = getIsolatedGitEnv(tmpDir);
const bareDir = await createBareRemote(tmpDir);
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});

const {stdout: remoteHeadBefore} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir});

await exec("node", [distPath, "--no-push", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}});

const {stdout: remoteHeadAfter} = await exec("git", ["rev-parse", "HEAD"], {cwd: bareDir});
expect(remoteHeadAfter.trim()).toEqual(remoteHeadBefore.trim());
const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir});
expect(remoteTags.trim().split("\n").filter(Boolean)).not.toContain("1.0.1");
}));

test("--no-push and --release are mutually exclusive", async () => {
try {
await exec("node", [distPath, "--no-push", "--release", "--base", "1.0.0", "patch"]);
throw new Error("should have thrown");
} catch (err: any) {
expect(err).toBeInstanceOf(SubprocessError);
expect(err.exitCode).toEqual(1);
}
});

test("--remote pushes to custom remote", () => withTmpDir(async (tmpDir) => {
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));

await initGitRepo(tmpDir);
const env = getIsolatedGitEnv(tmpDir);
const bareDir = await createBareRemote(tmpDir);
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["remote", "add", "upstream", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["push", "upstream", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});

await exec("node", [distPath, "--remote", "upstream", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}});

const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir});
expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1");
}));

test("--remote with --release uses that remote for forge detection", () => withTmpDir(async (tmpDir) => {
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));

await initGitRepo(tmpDir);
const env = getIsolatedGitEnv(tmpDir);
const bareDir = await createBareRemote(tmpDir);
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
// origin has no forge URL, upstream points at github.com — release must follow --remote
await exec("git", ["remote", "add", "origin", "file:///nowhere"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["remote", "add", "upstream", "https://github.com/owner/repo.git"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["remote", "set-url", "--push", "upstream", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["push", "upstream", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});

// fails at api.github.com call (fake token) — but only gets there if forge detection used upstream
try {
await exec("node", [distPath, "--remote", "upstream", "--release", "patch", "package.json"], {
cwd: tmpDir,
env: {...process.env, GITHUB_TOKEN: "fake-token", ...env},
});
} catch (err: any) {
expect(err).toBeInstanceOf(SubprocessError);
expect(err.exitCode).toEqual(1);
}

// confirm push landed on upstream (not origin)
const {stdout: remoteTags} = await exec("git", ["tag", "--list"], {cwd: bareDir});
expect(remoteTags.trim().split("\n").filter(Boolean)).toContain("1.0.1");
}));

test("--branch pushes specified branch", () => withTmpDir(async (tmpDir) => {
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));

await initGitRepo(tmpDir);
const env = getIsolatedGitEnv(tmpDir);
const bareDir = await createBareRemote(tmpDir);
await exec("git", ["add", "."], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["commit", "-m", "Initial commit"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["remote", "add", "origin", bareDir], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["push", "origin", "master"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["tag", "1.0.0"], {cwd: tmpDir, env: {...process.env, ...env}});
await exec("git", ["checkout", "-b", "release"], {cwd: tmpDir, env: {...process.env, ...env}});

await exec("node", [distPath, "--branch", "release", "patch", "package.json"], {cwd: tmpDir, env: {...process.env, ...env}});

const {stdout: remoteBranches} = await exec("git", ["branch", "--list"], {cwd: bareDir});
expect(remoteBranches).toContain("release");
}));

test("incrementSemver prerelease", () => {
expect(incrementSemver("1.0.0", "prerelease", "alpha")).toEqual("1.0.1-alpha.0");
expect(incrementSemver("1.0.1-beta.0", "prerelease", "beta")).toEqual("1.0.1-beta.1");
Expand Down
42 changes: 33 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,9 @@ export type RepoInfo = {
type: "github" | "gitea";
};

export async function getRepoInfo(cwd?: string): Promise<RepoInfo | null> {
export async function getRepoInfo(cwd?: string, remote: string = "origin"): Promise<RepoInfo | null> {
try {
const {stdout} = await exec("git", ["remote", "get-url", "origin"], cwd ? {cwd} : undefined);
const {stdout} = await exec("git", ["remote", "get-url", remote], cwd ? {cwd} : undefined);
const url = stdout.trim();

// Parse git URLs: https://host/owner/repo.git or git@host:owner/repo.git
Expand Down Expand Up @@ -356,6 +356,9 @@ async function main(): Promise<void> {
version: {short: "v", type: "boolean"},
date: {short: "d", type: "boolean"},
release: {short: "R", type: "boolean"},
"no-push": {short: "n", type: "boolean"},
remote: {short: "o", type: "string"},
branch: {short: "B", type: "string"},
base: {short: "b", type: "string"},
command: {short: "c", type: "string"},
replace: {short: "r", type: "string", multiple: true},
Expand Down Expand Up @@ -389,7 +392,10 @@ async function main(): Promise<void> {
-r, --replace <str> Additional replacements in the format "s#regexp#replacement#flags"
-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
-R, --release Create a GitHub or Gitea release with the changelog as body
-n, --no-push Skip pushing commit and tag
-o, --remote <name> Git remote to push to. Default is "origin"
-B, --branch <name> Git branch to push. Default is the current branch
-V, --verbose Print verbose output to stderr
-v, --version Print the version
-h, --help Print this help
Expand All @@ -412,8 +418,9 @@ async function main(): Promise<void> {
const gitDir = findUp(".git", pwd);
let projectRoot = gitDir ? dirname(gitDir) : null;
if (!projectRoot) projectRoot = pwd;
const pushRemote = typeof args.remote === "string" ? args.remote : "origin";
const releasePrep = (!args.gitless && args.release) ? (() => {
const repoInfo = getRepoInfo();
const repoInfo = getRepoInfo(undefined, pushRemote);
return {
repoInfo,
tokens: repoInfo.then(info => {
Expand Down Expand Up @@ -493,6 +500,23 @@ async function main(): Promise<void> {
if (args.gitless && args.release) {
return end(new Error("--gitless and --release are mutually exclusive"));
}
if (args["no-push"] && args.release) {
return end(new Error("--no-push and --release are mutually exclusive"));
}

// resolve push branch early so detached HEAD fails before commit/tag
let pushBranch: string = "";
if (!args.gitless && !args.dry && !args["no-push"]) {
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") {
return end(new Error("Cannot push from detached HEAD. Pass --branch <name> or --no-push."));
}
}
}

// set new version
const newVersion = incrementSemver(baseVersion, level, typeof args.preid === "string" ? args.preid : undefined);
Expand Down Expand Up @@ -592,18 +616,18 @@ async function main(): Promise<void> {
// adding explicit -a here seems to make git no longer sign the tag
writeResult(await exec("git", ["tag", "-f", "-F", "-", tagName], {stdin: {string: tagMsg}}));

// push commit and tag
if (!args["no-push"]) {
writeResult(await exec("git", ["push", pushRemote, pushBranch, tagName]));
}

// create release if requested
if (releasePrep) {
const repoInfo = await releasePrep.repoInfo;
if (!repoInfo) {
throw new Error("Could not determine repository type from git remote. Only GitHub and Gitea repositories are supported for release creation.");
}

const {stdout: branchOut} = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
const branch = branchOut.trim();
if (branch === "HEAD") throw new Error("Cannot create release from detached HEAD");
writeResult(await exec("git", ["push", "origin", branch, tagName]));

const releaseBody = changelog || tagName;
const forgeName = repoInfo.type === "github" ? "GitHub" : "Gitea";
const tokens = await releasePrep.tokens;
Expand Down