From b6e42c3db3d367055949ebe2b164fb305bc9e635 Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Tue, 28 Jul 2026 14:51:27 -0300 Subject: [PATCH 1/2] fix(changelog): use effect, because hidden does nothing `{ type: "style", hidden: true }` never hid anything. conventional-changelog-conventionalcommits 10 reads `effect` and no other field, so `style: format the repo with oxfmt` shipped in the v1.0.12 release body. It rendered as a bare bullet above Bug Fixes with no section heading, because the entry carried `hidden` instead of `section` and the writer had no group to file it under. `test` was marked the same way. The list moves to `effect` throughout and follows the same rule as the other repos: whether a type can change what npm hands a consumer. `files` is ["build", "app.plugin.js"], so `build`, `refactor`, `chore` and `docs` render, and `ci`, `style` and `test` do not. `ci` had its own section until now. Everything that renders without being able to change the version is `effect: "changelog"`, so a release of nothing but `build:` commits is still a patch. `commitFilter` is gone too, for the same reason: the plugin destructures `preset`, `context`, `gitRawCommitsOpts`, `parserOpts`, `writerOpts` and `whatBump`, and the key appears nowhere in its source. Nothing was lost by it doing nothing. What actually keeps the `chore(release)` bump commits out of the notes is the workflow merging the bump PR instead of squashing it, since conventional-changelog skips merge commits. Both v1.0.12's and v1.0.13's have two parents. The workflow comment claimed the filter did it; it now says what does. The config moves to `.release-it.mjs` so the type list can live in tools/changelog-preset.mjs and be shared with tools/changelog-check.mjs, which renders a synthetic history of every type and asserts each one renders or stays out per a policy written down longhand. It also measures the bump, and re-renders through a sabotaged list to prove the assertions can fail. Wired into CI. --- .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 9 +- .release-it.cjs => .release-it.mjs | 38 ++-- oxlint.config.mts | 8 +- package.json | 4 + pnpm-lock.yaml | 9 + tools/changelog-check.mjs | 332 +++++++++++++++++++++++++++++ tools/changelog-preset.mjs | 55 +++++ 8 files changed, 440 insertions(+), 24 deletions(-) rename .release-it.cjs => .release-it.mjs (62%) create mode 100644 tools/changelog-check.mjs create mode 100644 tools/changelog-preset.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f377664..95506cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,13 @@ jobs: build-command: pnpm run build test-command: pnpm run test # `build/` is generated, so the only way to know the published tarball - # isn't empty is to pack one. - extra-command: npm pack --dry-run + # isn't empty is to pack one. changelog:check renders a synthetic history + # of every commit type through the real changelog preset and fails if a + # type renders against policy or a breaking change goes missing. + # + # One `extra-command`, so they're chained. `&&` short-circuits, which is + # what we want: either failing fails the job. + extra-command: npm pack --dry-run && pnpm run changelog:check # Nothing here uses turbo. turbo-cache: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b05b19d..724e242 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: - run: pnpm run build # Publishes to npm, pushes the annotated tag and creates the GitHub - # release. The version bump commit stays local, see .release-it.cjs. + # release. The version bump commit stays local, see .release-it.mjs. - name: Release id: release env: @@ -130,7 +130,10 @@ jobs: echo "opened $url" # The merge commit lands on master too, so it needs a conventional - # subject. `.release-it.cjs` filters `chore(release)` out of the - # changelog, so neither this nor the bump commit shows up next time. + # subject. `--merge` rather than `--squash` is what keeps both this + # and the bump commit out of the next changelog: conventional-changelog + # skips merge commits, and a merge is what this produces. There used + # to be a `commitFilter` in the release-it config claiming that job, + # but the plugin never read the key. gh pr merge "$number" --auto --merge --delete-branch \ --subject "chore(release): v$VERSION (#$number)" --body "" diff --git a/.release-it.cjs b/.release-it.mjs similarity index 62% rename from .release-it.cjs rename to .release-it.mjs index b56dae9..40f5b1a 100644 --- a/.release-it.cjs +++ b/.release-it.mjs @@ -3,8 +3,15 @@ * release workflow can't push the version bump straight to it. release-it * commits and tags locally, pushes only the tag (tags aren't covered by a * branch ruleset), and the workflow lands the bump commit through a PR. + * + * ESM rather than `.release-it.cjs` so the type list can live in + * tools/changelog-preset.mjs and be shared with tools/changelog-check.mjs. + * conventional-changelog-conventionalcommits is ESM-only, so a CJS config + * could only reach it through a dynamic import. */ -module.exports = { +import { TYPES } from "./tools/changelog-preset.mjs"; + +const config = { git: { requireBranch: "master", commitMessage: "chore(release): v${version}", @@ -39,26 +46,21 @@ module.exports = { "@release-it/conventional-changelog": { infile: "CHANGELOG.md", header: "# Changelog", - // Each release leaves a `chore(release)` bump commit and the merge commit - // that lands it. Neither is worth a changelog line. - commitFilter: (commit) => - !(commit.header ?? "").startsWith("chore(release)"), + // There used to be a `commitFilter` here, meant to keep the + // `chore(release)` bump commits out of the notes. The plugin never read + // it: it destructures `preset`, `context`, `gitRawCommitsOpts`, + // `parserOpts`, `writerOpts` and `whatBump`, and `commitFilter` appears + // nowhere in its source. Nothing was lost by it doing nothing, because + // the bump commits reach master as merge commits (both v1.0.12's and + // v1.0.13's have two parents) and conventional-changelog skips those + // anyway. Removing the key so the config stops claiming a behaviour it + // never had. preset: { name: "conventionalcommits", - types: [ - { type: "feat", section: "Features" }, - { type: "fix", section: "Bug Fixes" }, - { type: "perf", section: "Performance" }, - { type: "refactor", section: "Code Refactoring" }, - { type: "docs", section: "Documentation" }, - { type: "build", section: "Build System" }, - { type: "ci", section: "Continuous Integration" }, - { type: "chore", section: "Chores" }, - { type: "revert", section: "Reverts" }, - { type: "test", hidden: true }, - { type: "style", hidden: true }, - ], + types: TYPES, }, }, }, }; + +export default config; diff --git a/oxlint.config.mts b/oxlint.config.mts index 1b00c53..7c88079 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -9,11 +9,17 @@ import base from "magic-oxlint-config/base"; // with nothing local to drift from the preset. export default extendConfig(base, { overrides: [ + { + // tools/ holds the changelog preset and its control script. Printing to + // the terminal is what the control is for, the CI job reads its output. + files: ["tools/**"], + rules: { "no-console": "off" }, + }, { // `${version}` and friends in this file are release-it's own template // syntax, interpolated by release-it at release time. They are supposed // to reach it uninterpolated, so a real template literal would be the bug. - files: [".release-it.cjs"], + files: [".release-it.mjs"], rules: { "no-template-curly-in-string": "off" }, }, ], diff --git a/package.json b/package.json index 6073823..62469bc 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "scripts": { "build": "tsc --project tsconfig.build.json", + "changelog:check": "node tools/changelog-check.mjs", "clean": "expo-module clean", "lint": "oxlint --report-unused-disable-directives --deny-warnings", "lint:fix": "oxlint --report-unused-disable-directives --deny-warnings --fix", @@ -40,6 +41,9 @@ "@release-it/conventional-changelog": "^12.0.0", "@types/jest": "^29.5.14", "@types/node": "^24.13.3", + "conventional-changelog": "^8.1.0", + "conventional-changelog-conventionalcommits": "^10.2.1", + "conventional-recommended-bump": "^12.1.0", "expo": ">=49.0.0", "expo-module-scripts": "^3.0.3", "jest": "^29", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee64ccf..6bb14f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,15 @@ importers: '@types/node': specifier: ^24.13.3 version: 24.13.3 + conventional-changelog: + specifier: ^8.1.0 + version: 8.1.0(conventional-commits-filter@6.0.1) + conventional-changelog-conventionalcommits: + specifier: ^10.2.1 + version: 10.2.1 + conventional-recommended-bump: + specifier: ^12.1.0 + version: 12.1.0 expo: specifier: '>=49.0.0' version: 57.0.8(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.2.17)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.3) diff --git a/tools/changelog-check.mjs b/tools/changelog-check.mjs new file mode 100644 index 0000000..9e0b378 --- /dev/null +++ b/tools/changelog-check.mjs @@ -0,0 +1,332 @@ +// Control for tools/changelog-preset.mjs. +// +// The preset decides which commit types reach the changelog and which of them +// can move the version, so the risk it carries is silent omission: mark a type +// hidden and its commits vanish with no error anywhere. A breaking change +// vanishing that way is the one failure this package cannot ship. +// +// POLICY below is written out longhand on purpose. Deriving it from the +// preset's own type list would make every assertion a tautology — hide a +// section and the check would just change its mind about what it expected. So +// the policy is stated here, the preset is asserted to match it, and the +// rendered output is asserted to match it too. Changing what reaches the +// changelog means changing both files, deliberately. +// +// Three things get checked: +// +// 1. the type list agrees with the policy +// 2. a synthetic history of every type renders the way the policy says, +// including the `ci!:` case — `ci` is hidden and its breaking note still +// has to surface +// 3. the recommended bump respects `effect`, so a release of nothing but +// `build:` and `chore:` commits cannot come out a minor +// +// Then it renders the same history through a sabotaged type list and fails if +// *that* passes. An assertion that cannot fail is worth nothing. +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 { Bumper } from "conventional-recommended-bump"; + +import preset, { 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 + * without raising it, `hidden` does neither. + * + * @type {Record} + */ +const POLICY = { + feat: "bump", + feature: "bump", + fix: "bump", + perf: "bump", + revert: "bump", + build: "changelog", + refactor: "changelog", + chore: "changelog", + docs: "changelog", + ci: "hidden", + style: "hidden", + test: "hidden", +}; + +// One commit per type, plus three breaking ones spread across a bump type, a +// changelog type and a hidden type. +const COMMITS = [ + "feat: add a thing", + "fix: correct a thing", + "build: shrink the tarball", + "refactor: rewrite the internals", + "chore(deps): bump something", + "docs: update the readme", + "ci: tweak the workflow", + "style: reformat", + "test: add cases", + "perf!: drop the slow path\n\nBREAKING CHANGE: the slow path is gone", + "chore: retire the legacy loader\n\nBREAKING CHANGE: the legacy loader is gone", + "ci!: require node 24\n\nBREAKING CHANGE: node 22 is no longer supported", +]; + +// Subject fragment -> the type it was committed under. Matching on subjects +// rather than section headings keeps the check working in a repo that renames +// its sections. +const SUBJECTS = { + feat: "add a thing", + fix: "correct a thing", + build: "shrink the tarball", + refactor: "rewrite the internals", + chore: "bump something", + docs: "update the readme", + ci: "tweak the workflow", + style: "reformat", + test: "add cases", +}; + +const NOTES = { + "a bump type (perf!)": "the slow path is gone", + "a changelog type (chore!)": "the legacy loader is gone", + "a HIDDEN type (ci!)": "node 22 is no longer supported", +}; + +/** + * Builds a throwaway repo out of `commits` and hands it to `run`. Async on + * purpose: `bumpFor` below is, and a synchronous `finally` would delete the + * repo out from under it. + * + * `tag` says where v1.0.0 goes, and the two callers need different answers. + * The renderer wants it last, so `--release-count 0` has a released section to + * render — a chunk whose version matches the last tag comes out empty. The + * bumper wants it first, so the commits are the range being measured. + * + * @template T + * @param {string[]} commits + * @param {"before" | "after"} tag + * @param {(repo: string) => T | Promise} run + * @returns {Promise} + */ +const inThrowawayRepo = async (commits, tag, run) => { + const repo = mkdtempSync(join(tmpdir(), "code-push-changelog-")); + /** @param {string[]} args */ + const git = (...args) => + execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: "pipe" }); + + try { + git("init", "--quiet", "--initial-branch", "main"); + git("config", "user.email", "check@example.com"); + git("config", "user.name", "changelog check"); + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ name: "changelog-check", version: "1.0.0" }), + ); + git("add", "."); + git("commit", "--quiet", "-m", "chore: initial"); + if (tag === "before") git("tag", "-a", "v1.0.0", "-m", "v1.0.0"); + + for (const message of commits) { + git( + "commit", + "--quiet", + "--allow-empty", + "--cleanup=verbatim", + "-m", + message, + ); + } + + if (tag === "after") git("tag", "-a", "v1.0.0", "-m", "v1.0.0"); + + return await run(repo); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}; + +/** + * Renders COMMITS through a preset module and returns the changelog text. + * + * @param {string} configPath + * @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 }, + ), + ); + +/** + * The release type conventional-recommended-bump lands on for `commits`, read + * through the real preset. `null` means "nothing here warrants a release". + * + * @param {string[]} commits + * @returns {Promise} + */ +const bumpFor = (commits) => + inThrowawayRepo(commits, "before", async (repo) => { + const bumper = new Bumper(repo); + bumper.loadPreset({ name: "conventionalcommits", types: TYPES }); + // Same double cast .release-it.js needs: the preset publishes + // `createPreset(config?): {}`, so its own return type doesn't describe the + // `whatBump` it demonstrably returns. `bump()` is typed as a union whose + // empty arm has no `releaseType`, which is the `null` case here. + const { whatBump } = + /** @type {{ whatBump: Parameters[0] }} */ ( + /** @type {unknown} */ (await preset) + ); + const recommendation = /** @type {{ releaseType?: string }} */ ( + await bumper.bump(whatBump) + ); + return recommendation.releaseType ?? null; + }); + +/** @type {string[]} */ +const failures = []; +/** + * @param {string} label + * @param {boolean} condition + */ +const expect = (label, condition) => { + if (!condition) failures.push(label); +}; + +// 1. The type list says what the policy says. +for (const [type, effect] of Object.entries(POLICY)) { + const entry = TYPES.find((candidate) => candidate.type === type); + expect(`${type} is in the type list`, entry !== undefined); + expect(`${type} is "${effect}"`, entry?.effect === effect); +} +for (const entry of TYPES) { + expect(`${entry.type} is covered by the policy`, entry.type in POLICY); +} + +/** + * 2. The rendered output says what the policy says. + * + * @param {string} output + * @returns {string[]} + */ +const assessRendering = (output) => { + /** @type {string[]} */ + const found = []; + /** + * @param {string} label + * @param {boolean} condition + */ + const check = (label, condition) => { + if (!condition) found.push(label); + }; + + // Breaking changes render whatever type they hang off, hidden ones included. + // The preset prefixes the heading with a warning sign, so match on the words. + check("BREAKING CHANGES heading", /^### .*BREAKING CHANGES$/m.test(output)); + for (const [label, text] of Object.entries(NOTES)) { + check(`breaking note on ${label}`, output.includes(text)); + } + + for (const [type, subject] of Object.entries(SUBJECTS)) { + const shouldRender = POLICY[type] !== "hidden"; + check( + shouldRender ? `${type} renders` : `${type} stays hidden`, + output.includes(subject) === shouldRender, + ); + } + + return found; +}; + +failures.push(...assessRendering(await render(presetPath))); + +// 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 +// release past a patch. +const bumps = { + breaking: await bumpFor(["feat!: drop the old API"]), + feature: await bumpFor(["feat: add a thing"]), + fix: await bumpFor(["fix: correct a thing"]), + changelogOnly: await bumpFor([ + "build: shrink the tarball", + "refactor: rewrite the internals", + "chore(deps): bump something", + "docs: update the readme", + ]), + hiddenOnly: await bumpFor(["ci: tweak the workflow", "style: reformat"]), +}; + +expect("a breaking feat is a major", bumps.breaking === "major"); +expect("a feat is a minor", bumps.feature === "minor"); +expect("a fix is a patch", bumps.fix === "patch"); +expect( + `build/refactor/chore/docs alone do not bump (got ${bumps.changelogOnly})`, + bumps.changelogOnly === null, +); +expect( + `ci/style alone do not bump (got ${bumps.hiddenOnly})`, + bumps.hiddenOnly === null, +); + +if (failures.length > 0) { + console.error("changelog preset check FAILED:"); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} + +// Negative control. Same commits, same assertions, a type list with every +// visible type forced to `hidden`. POLICY doesn't move, so hiding a section has +// to show up as a failure. +const sabotaged = TYPES.map((entry) => + entry.effect === "hidden" ? entry : { ...entry, effect: "hidden" }, +); +// Written next to the real preset rather than in the temp dir so the bare +// `conventional-changelog-conventionalcommits` specifier still resolves. +const sabotagedPath = join( + here, + `.changelog-preset-sabotaged.${process.pid}.mjs`, +); +writeFileSync( + sabotagedPath, + 'import createPreset from "conventional-changelog-conventionalcommits";\n' + + `export default createPreset({ types: ${JSON.stringify(sabotaged)} });\n`, +); + +let sabotagedFailures; +try { + sabotagedFailures = assessRendering(await render(sabotagedPath)); +} finally { + rmSync(sabotagedPath, { force: true }); +} + +if (sabotagedFailures.length === 0) { + console.error( + "negative control FAILED: hiding every visible type changed nothing, so the assertions above are not testing anything.", + ); + process.exit(1); +} + +console.log( + `changelog preset check passed (${COMMITS.length} synthetic commits, 3 breaking; bumps: feat!=${bumps.breaking}, feat=${bumps.feature}, fix=${bumps.fix}, changelog-only=${bumps.changelogOnly}, hidden-only=${bumps.hiddenOnly})`, +); +console.log( + `negative control passed (hiding the visible types breaks ${sabotagedFailures.length} assertions: ${sabotagedFailures.join(", ")})`, +); + +// `--print` is for looking at the control by hand, which is the only way to +// judge the section names and ordering. The assertions above cannot. +if (process.argv.includes("--print")) { + console.log("\n--- rendered through the real preset ---\n"); + console.log(await render(presetPath)); +} diff --git a/tools/changelog-preset.mjs b/tools/changelog-preset.mjs new file mode 100644 index 0000000..2d47c40 --- /dev/null +++ b/tools/changelog-preset.mjs @@ -0,0 +1,55 @@ +// conventionalcommits preset, retuned so the changelog lists what actually +// ships. `.release-it.mjs` and `tools/changelog-check.mjs` both import this +// list, so the check can't pass against a policy the release doesn't use. +// +// `effect` replaces `hidden`, because `hidden` does nothing. +// conventional-changelog-conventionalcommits 10 reads `effect` and no other +// field, so `{ type: "style", hidden: true }` was never hidden. `style: format +// the repo with oxfmt` shipped in the v1.0.12 release body over an entry that +// claimed to suppress it, and it rendered with no section heading at all: the +// entry carried `hidden` instead of `section`, so the writer had no group to +// put it in and left it as a bare bullet above Bug Fixes. +// +// The split is by whether a type can change what npm hands a consumer. +// `files` is `["build", "app.plugin.js"]`: +// +// renders feat fix perf revert the stock set +// build tsconfig.build.json decides what lands +// in build/ +// refactor rewrites the source tsc emits from +// chore dependency and config moves ship +// docs README.md is inside the tarball +// +// hidden ci .github/ is not in `files` +// style only `build/` ships, and oxfmt over +// `src/` cannot move what tsc emits +// test tests are not in `files` +// +// `effect: "changelog"` is the point: those types render without entering the +// bump count, so a release of nothing but `build:` commits is still a patch. +// Only the `bump` types can raise a version. +// +// Breaking changes are not configurable here and don't need to be. The +// preset's writer sets `discard = false` the moment a commit carries a note, +// so a `BREAKING CHANGE:` footer or a `!` renders its own section whatever +// type it hangs off, hidden ones included. tools/changelog-check.mjs is the +// control for that. +import createPreset from "conventional-changelog-conventionalcommits"; + +/** @type {import("conventional-changelog-conventionalcommits").CommitType[]} */ +export const TYPES = [ + { type: "feat", section: "Features", effect: "bump" }, + { type: "feature", section: "Features", effect: "bump" }, + { type: "fix", section: "Bug Fixes", effect: "bump" }, + { type: "perf", section: "Performance", effect: "bump" }, + { type: "revert", section: "Reverts", effect: "bump" }, + { type: "build", section: "Build System", effect: "changelog" }, + { type: "refactor", section: "Code Refactoring", effect: "changelog" }, + { type: "chore", section: "Chores", effect: "changelog" }, + { type: "docs", section: "Documentation", effect: "changelog" }, + { type: "ci", section: "Continuous Integration", effect: "hidden" }, + { type: "style", section: "Styles", effect: "hidden" }, + { type: "test", section: "Tests", effect: "hidden" }, +]; + +export default createPreset({ types: TYPES }); From c367225a9dcf89dea3712012bf6ea84d01ea5e9c Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Tue, 28 Jul 2026 20:16:02 -0300 Subject: [PATCH 2/2] fix(release): stop the bump PR when nothing warrants a release Now that the types carry `effect`, only the `bump` ones can raise a version, so a cycle of nothing but docs/chore/build/refactor recommends nothing at all. release-it prints "No new version to release", exits 0 and leaves the tree alone. The workflow read that as success and carried on: it pushed a branch at master's head, failed to open a PR against it because there were no commits between the two, and reported that npm and the tag were already out when neither was. Gate the step on the version actually moving and say so in the log instead. The preset comment claimed a build-only release was "still a patch". It isn't, and the check next to it already asserted otherwise. --- .github/workflows/release.yml | 22 +++++++++++++++++++--- tools/changelog-preset.mjs | 7 +++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 724e242..0b2b797 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,9 +79,25 @@ jobs: args="$args --dry-run" fi + before=$(node -p "require('./package.json').version") pnpm run release $args - - echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + after=$(node -p "require('./package.json').version") + + echo "version=$after" >> "$GITHUB_OUTPUT" + + # Now that the changelog types carry `effect`, only the `bump` ones + # can raise a version, so a cycle of nothing but docs/chore/build/ + # refactor commits recommends nothing. release-it prints "No new + # version to release", exits 0 and leaves the tree alone. Without + # this flag the next step would push an empty branch, fail to open a + # PR against it and report that npm and the tag are out when neither + # is. Re-run with the `increment` input if a release was the point. + if [ "$before" = "$after" ]; then + echo "released=false" >> "$GITHUB_OUTPUT" + echo "::notice::No new version to release. Nothing was published or tagged. Dispatch again with an explicit \`increment\` to force one." + else + echo "released=true" >> "$GITHUB_OUTPUT" + fi # master requires status checks and a fresh commit has none, so the bump # has to arrive as a PR. Merge commit, not squash: the tag points at the @@ -99,7 +115,7 @@ jobs: # npm, the tag and the GitHub release are already out at that point, so # only the bump on master is waiting. - name: Land the version bump on master - if: ${{ !inputs.dry-run }} + if: ${{ !inputs.dry-run && steps.release.outputs.released == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.release.outputs.version }} diff --git a/tools/changelog-preset.mjs b/tools/changelog-preset.mjs index 2d47c40..176e019 100644 --- a/tools/changelog-preset.mjs +++ b/tools/changelog-preset.mjs @@ -26,8 +26,11 @@ // test tests are not in `files` // // `effect: "changelog"` is the point: those types render without entering the -// bump count, so a release of nothing but `build:` commits is still a patch. -// Only the `bump` types can raise a version. +// bump count. Only the `bump` types can raise a version, so a cycle of nothing +// but `build:` and `docs:` commits recommends no release at all, not a patch. +// release-it handles that by exiting 0 with "No new version to release" and +// touching nothing, and the release workflow's `increment` input is there to +// force a version out anyway when one is wanted. // // Breaking changes are not configurable here and don't need to be. The // preset's writer sets `discard = false` the moment a commit carries a note,