From 18b04563bb8fa5205b1471f637a092312409814a Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 17 Aug 2026 10:29:34 -0300 Subject: [PATCH] fix(release): fail closed on local tooling Run conventional-changelog through its installed API so a missing dev dependency cannot reach npx registry fallback. Guard annotated tag finalization against published refs and unexpected local state. Freeze published changelog sections before normalizing future release dates. --- .npmrc | 4 +- CHANGELOG.md | 2 +- CONTRIBUTING.md | 21 +++ package.json | 6 +- tools/changelog-check.mjs | 56 +++++--- tools/changelog-history.md | 110 +++++++++++++++ tools/changelog-preset.mjs | 21 ++- tools/changelog.mjs | 31 ++--- tools/release-check.mjs | 272 +++++++++++++++++++++++++++++++++++++ tools/tag-release.mjs | 179 ++++++++++++++++++++++++ 10 files changed, 660 insertions(+), 42 deletions(-) create mode 100644 tools/release-check.mjs create mode 100644 tools/tag-release.mjs diff --git a/.npmrc b/.npmrc index 8eeca77..9aa0af1 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ package-lock=false -allow-same-version=true -message=v%s \ No newline at end of file +allow-same-version=false +message=v%s diff --git a/CHANGELOG.md b/CHANGELOG.md index 03af433..00c740a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Generated from conventional commit messages. Breaking changes are collected under their own heading, from either a `!` after the type or a `BREAKING CHANGE:` footer. -Releases up to 1.3.0 are kept as they were published, in +Releases up to 1.3.8 are kept as they were published, in tools/changelog-history.md. ## [1.3.8](https://github.com/GSTJ/safe-jsx/compare/v1.3.7...v1.3.8) (2026-08-05) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ccb872..56ebb8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,4 +140,25 @@ at all. 3. Increase the version numbers in any examples files and the README.md to the new version that this Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 4. You may merge the Pull Request in once you have the sign-off of other developer, or if you do not have permission to do that, you may request the reviewer to merge it for you. +## Releases + +Run `npm version patch`, `npm version minor` or `npm version major` on a release +branch. npm updates the package, rebuilds the changelog, creates the release +commit and prepares an annotated tag from the same release notes. + +Push the branch without `--tags` and merge its PR. From the updated `main` +branch, run: + +```sh +pnpm run release:tag +RELEASE_VERSION=$(node -p 'require("./package.json").version') +git push origin "v${RELEASE_VERSION}" +``` + +`release:tag` can move the local tag from the release branch to its squash +commit while the tag is unpublished. It refuses to change a tag that already +exists on `origin`. Create the GitHub release from that tag and use the output +from `pnpm run release-notes` as its body; the release event publishes the npm +package with provenance. + Thank you for considering contributing to @gstj/safe-jsx! diff --git a/package.json b/package.json index 1b26e9e..79cc937 100644 --- a/package.json +++ b/package.json @@ -37,14 +37,16 @@ "format": "oxfmt --check .", "format:fix": "oxfmt .", "typecheck": "tsc --noEmit", - "test": "jest --coverage", + "test": "jest --coverage && node tools/release-check.mjs", "prepublish": "not-in-publish || npm run prepublishOnly", "prepublishOnly": "safe-publish-latest && npm run lint && npm run test", "changelog": "node tools/changelog.mjs", "changelog:check": "node tools/changelog-check.mjs", "release-notes": "node tools/release-notes.mjs", + "release:check": "node tools/release-check.mjs", + "release:tag": "node tools/tag-release.mjs", "version": "npm run changelog && git add CHANGELOG.md", - "postversion": "node tools/release-notes.mjs > .release-notes && git tag -f -a --cleanup=verbatim \"v$npm_package_version\" -F .release-notes && rm -f .release-notes" + "postversion": "npm run release:tag" }, "devDependencies": { "@types/estree": "^1.0.9", diff --git a/tools/changelog-check.mjs b/tools/changelog-check.mjs index 508f906..5dca16b 100644 --- a/tools/changelog-check.mjs +++ b/tools/changelog-check.mjs @@ -27,21 +27,17 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { env } from "node:process"; +import { pathToFileURL } from "node:url"; +import { ConventionalChangelog } from "conventional-changelog"; import { Bumper } from "conventional-recommended-bump"; -import preset, { TYPES } from "./changelog-preset.mjs"; +import preset, { formatCommitDate, TYPES } from "./changelog-preset.mjs"; const here = import.meta.dirname; const presetPath = join(here, "changelog-preset.mjs"); -// Resolved rather than assumed to sit at ../node_modules/.bin: pnpm hoists, and -// in a workspace the binary can land in the root store instead of the package. -// The package exports no `./package.json`, so resolve its entry point and walk -// across to the CLI beside it. -const cliEntry = fileURLToPath(import.meta.resolve("conventional-changelog")); -const cliPath = join(dirname(cliEntry), "cli", "index.js"); /** * What each type is for. `bump` can raise the version, `changelog` renders @@ -122,7 +118,16 @@ const inThrowawayRepo = async (commits, tag, run) => { const repo = mkdtempSync(join(tmpdir(), "safe-jsx-changelog-")); /** @param {string[]} args */ const git = (...args) => - execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: "pipe" }); + execFileSync("git", args, { + cwd: repo, + encoding: "utf8", + env: { + ...env, + GIT_AUTHOR_DATE: "2026-08-05T21:03:25-03:00", + GIT_COMMITTER_DATE: "2026-08-05T21:03:25-03:00", + }, + stdio: "pipe", + }); try { git("init", "--quiet", "--initial-branch", "main"); @@ -162,13 +167,19 @@ const inThrowawayRepo = async (commits, tag, run) => { * @returns {Promise} */ const render = (configPath) => - inThrowawayRepo(COMMITS, "after", (repo) => - execFileSync( - process.execPath, - [cliPath, "--config", configPath, "--release-count", "0", "--stdout"], - { cwd: repo, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, - ), - ); + inThrowawayRepo(COMMITS, "after", async (repo) => { + const config = + /** @type {{ default: Parameters[0] }} */ ( + await import(pathToFileURL(configPath).href) + ); + const generator = new ConventionalChangelog(repo) + .readPackage() + .config(config.default) + .options({ formatDate: formatCommitDate, releaseCount: 0 }); + let output = ""; + for await (const chunk of generator.write()) output += chunk; + return output; + }); /** * The release type conventional-recommended-bump lands on for `commits`, read @@ -214,6 +225,10 @@ for (const [type, effect] of Object.entries(POLICY)) { for (const entry of TYPES) { expect(`${entry.type} is covered by the policy`, entry.type in POLICY); } +expect( + "release dates keep the calendar day recorded by git", + formatCommitDate("2026-08-05 21:03:25 -0300") === "2026-08-05", +); /** * 2. The rendered output says what the policy says. @@ -250,7 +265,12 @@ const assessRendering = (output) => { return found; }; -failures.push(...assessRendering(await render(presetPath))); +const rendered = await render(presetPath); +failures.push(...assessRendering(rendered)); +expect( + "rendered release dates keep the calendar day recorded by git", + rendered.includes(" (2026-08-05)"), +); // 3. `effect` decides the bump, not just the rendering. The middle case is the // one that matters: those four types all render, and none of them may push a diff --git a/tools/changelog-history.md b/tools/changelog-history.md index 6529283..a1a5152 100644 --- a/tools/changelog-history.md +++ b/tools/changelog-history.md @@ -1,3 +1,113 @@ +## [1.3.8](https://github.com/GSTJ/safe-jsx/compare/v1.3.7...v1.3.8) (2026-08-05) + +### Bug Fixes + +* **package:** correct the Node engine floor ([#72](https://github.com/GSTJ/safe-jsx/issues/72)) ([fe09789](https://github.com/GSTJ/safe-jsx/commit/fe0978959e9b3f1b92bb66dae5fe7fb28ab3fd99)) + +### Chores + +* **deps:** lock file maintenance ([#70](https://github.com/GSTJ/safe-jsx/issues/70)) ([59d86c3](https://github.com/GSTJ/safe-jsx/commit/59d86c32e328879be750750ac02035d76be03bda)) +* **deps:** quarantine new releases for 14 days ([#71](https://github.com/GSTJ/safe-jsx/issues/71)) ([93dfff1](https://github.com/GSTJ/safe-jsx/commit/93dfff1a3191815877089beb7e838fac1b3a5cdb)) +* **deps:** update dependency @typescript-eslint/parser to v8.66.0 ([#67](https://github.com/GSTJ/safe-jsx/issues/67)) ([e58969d](https://github.com/GSTJ/safe-jsx/commit/e58969d2567103aa91a38363521606e494dd5605)) +* **deps:** update github actions ([#65](https://github.com/GSTJ/safe-jsx/issues/65)) ([d5fe3b1](https://github.com/GSTJ/safe-jsx/commit/d5fe3b1504fad966ffdf2d8b4cb7fde8c11f0068)) +* **deps:** update magic tooling ([#66](https://github.com/GSTJ/safe-jsx/issues/66)) ([35be0bd](https://github.com/GSTJ/safe-jsx/commit/35be0bd931c3e437aad1110fc7836c8cb42d63a7)) +* **deps:** update oxc toolchain ([#68](https://github.com/GSTJ/safe-jsx/issues/68)) ([c61ec9e](https://github.com/GSTJ/safe-jsx/commit/c61ec9e0e6f8b04c0055057c4378e5c3ec284449)) +* **deps:** update pnpm to v11.20.0 ([#69](https://github.com/GSTJ/safe-jsx/issues/69)) ([3bb5ce0](https://github.com/GSTJ/safe-jsx/commit/3bb5ce09f197d36c34924342b7580f0da967eb47)) + +## [1.3.7](https://github.com/GSTJ/safe-jsx/compare/v1.3.6...v1.3.7) (2026-08-04) + +### Bug Fixes + +* **jsx-explicit-boolean:** bound boolean evidence traversal ([#61](https://github.com/GSTJ/safe-jsx/issues/61)) ([4fc3c98](https://github.com/GSTJ/safe-jsx/commit/4fc3c984e23837a36ffd6e48e017808d60c3d124)) + +### Chores + +* **deps:** lock file maintenance ([#58](https://github.com/GSTJ/safe-jsx/issues/58)) ([1c557ca](https://github.com/GSTJ/safe-jsx/commit/1c557cae041e540a9113cb39cd6595dbf324e1ae)) +* **deps:** patch brace-expansion ([#54](https://github.com/GSTJ/safe-jsx/issues/54)) ([523cb0b](https://github.com/GSTJ/safe-jsx/commit/523cb0b9694092a407845977fc6c0db12e7b7629)) +* **deps:** update dependency @types/node to v26.1.2 ([#50](https://github.com/GSTJ/safe-jsx/issues/50)) ([51d1658](https://github.com/GSTJ/safe-jsx/commit/51d165866e32d95dd4ee54877181a845fb757adc)) +* **deps:** update dependency magic-oxlint-config to v2 ([#52](https://github.com/GSTJ/safe-jsx/issues/52)) ([e267289](https://github.com/GSTJ/safe-jsx/commit/e26728997b64a47d17ed059c5e9cfb6e975404fe)) +* **deps:** update oxc toolchain ([#51](https://github.com/GSTJ/safe-jsx/issues/51)) ([c663647](https://github.com/GSTJ/safe-jsx/commit/c663647626e6f97c7c61f6245120ac5190a6fd1a)) +* **deps:** update pnpm to v11.18.0 ([#53](https://github.com/GSTJ/safe-jsx/issues/53)) ([af4d497](https://github.com/GSTJ/safe-jsx/commit/af4d4979bcb37e43b21a19f1cbde95d245c021f7)) +* **deps:** update pnpm to v11.19.0 ([#59](https://github.com/GSTJ/safe-jsx/issues/59)) ([6e0b5e1](https://github.com/GSTJ/safe-jsx/commit/6e0b5e1dfe18aefdccf62c615a6546f7dbc374af)) +* **deps:** update pnpm/action-setup action to v6 ([#62](https://github.com/GSTJ/safe-jsx/issues/62)) ([2780bc7](https://github.com/GSTJ/safe-jsx/commit/2780bc7ee3c735b8a4bdb40dad8a2128a14ef4fe)) +* **renovate:** keep overrides within major ([#57](https://github.com/GSTJ/safe-jsx/issues/57)) ([a7620f7](https://github.com/GSTJ/safe-jsx/commit/a7620f7a1229ef96abbf36819ab63133608db275)) + +## [1.3.6](https://github.com/GSTJ/safe-jsx/compare/v1.3.5...v1.3.6) (2026-07-28) + +### Bug Fixes + +* **jsx-explicit-boolean:** stop trusting a shadowed Boolean ([#49](https://github.com/GSTJ/safe-jsx/issues/49)) ([d140eb5](https://github.com/GSTJ/safe-jsx/commit/d140eb5de0f02c99aa766c6713090c9d57bbde92)) + +## [1.3.5](https://github.com/GSTJ/safe-jsx/compare/v1.3.4...v1.3.5) (2026-07-28) + +### Build System + +* rewrite the security policy and ship it in the tarball ([#47](https://github.com/GSTJ/safe-jsx/issues/47)) ([78bc88f](https://github.com/GSTJ/safe-jsx/commit/78bc88f7791cda50b63fd6e2ce84aaa13accb8ff)) + +### Chores + +* drop the npmignore leftovers from [#44](https://github.com/GSTJ/safe-jsx/issues/44) ([#46](https://github.com/GSTJ/safe-jsx/issues/46)) ([00cc4d9](https://github.com/GSTJ/safe-jsx/commit/00cc4d904ab8a900221a1d2e16b91439bcc47df1)) + +## [1.3.4](https://github.com/GSTJ/safe-jsx/compare/v1.3.3...v1.3.4) (2026-07-28) + +### Build System + +* keep CHANGELOG.md in the published tarball ([#45](https://github.com/GSTJ/safe-jsx/issues/45)) ([487f5ef](https://github.com/GSTJ/safe-jsx/commit/487f5ef40ddec413d22a647930a6c95bf6e41439)), references [#44](https://github.com/GSTJ/safe-jsx/issues/44) + +## [1.3.3](https://github.com/GSTJ/safe-jsx/compare/v1.3.2...v1.3.3) (2026-07-28) + +### Build System + +* make the eslint peer optional ([b0c36cb](https://github.com/GSTJ/safe-jsx/commit/b0c36cba0c2b47cc1b529aae2ac50e95f0961a69)) +* publish with npm's files field instead of npmignore ([b49634e](https://github.com/GSTJ/safe-jsx/commit/b49634e4848350548ad3847e77462d68eb8303fd)) + +## [1.3.2](https://github.com/GSTJ/safe-jsx/compare/v1.3.1...v1.3.2) (2026-07-28) + +### Bug Fixes + +* **deps:** make the eslint peer optional ([0f89f8a](https://github.com/GSTJ/safe-jsx/commit/0f89f8a986749f8c3e46a95b62f2559dc1b849ce)) + +### Chores + +* **changelog:** render the types that can change the tarball ([8303da9](https://github.com/GSTJ/safe-jsx/commit/8303da983839255670aa76a74d5eecff846db00f)) + +### Documentation + +* **contributing:** say which commit types reach the changelog ([32ca7f9](https://github.com/GSTJ/safe-jsx/commit/32ca7f99e0feeb83fa803d75d9e78ff518970862)) + +## [1.3.1](https://github.com/GSTJ/safe-jsx/compare/v1.3.0...v1.3.1) (2026-07-28) + +### Bug Fixes + +* **release:** stop running oxfmt over the generated CHANGELOG ([0abd1fe](https://github.com/GSTJ/safe-jsx/commit/0abd1fe55f6e883e09a8474dc5eb99d37e086c84)) + +### Build System + +* drop the dev tsconfig from the tarball ([4c41336](https://github.com/GSTJ/safe-jsx/commit/4c413366e289183aca0cb525071aac656f7eeb18)) +* drop the incremental workaround ([b47fc38](https://github.com/GSTJ/safe-jsx/commit/b47fc38afe36b2e59d2ba16b636c127a5863e990)) +* keep dev config out of the tarball and fix the incremental build ([4c4b2a0](https://github.com/GSTJ/safe-jsx/commit/4c4b2a0c543e6b6b99f4266da717ee25dcefa6f3)) + +### Code Refactoring + +* **rules:** make LegacyRuleContext a type alias ([4255fe3](https://github.com/GSTJ/safe-jsx/commit/4255fe3b951a81e66b83fa18c4d61bd9c7cb2e75)) +* type the plugin sources against the strict shared tsconfig ([0bc750f](https://github.com/GSTJ/safe-jsx/commit/0bc750f73b521dda2e4eb644a3e0523fe6a3aba2)) + +### Chores + +* **changelog:** rebuild in the generator's own bullet style ([0f15905](https://github.com/GSTJ/safe-jsx/commit/0f15905c891d68ce524646877263b4a904db7c5e)) +* **config:** adopt the magic oxlint, oxfmt and tsconfig presets ([9423539](https://github.com/GSTJ/safe-jsx/commit/9423539818b9d53d20f82e6e86958997fc4378b7)) +* **config:** build the lint config with extendConfig ([df8f17f](https://github.com/GSTJ/safe-jsx/commit/df8f17f644787744fd714018a5de786b08cbc92e)) +* **deps:** move the magic stack to 1.1.0 ([55f8751](https://github.com/GSTJ/safe-jsx/commit/55f87518849789dde154fc55c2b119e144399a8e)) +* **deps:** move the magic stack to 1.2.0 ([008b298](https://github.com/GSTJ/safe-jsx/commit/008b298024171fae03e1ef2f6da38ed2abd11f6d)) +* **deps:** move to pnpm and swap eslint/prettier tooling for the magic stack ([faceee1](https://github.com/GSTJ/safe-jsx/commit/faceee1310e32e5f4bf91ec3b6122c4e3db0adee)) +* **deps:** replace the deprecated conventional-changelog-cli ([27f1e1e](https://github.com/GSTJ/safe-jsx/commit/27f1e1edb5ccaf1f6436e680ea37044eb56754fa)), references [#33](https://github.com/GSTJ/safe-jsx/issues/33) +* **renovate:** say what actually sets the typescript ceiling ([11a0f20](https://github.com/GSTJ/safe-jsx/commit/11a0f20f90cc1b4706e3542b99d82c73120dd9c6)) + +### Documentation + +* **config:** correct why this config uses extendConfig ([2fc12b3](https://github.com/GSTJ/safe-jsx/commit/2fc12b383bc9d92ac3c4169dc42b20d5c2fa5410)) +* **config:** say why the eslint-plugin meta rules are not wired up ([9cef61c](https://github.com/GSTJ/safe-jsx/commit/9cef61c8d6c185e555362a94b97872f9c68966fe)) + ## [1.3.0](https://github.com/GSTJ/safe-jsx/compare/v1.2.0...v1.3.0) (2026-07-27) ### Bug Fixes diff --git a/tools/changelog-preset.mjs b/tools/changelog-preset.mjs index 128667b..cc5b4a4 100644 --- a/tools/changelog-preset.mjs +++ b/tools/changelog-preset.mjs @@ -30,6 +30,19 @@ // positive control for that. import createPreset from "conventional-changelog-conventionalcommits"; +/** + * Keeps a tagged release on the calendar date recorded by git. The writer's + * default converts offset-aware timestamps to UTC, which can move a late + * release into the next day when the changelog is regenerated. + * + * @param {string | Date} date + * @returns {string} + */ +export const formatCommitDate = (date) => + typeof date === "string" + ? date.slice(0, 10) + : date.toISOString().slice(0, 10); + /** @type {import("conventional-changelog-conventionalcommits").CommitType[]} */ export const TYPES = [ { type: "feat", section: "Features", effect: "bump" }, @@ -46,4 +59,10 @@ export const TYPES = [ { type: "test", section: "Tests", effect: "hidden" }, ]; -export default createPreset({ types: TYPES }); +const preset = + /** @type {{ writer: { formatDate?: typeof formatCommitDate } }} */ ( + /** @type {unknown} */ (createPreset({ types: TYPES })) + ); +preset.writer.formatDate = formatCommitDate; + +export default preset; diff --git a/tools/changelog.mjs b/tools/changelog.mjs index 293792e..f4e5589 100644 --- a/tools/changelog.mjs +++ b/tools/changelog.mjs @@ -12,15 +12,17 @@ // 2023 — it also moves 1.0.1's compare link, since the first commit the range // starts from changes once chore is visible. Published notes stay as published. // Everything above the freeze is still generated from git on every run. -import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const FROZEN_AT = "1.3.0"; +import { ConventionalChangelog } from "conventional-changelog"; + +import preset, { formatCommitDate } from "./changelog-preset.mjs"; + +const FROZEN_AT = "1.3.8"; const here = import.meta.dirname; const historyPath = join(here, "changelog-history.md"); -const presetPath = join(here, "changelog-preset.mjs"); const header = `# Changelog @@ -33,20 +35,12 @@ tools/changelog-history.md. `; -const body = execFileSync( - process.platform === "win32" ? "npx.cmd" : "npx", - [ - "conventional-changelog", - "--config", - presetPath, - "--release-count", - "0", - // Without this the CLI writes CHANGELOG.md itself and leaves stdout empty, - // which would skip the header and trip the guard below. - "--stdout", - ], - { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, -); +const generator = new ConventionalChangelog() + .readPackage() + .config(preset) + .options({ formatDate: formatCommitDate, releaseCount: 0 }); +let body = ""; +for await (const chunk of generator.write()) body += chunk; if (!body.trim()) { console.error( @@ -68,6 +62,7 @@ if (freezeIndex === -1) { const generated = body.slice(0, freezeIndex).trim(); const history = readFileSync(historyPath, "utf8").trim(); +const current = generated ? `${generated}\n\n${history}` : history; -writeFileSync("CHANGELOG.md", `${header + generated}\n\n${history}\n`); +writeFileSync("CHANGELOG.md", `${header + current}\n`); console.log("CHANGELOG.md rebuilt"); diff --git a/tools/release-check.mjs b/tools/release-check.mjs new file mode 100644 index 0000000..2cc24da --- /dev/null +++ b/tools/release-check.mjs @@ -0,0 +1,272 @@ +// Regression controls for the maintainer-only release tools. +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { env } from "node:process"; + +const here = import.meta.dirname; +const root = dirname(here); +const changelogPath = join(here, "changelog.mjs"); +const changelogPresetPath = join(here, "changelog-preset.mjs"); +const tagReleasePath = join(here, "tag-release.mjs"); + +/** + * @param {string} cwd + * @param {...string} args + * @returns {string} + */ +const git = (cwd, ...args) => + execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + +/** + * @template T + * @param {(directory: string) => T} run + * @returns {T} + */ +const inTempDir = (run) => { + const directory = mkdtempSync(join(tmpdir(), "safe-jsx-release-check-")); + try { + return run(directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}; + +/** @param {string} repo */ +const runTagRelease = (repo) => + spawnSync(process.execPath, [tagReleasePath], { + cwd: repo, + encoding: "utf8", + timeout: 30_000, + }); + +/** + * @param {string} repo + * @param {string} tagName + */ +const tagObject = (repo, tagName) => + git(repo, "rev-parse", `refs/tags/${tagName}`).trim(); + +/** + * @param {string} repo + * @param {string} tagName + */ +const tagTarget = (repo, tagName) => + git(repo, "rev-parse", `refs/tags/${tagName}^{commit}`).trim(); + +/** + * @param {string} repo + * @param {string} tagName + */ +const tagMessage = (repo, tagName) => { + const object = git(repo, "cat-file", "-p", `refs/tags/${tagName}`); + return object.slice(object.indexOf("\n\n") + 2); +}; + +/** + * @param {string} directory + * @returns {string} + */ +const initReleaseRepo = (directory) => { + const remote = join(directory, "remote.git"); + const repo = join(directory, "repo"); + mkdirSync(repo); + git(directory, "init", "--quiet", "--bare", remote); + git(repo, "init", "--quiet", "--initial-branch", "main"); + git(repo, "config", "user.name", "release check"); + git(repo, "config", "user.email", "check@example.com"); + git(repo, "remote", "add", "origin", remote); + + writeFileSync( + join(repo, "package.json"), + `${JSON.stringify({ name: "release-check", version: "1.3.8" }, null, 2)}\n`, + ); + writeFileSync( + join(repo, "CHANGELOG.md"), + "# Changelog\n\n## [1.3.8] (2026-08-05)\n\n* previous release\n", + ); + git(repo, "add", "package.json", "CHANGELOG.md"); + git(repo, "commit", "--quiet", "-m", "chore: initial"); + + writeFileSync( + join(repo, "package.json"), + `${JSON.stringify({ name: "release-check", version: "1.3.9" }, null, 2)}\n`, + ); + writeFileSync( + join(repo, "CHANGELOG.md"), + "# Changelog\n\n## [1.3.9] (2026-08-17)\n\n* secure the release tools\n\n## [1.3.8] (2026-08-05)\n\n* previous release\n", + ); + git(repo, "add", "package.json", "CHANGELOG.md"); + git(repo, "commit", "--quiet", "-m", "v1.3.9"); + + return repo; +}; + +/** @type {[string, () => void][]} */ +const checks = [ + [ + "missing changelog dependency never reaches npx", + () => + inTempDir((directory) => { + const fixture = join(directory, "fixture"); + const tools = join(fixture, "tools"); + const trap = join(directory, "trap"); + const marker = join(directory, "npx-ran"); + mkdirSync(tools, { recursive: true }); + mkdirSync(trap); + copyFileSync(changelogPath, join(tools, "changelog.mjs")); + copyFileSync(changelogPresetPath, join(tools, "changelog-preset.mjs")); + + const fakeNpx = join( + trap, + process.platform === "win32" ? "npx.cmd" : "npx", + ); + writeFileSync( + fakeNpx, + process.platform === "win32" + ? `@echo off\r\ntype nul > "${marker}"\r\nexit /b 97\r\n` + : `#!/bin/sh\n: > "${marker}"\nexit 97\n`, + ); + if (process.platform !== "win32") chmodSync(fakeNpx, 0o755); + + const result = spawnSync( + process.execPath, + [join(tools, "changelog.mjs")], + { + cwd: fixture, + encoding: "utf8", + env: { + ...env, + PATH: `${trap}${delimiter}${env.PATH ?? ""}`, + npm_config_registry: "http://127.0.0.1:9", + }, + timeout: 5_000, + }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /conventional-changelog/u); + assert.equal(existsSync(marker), false, "the npx trap was executed"); + assert.equal(existsSync(join(fixture, "CHANGELOG.md")), false); + }), + ], + [ + "same-version releases cannot make npm force a tag", + () => { + const npmConfig = readFileSync(join(root, ".npmrc"), "utf8"); + assert.match(npmConfig, /^allow-same-version\s*=\s*false$/mu); + }, + ], + [ + "release tags are annotated and idempotent", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + const firstRun = runTagRelease(repo); + assert.equal(firstRun.status, 0, firstRun.stderr); + assert.equal(git(repo, "cat-file", "-t", "v1.3.9").trim(), "tag"); + assert.equal( + tagTarget(repo, "v1.3.9"), + git(repo, "rev-parse", "HEAD").trim(), + ); + assert.equal( + tagMessage(repo, "v1.3.9"), + "## [1.3.9] (2026-08-17)\n\n* secure the release tools\n", + ); + + const firstObject = tagObject(repo, "v1.3.9"); + const secondRun = runTagRelease(repo); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.equal(tagObject(repo, "v1.3.9"), firstObject); + + git(repo, "tag", "--delete", "v1.3.9"); + const thirdRun = runTagRelease(repo); + assert.equal(thirdRun.status, 0, thirdRun.stderr); + assert.equal(tagObject(repo, "v1.3.9"), firstObject); + }), + ], + [ + "an unpublished release tag can follow its squash commit", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + const firstRun = runTagRelease(repo); + assert.equal(firstRun.status, 0, firstRun.stderr); + const releaseBranchTarget = tagTarget(repo, "v1.3.9"); + + git(repo, "commit", "--quiet", "--allow-empty", "-m", "v1.3.9 (#99)"); + const squashTarget = git(repo, "rev-parse", "HEAD").trim(); + const secondRun = runTagRelease(repo); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.notEqual(squashTarget, releaseBranchTarget); + assert.equal(tagTarget(repo, "v1.3.9"), squashTarget); + }), + ], + [ + "a published release tag is immutable", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + const firstRun = runTagRelease(repo); + assert.equal(firstRun.status, 0, firstRun.stderr); + git(repo, "push", "--quiet", "origin", "refs/tags/v1.3.9"); + const publishedObject = tagObject(repo, "v1.3.9"); + + git(repo, "commit", "--quiet", "--allow-empty", "-m", "v1.3.9 (#99)"); + const secondRun = runTagRelease(repo); + assert.notEqual(secondRun.status, 0); + assert.match(secondRun.stderr, /already published/u); + assert.equal(tagObject(repo, "v1.3.9"), publishedObject); + assert.equal( + git( + directory, + "--git-dir", + join(directory, "remote.git"), + "rev-parse", + "refs/tags/v1.3.9", + ).trim(), + publishedObject, + ); + }), + ], + [ + "an unexpected local annotation is preserved", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + git(repo, "tag", "-a", "v1.3.9", "-m", "keep this annotation"); + const originalObject = tagObject(repo, "v1.3.9"); + const result = runTagRelease(repo); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /unexpected annotation/u); + assert.equal(tagObject(repo, "v1.3.9"), originalObject); + }), + ], +]; + +for (const [name, check] of checks) { + try { + check(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +console.log(`release tooling check passed (${checks.length} controls)`); diff --git a/tools/tag-release.mjs b/tools/tag-release.mjs new file mode 100644 index 0000000..4cb022f --- /dev/null +++ b/tools/tag-release.mjs @@ -0,0 +1,179 @@ +// Creates or finalizes the annotated tag for the version in package.json. +// +// npm creates an annotated tag before postversion. A release PR can leave that +// unpublished tag on the PR commit, while the release belongs on the squash +// commit from main. This script can update that local tag only while the remote +// has no tag with the same name. Published tags are immutable. +import { execFileSync, spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const here = import.meta.dirname; +const releaseNotesPath = join(here, "release-notes.mjs"); + +/** @param {string} message */ +const fail = (message) => { + console.error(message); + process.exit(1); +}; + +/** + * @param {string[]} args + * @param {Omit} [options] + * @returns {string} + */ +const git = (args, options = {}) => + execFileSync("git", args, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + ...options, + }); + +const packageJson = JSON.parse(readFileSync("package.json", "utf8")); +const { version } = packageJson; +if (typeof version !== "string" || version.length === 0) { + fail("package.json has no version, refusing to create a release tag"); +} + +const tagName = `v${version}`; +const tagRef = `refs/tags/${tagName}`; +const releaseNotes = execFileSync(process.execPath, [releaseNotesPath], { + encoding: "utf8", +}); + +if (!releaseNotes.startsWith(`## [${version}]`)) { + fail( + `the newest changelog section is not ${version}, refusing to create ${tagName}`, + ); +} + +const status = git(["status", "--porcelain"]); +if (status.trim()) { + fail("the worktree is dirty, refusing to create a release tag"); +} + +const head = git(["rev-parse", "HEAD"]).trim(); +const subject = git(["show", "-s", "--format=%s", "HEAD"]).trim(); +const escapedVersion = version.replaceAll( + /[.*+?^${}()|[\]\\]/g, + String.raw`\$&`, +); +if (!new RegExp(`^v${escapedVersion}(?: \\(#\\d+\\))?$`).test(subject)) { + fail( + `HEAD is "${subject}" instead of the ${tagName} release commit, refusing to tag it`, + ); +} + +const localResult = spawnSync( + "git", + ["rev-parse", "--verify", "--quiet", tagRef], + { encoding: "utf8" }, +); +if (localResult.error) throw localResult.error; +if (localResult.status !== 0 && localResult.status !== 1) { + process.stderr.write(localResult.stderr); + process.exit(localResult.status ?? 1); +} +const localObject = + localResult.status === 0 ? localResult.stdout.trim() : undefined; + +const remoteResult = spawnSync( + "git", + ["ls-remote", "--exit-code", "--tags", "--refs", "origin", tagRef], + { encoding: "utf8", timeout: 30_000 }, +); +if (remoteResult.error) throw remoteResult.error; +if (remoteResult.status !== 0 && remoteResult.status !== 2) { + process.stderr.write(remoteResult.stderr); + fail(`could not check whether ${tagName} is published`); +} +const remoteObject = + remoteResult.status === 0 + ? remoteResult.stdout.trim().split(/\s+/u)[0] + : undefined; + +let localTarget; +let localMessage; +if (localObject) { + const objectType = git(["cat-file", "-t", localObject]).trim(); + if (objectType !== "tag") { + fail(`${tagName} is not annotated, refusing to replace it`); + } + + localTarget = git(["rev-parse", `${tagRef}^{commit}`]).trim(); + const tagObject = git(["cat-file", "-p", localObject]); + const messageStart = tagObject.indexOf("\n\n"); + localMessage = messageStart === -1 ? "" : tagObject.slice(messageStart + 2); + + if (localMessage !== `${tagName}\n` && localMessage !== releaseNotes) { + fail(`${tagName} has an unexpected annotation, refusing to replace it`); + } +} + +if (remoteObject) { + if ( + remoteObject === localObject && + localTarget === head && + localMessage === releaseNotes + ) { + console.log(`${tagName} already matches the published release`); + process.exit(0); + } + + fail(`${tagName} is already published, refusing to replace it`); +} + +if (localTarget === head && localMessage === releaseNotes) { + console.log(`${tagName} already matches the release`); + process.exit(0); +} + +if (localTarget && localTarget !== head) { + const releaseFiles = spawnSync( + "git", + [ + "diff", + "--quiet", + localTarget, + head, + "--", + "package.json", + "CHANGELOG.md", + ], + { encoding: "utf8" }, + ); + if (releaseFiles.error) throw releaseFiles.error; + if (releaseFiles.status !== 0) { + fail( + `${tagName} points to a commit with different release files, refusing to move it`, + ); + } +} + +const taggerIdentity = git(["var", "GIT_COMMITTER_IDENT"]) + .trim() + .replace(/ \d+ [+-]\d{4}$/u, ""); +const taggerDate = git([ + "show", + "-s", + "--date=format:%z", + "--format=%ct %cd", + "HEAD", +]).trim(); +const tagObject = [ + `object ${head}`, + "type commit", + `tag ${tagName}`, + `tagger ${taggerIdentity} ${taggerDate}`, + "", + releaseNotes, +].join("\n"); +const nextObject = git(["mktag"], { input: tagObject }).trim(); +const expectedObject = localObject ?? "0".repeat(nextObject.length); + +git(["update-ref", tagRef, nextObject, expectedObject]); +console.log( + localObject + ? `${tagName} finalized at ${head}` + : `${tagName} created at ${head}`, +);