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..0b2b797 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: @@ -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 }} @@ -130,7 +146,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..176e019 --- /dev/null +++ b/tools/changelog-preset.mjs @@ -0,0 +1,58 @@ +// 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. 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, +// 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 });