From 2e5010eae045cc9690766aa66a1ccf0e74286784 Mon Sep 17 00:00:00 2001 From: bircni Date: Thu, 10 Sep 2026 22:53:43 +0200 Subject: [PATCH] Accept changelog from stdin There was no way to feed a manual changelog into the commit, tag, and release body without a CHANGELOG.md file. Piped stdin is used when present, then CHANGELOG.md, then git log. Closes https://github.com/silverwind/versions/issues/54 --- README.md | 7 +++++-- index.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.ts | 21 ++++++++++++++++--- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0e6bbf6..3b3ab4d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/index.test.ts b/index.test.ts index 61bca5d..2354268 100644 --- a/index.test.ts +++ b/index.test.ts @@ -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) => { @@ -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(); diff --git a/index.ts b/index.ts index e838b6c..2e5cb62 100755 --- a/index.ts +++ b/index.ts @@ -80,6 +80,12 @@ async function readToken(stdin: Readable | ReadStream, host: string): Promise { + if ("isTTY" in input && input.isTTY) return ""; + return (await text(input)).trim(); +} + async function main(): Promise { // exit() discards queued writes on a non-blocking stream, losing diagnostics under CI pipes for (const stream of [stdout, stderr]) { @@ -161,12 +167,15 @@ async function main(): Promise { 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(); } @@ -216,6 +225,8 @@ async function main(): Promise { (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) : []; @@ -297,8 +308,8 @@ async function main(): Promise { } // === 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 @@ -396,6 +407,10 @@ async function main(): Promise { 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;