diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dac40c884..084fcb613 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,13 @@ on: # Running this from a branch other than main is harmless — .releaserc.yaml # releases from main only, so semantic-release finds nothing to do. workflow_dispatch: + inputs: + minor: + description: >- + Release a minor version. Leave unticked for a patch, which is + what everything releasable defaults to. + type: boolean + default: false defaults: run: @@ -97,10 +104,25 @@ jobs: - run: pnpm install --frozen-lockfile + # The checks pr.yml already ran, run again against main. + # + # This is not belt and braces. The main ruleset does not require a pull + # request to be up to date with main before it merges, so two branches + # that were each green on their own can still break main together, and + # a release is the first thing after that which anyone would notice. + # + # Spelled out rather than calling `pnpm check`, because that starts with + # `pnpm fmt`, which rewrites files instead of reporting on them. Keep + # this list and the jobs in pr.yml in step with each other. + - run: pnpm lint + - run: pnpm fta + - run: pnpm build:check + - run: pnpm examples:check + - run: pnpm test:coverage + # The same check pr.yml runs as Pack: builds the tarball, installs it - # into a throwaway project and imports every export subpath. The pull - # request already ran it, but main is what gets published, so this is - # the last gate before a broken dist/ reaches the registry. + # into a throwaway project and imports every export subpath. The last + # gate before a broken dist/ reaches the registry. - run: pnpm verify:pack # Decides the version, publishes to npm, tags and writes the GitHub @@ -109,3 +131,6 @@ jobs: - run: pnpm exec semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Read by analyzeCommitsCmd in .releaserc.yaml, which raises the + # release to a minor when this is "true". + RELEASE_MINOR: ${{ inputs.minor }} diff --git a/.releaserc.yaml b/.releaserc.yaml index 321fb9854..c0b324401 100644 --- a/.releaserc.yaml +++ b/.releaserc.yaml @@ -9,37 +9,71 @@ branches: - main plugins: - # Reads the conventional-commit subjects on main and works out the bump. - # The stock rules already match what this repository writes: feat gives a - # minor, fix and perf give a patch, and chore, test, docs, refactor, style, - # build and ci release nothing. + # Reads the commit subjects on main and works out the bump. # - # Two of the stock rules are worth knowing about, since neither is angular. - # A commit the parser recognises as a revert gives a patch, which is wanted: - # undoing something already published is a change consumers need. The rest — - # Atom emoji, Ember and ESLint tags, Express components, JSHint's uppercase - # types — read commit fields the angular parser never fills in, so they can - # match nothing here. + # Anything that ships is a patch. A minor is not something a commit subject + # can ask for: it is the checkbox on the workflow, below. The reasoning is + # that "is this significant enough to be a minor" is a judgement about the + # release as a whole, made once, by a person looking at the whole batch — + # not a judgement each commit subject should be trusted to make. Reserving + # it that way is also what keeps the minor number meaning something. # - # No custom releaseRules deliberately. A rule set here would be a second - # place defining the commit convention, and the catch-all needed to make it - # exhaustive can swallow feat and fix outright if it is ordered wrong. - - "@semantic-release/commit-analyzer" + # So the ladder is: patch automatically, minor by checkbox, major by hand + # and never from this workflow at all. + # + # The breaking rule has to come first and has to be here. Without it the + # feat rule below matches "feat!: …" too, and a breaking change would go out + # as a patch — worse than any of this, because it would be silent. With it, + # both "feat!: …" and a BREAKING CHANGE: footer reach the guard further + # down and fail the run. + # + # docs, test, chore, ci and style are absent on purpose: they change nothing + # a consumer installs, so they release nothing. + - - "@semantic-release/commit-analyzer" + - preset: conventionalcommits + releaseRules: + - breaking: true + release: major + - type: feat + release: patch + - type: fix + release: patch + - type: perf + release: patch + - type: refactor + release: patch + - type: build + release: patch - # Turns those same commits into the GitHub Release notes. - - "@semantic-release/release-notes-generator" + # Turns those same commits into the GitHub Release notes. Same preset as the + # analyzer, so the notes group commits the way the analyzer read them. + - - "@semantic-release/release-notes-generator" + - preset: conventionalcommits - # Majors are published by hand, deliberately: a breaking change to a - # simulator is a decision about what consumers have to rewrite, not - # something a commit footer should be able to trigger on its own. + # The two ends of the ladder the analyzer above deliberately cannot reach. + # + # analyzeCommitsCmd raises the release to a minor when the workflow was + # started with its checkbox ticked. semantic-release takes the highest + # result across every analyzer, so this can only ever raise a patch to a + # minor — it cannot lower anything, and it leaves a major alone. Printing + # nothing, which is what happens when the box is unticked, is read as "no + # opinion" rather than "no release". + # + # Ticking the box with nothing releasable on main would publish a minor + # with empty notes. That is a deliberate act with an obvious result, so it + # is left possible rather than guarded against. # - # This runs after the version is worked out and before anything is - # published, so a stray "BREAKING CHANGE:" footer fails the run rather than - # silently shipping 2.0.0. The alternative — mapping breaking changes down - # to a minor — was rejected because it would put a false version number - # against a genuinely breaking change. + # verifyReleaseCmd refuses majors. A breaking change to a simulator is a + # decision about what consumers have to rewrite, so it is made by a person + # publishing by hand, not by a marker in a subject line. This runs after + # the version is worked out and before anything is published, so "feat!: …" + # or a BREAKING CHANGE: footer fails the run rather than shipping 2.0.0. + # Mapping breaking changes down to a minor was rejected: it would put a + # false version number against a genuinely breaking change. - - "@semantic-release/exec" - - verifyReleaseCmd: >- + - analyzeCommitsCmd: >- + if [ "$RELEASE_MINOR" = "true" ]; then echo minor; fi + verifyReleaseCmd: >- if [ "${nextRelease.type}" = "major" ]; then echo "Refusing to publish ${nextRelease.version} automatically: major releases are published by hand." >&2; diff --git a/package.json b/package.json index 79a760111..81f8eed96 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "aws-jwt-verify": "^5.2.1", "aws-sdk-client-mock": "^4.1.0", "constructs": "^10.8.0", + "conventional-changelog-conventionalcommits": "10.2.1", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsdoc": "^63.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 342f05195..b0c2269c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: constructs: specifier: ^10.8.0 version: 10.8.0 + conventional-changelog-conventionalcommits: + specifier: 10.2.1 + version: 10.2.1 eslint: specifier: ^10.8.0 version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) @@ -416,6 +419,10 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@conventional-changelog/template@1.2.1': + resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} + engines: {node: '>=22'} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -1461,6 +1468,10 @@ packages: resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} engines: {node: '>=18'} + conventional-changelog-conventionalcommits@10.2.1: + resolution: {integrity: sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==} + engines: {node: '>=22'} + conventional-changelog-writer@8.4.0: resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==} engines: {node: '>=18'} @@ -3484,6 +3495,8 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@conventional-changelog/template@1.2.1': {} + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -4423,6 +4436,10 @@ snapshots: dependencies: compare-func: 2.0.0 + conventional-changelog-conventionalcommits@10.2.1: + dependencies: + '@conventional-changelog/template': 1.2.1 + conventional-changelog-writer@8.4.0: dependencies: '@simple-libs/stream-utils': 1.2.0