Skip to content
Open
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,15 @@ usage: versions [options] patch|minor|major|prerelease [files...]

The message and replacement strings accept tokens _VER_, _MAJOR_, _MINOR_, _PATCH_.

A changelog piped on stdin is used as the commit, tag, and release body.

Unless --gitless, at least one given file must change.

Examples:
$ versions patch package.json
$ versions prerelease --preid=alpha package.json
$ versions -c 'npm run build' -m 'Release _VER_' minor file.css
$ versions --release patch package.json < notes.md
```

## Lockfiles
Expand All @@ -73,11 +76,11 @@ To automatically sign commits and tags created by `versions` with GPG add this t

## Changelog

If a `CHANGELOG.md` is present in the current directory or any directory above it up to the repository root, and it has a heading for the new version, its body is used as the commit message, tag annotation, and release body. Heading matching is lenient — `# 1.2.3`, `## v1.2.3`, `## [1.2.3]`, `## [1.2.3] - 2024-01-15`, `## 1.2.3 (YYYY-MM-DD)` all work. If the heading has no date or a placeholder (`YYYY-MM-DD`, `xxxx-xx-xx`, etc.), it gets rewritten to today's date and included in the commit. With no matching entry, the tool falls back to a `git log` summary.
A changelog piped on stdin is used as the commit message, tag annotation, and release body. Otherwise, if a `CHANGELOG.md` is present in the current directory or any directory above it up to the repository root, and it has a heading for the new version, its body is used. Heading matching is lenient — `# 1.2.3`, `## v1.2.3`, `## [1.2.3]`, `## [1.2.3] - 2024-01-15`, `## 1.2.3 (YYYY-MM-DD)` all work. If the heading has no date or a placeholder (`YYYY-MM-DD`, `xxxx-xx-xx`, etc.), it gets rewritten to today's date and included in the commit. With no matching entry and nothing on stdin, the tool falls back to a `git log` summary.

## Creating releases

`--release` creates a GitHub or Gitea release after pushing the tag, with the forge detected from the git remote URL. The body is the changelog entry or `git log` summary the commit message carries, without the leading tag name line and any `--message` strings, or just the tag name if there is neither. It requires the push, so it is incompatible with `--no-push` and `--gitless`.
`--release` creates a GitHub or Gitea release after pushing the tag, with the forge detected from the git remote URL. The body is the stdin changelog, the `CHANGELOG.md` entry, or `git log` summary the commit message carries, without the leading tag name line and any `--message` strings, or just the tag name if there is neither. It requires the push, so it is incompatible with `--no-push` and `--gitless`.

### API Tokens

Expand Down
57 changes: 57 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,7 @@ test.each([[[]], [["patch", "--help"]]])("prints help for %j", async (args) => {
const {stdout} = await exec("node", [distPath, ...args]);
expect(stdout).toContain("usage: versions");
expect(stdout).toContain("--replace");
expect(stdout).toContain("piped on stdin");
});

test("login and logout dispatch without a release level", () => withTmpDir(async (tmpDir) => {
Expand Down Expand Up @@ -1242,6 +1243,62 @@ test("CHANGELOG.md with existing date is left alone", () => withTmpDir(async (tm
expect(msg).toContain("- existing entry");
}));

test("changelog piped on stdin drives commit and tag body", () => withTmpDir(async (tmpDir) => {
await writeFile(join(tmpDir, "package.json"), pkgJson("1.0.0"));

const {opts} = await setupReleaseRepo(tmpDir);
await exec("git", ["commit", "--allow-empty", "-m", "tweak something"], opts);

await exec("node", [distPath, "--no-push", "-m", "Release _VER_", "patch", "package.json"], {
...opts,
stdin: "- Fixed thing X\n- Added thing Y\n",
});

const {stdout: msg} = await exec("git", ["log", "-1", "--pretty=%B"], opts);
expect(msg).toContain("Release 1.0.1");
expect(msg).toContain("- Fixed thing X");
expect(msg).toContain("- Added thing Y");
expect(msg).not.toContain("tweak something");
expect(msg.split("\n")[0]).toEqual("1.0.1");

const {stdout: tagMsg} = await exec("git", ["tag", "-l", "1.0.1", "--format=%(contents)"], opts);
expect(tagMsg).toContain("- Fixed thing X");
}));

test("stdin changelog takes precedence over CHANGELOG.md", () => withTmpDir(async (tmpDir) => {
await writeFile(join(tmpDir, "package.json"), pkgJson("1.0.0"));
await writeFile(join(tmpDir, "CHANGELOG.md"), `# Changelog\n\n## [1.0.1]\n- from file\n\n## 1.0.0\nold\n`);

const {opts} = await setupReleaseRepo(tmpDir);

await exec("node", [distPath, "--no-push", "patch", "package.json"], {
...opts,
stdin: "- from stdin\n",
});

const today = new Date().toISOString().substring(0, 10);
expect(await readFile(join(tmpDir, "CHANGELOG.md"), "utf8")).toContain(`## [1.0.1] - ${today}`);

const {stdout: msg} = await exec("git", ["log", "-1", "--pretty=%B"], opts);
expect(msg).toContain("- from stdin");
expect(msg).not.toContain("- from file");
}));

test("whitespace-only stdin falls back to git log", () => withTmpDir(async (tmpDir) => {
await writeFile(join(tmpDir, "package.json"), pkgJson("1.0.0"));

const {opts} = await setupReleaseRepo(tmpDir);
await exec("git", ["commit", "--allow-empty", "-m", "tweak something"], opts);

await exec("node", [distPath, "--no-push", "patch", "package.json"], {
...opts,
stdin: " \n ",
});

const {stdout: msg} = await exec("git", ["log", "-1", "--pretty=%B"], opts);
expect(msg).toContain("tweak something");
}));

test("readVersionFile package.json", () => withTmpDir(async (tmpDir) => {
expect(readVersionFile("package.json", tmpDir)).toBeNull();

Expand Down
21 changes: 18 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ async function readToken(stdin: Readable | ReadStream, host: string): Promise<st
return token;
}

// a TTY would block waiting for Ctrl-D; empty or whitespace falls through to CHANGELOG.md / git log
async function readStdinChangelog(input: Readable | ReadStream): Promise<string> {
if ("isTTY" in input && input.isTTY) return "";
return (await text(input)).trim();
}

async function main(): Promise<void> {
// exit() discards queued writes on a non-blocking stream, losing diagnostics under CI pipes
for (const stream of [stdout, stderr]) {
Expand Down Expand Up @@ -161,12 +167,15 @@ async function main(): Promise<void> {

The message and replacement strings accept tokens _VER_, _MAJOR_, _MINOR_, _PATCH_.

A changelog piped on stdin is used as the commit, tag, and release body.

Unless --gitless, at least one given file must change.

Examples:
$ versions patch package.json
$ versions prerelease --preid=alpha package.json
$ versions -c 'npm run build' -m 'Release _VER_' minor file.css`);
$ versions -c 'npm run build' -m 'Release _VER_' minor file.css
$ versions --release patch package.json < notes.md`);
end();
}

Expand Down Expand Up @@ -216,6 +225,8 @@ async function main(): Promise<void> {
(async () => stringArg(args.branch) ?? (await exec("git", ["branch", "--show-current"])).stdout)() :
Promise.resolve("");
const identityOkP = (async () => !willCommit || await tryExec("git", ["var", "GIT_AUTHOR_IDENT"]) !== null)();
// drain here so --dry still consumes a pipe, and before --command runs
const stdinChangelogP = readStdinChangelog(stdin);
const forgeP = (async () => {
const repoInfo = wantRelease && willCommit ? await getRepoInfo(undefined, pushRemote) : null;
const tokens = repoInfo ? await getForgeTokens(repoInfo) : [];
Expand Down Expand Up @@ -297,8 +308,8 @@ async function main(): Promise<void> {
}

// === VALIDATE === one await collects every probe, the checks below are pure
const [remoteState, {repoInfo, tokens, pingResult}, identityOk, mergeBaseOk] = await Promise.all([
remoteStateP, forgeP, identityOkP, mergeBaseOkP,
const [remoteState, {repoInfo, tokens, pingResult}, identityOk, mergeBaseOk, stdinChangelog] = await Promise.all([
remoteStateP, forgeP, identityOkP, mergeBaseOkP, stdinChangelogP,
]);

// manifest rewrites set the version outright, so the no-diff check below can never catch a wrong base
Expand Down Expand Up @@ -396,6 +407,10 @@ async function main(): Promise<void> {
const [filesToAdd, changelogBody] = await Promise.all([
!args.all && allFiles.length ? removeIgnoredFiles(allFiles) : [],
(async () => {
if (stdinChangelog) {
logVerbose("using changelog from stdin");
return stdinChangelog;
}
if (changelogInfo) {
logVerbose(`using changelog entry from ${changelogPath}`);
return changelogInfo.entry;
Expand Down