diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..d7eb4da --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,52 @@ +name: pr title + +# The pull request title is this repository's release contract. +# +# Merges here are squash merges, so a merge writes ONE commit to main and its +# subject is the pull request title. tools/version.mjs reads that subject to +# pick the next version, which makes the title — not the individual commits — +# what decides the release. +# +# Pull request #1 is why this check exists. It carried seven commits, four of +# them `fix:`, and merged under the title "chore: publish as +# @cornerstonejs/jpeg-lossless-decoder-js". `chore` releases nothing, so the +# release workflow published nothing, and the run still reported success +# because "nothing to release" is a legitimate green outcome. The mistake was +# invisible until someone looked at the registry. +# +# This check moves that outcome to where it can still be changed: it fails a +# title that is not conventional, and it states the release a valid title will +# produce. + +on: + pull_request: + # `edited` matters as much as `opened`: the title is what merges, so a + # title corrected after review has to re-run this check. Without `edited` + # a stale pass would sit on a title nobody checked. + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24.20.0' + - name: Check the title + # The title reaches the script through the environment, never through + # the run script's text. A title is attacker-controlled on a fork pull + # request, and `${{ ... }}` interpolation would paste it into the shell + # — a title containing `$(...)` would then execute. + # + # No install: tools/check-pr-title.mjs and tools/conventional.mjs + # import nothing but each other. + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: node tools/check-pr-title.mjs diff --git a/README.md b/README.md index 44991b3..b21715e 100644 --- a/README.md +++ b/README.md @@ -73,21 +73,41 @@ build stable: together with the type annotations in `src/`. ### Publishing -`.github/workflows/release.yml` releases on each push to `main`, and it picks -the version itself from the conventional commits since the last `v*` tag. You -do not edit the version by hand. -| commit | bump | +**The pull request title decides the release.** This repository squash merges, +so a merge writes one commit to `main` and the title of the pull request is +that commit's subject. `.github/workflows/release.yml` reads the subject and +picks the version. You do not edit the version by hand. + +| title | bump | | --- | --- | -| `feat!:`, or a `BREAKING CHANGE:` footer | major | -| `feat:` | minor | -| `fix:`, `perf:` | patch | -| anything else | no release | +| `feat!: …`, or a `BREAKING CHANGE:` footer | major | +| `feat: …` | minor | +| `fix: …`, `perf: …` | patch | +| `build:`, `chore:`, `ci:`, `docs:`, `refactor:`, `revert:`, `style:`, `test:` | no release | +| no conventional prefix | rejected before merge | + +A title with a type in the fourth row is valid and publishes nothing, which is +right for a change that does not reach the package. + +`.github/workflows/pr-title.yml` checks the title on every pull request, and it +re-checks after an edit. It fails a title that is not conventional, and it +states the release a valid title will produce, so the outcome is visible before +the merge. Check a title locally with: + +```bash +node tools/check-pr-title.mjs "fix: decode the last sample of a scan" +``` -So `chore:`, `docs:`, `ci:`, `test:`, `refactor:`, `style:` and `build:` -release nothing on their own, and neither does a commit with no conventional -prefix. A push that carries only those commits ends the run green and publishes -nothing. +Pull request #1 is why the check exists. Seven commits, four of them `fix:`, +merged under the title `chore: publish as +@cornerstonejs/jpeg-lossless-decoder-js`. `chore` releases nothing, so the +release workflow published nothing — and the run still reported success, +because "nothing to release" is a legitimate green outcome. Only the registry +showed the mistake. + +`tools/conventional.mjs` holds the rules that both the check and the release +read, so the two cannot drift apart. When a release is due, the workflow writes the new version to `package.json`, prepends a section to `CHANGELOG.md`, commits as @@ -111,19 +131,23 @@ short-lived token that npm mints for each run and scopes to this workflow file, so this repository holds no `NPM_TOKEN`. The name of the workflow file is part of that configuration: rename the file, and npm refuses the exchange. -**The first publish of a new package name must be manual.** npm cannot create a -package that does not exist yet through trusted publishing. A maintainer with -publish rights on the `@cornerstonejs` scope does this once: +#### How this package was bootstrapped + +The steps below are done. They are recorded because npm cannot create a package +name through trusted publishing, so the first publish of ANY new package name +has to be manual, and the next one starts here. + +`2.2.0` was published by hand: ```bash npm login # a web login session, not an access token npm ci -npm run lint && npm run test +npm run lint && npm test npm publish # prepublishOnly runs the build first ``` -Then register this workflow as the package's trusted publisher, so that later -releases need no token: +This workflow was then registered as the package's trusted publisher, so that +later releases need no token: ```bash npm trust github @cornerstonejs/jpeg-lossless-decoder-js \ @@ -143,19 +167,12 @@ Every access token that could publish this package before can still publish it afterwards. To close that path, set the package's Publishing access on npmjs.com to "Require two-factor authentication and disallow tokens". -Last, tag the version you published, on `main`, after the merge: - -```bash -git checkout main && git pull -git tag -a v2.2.0 -m 'v2.2.0' -git push origin v2.2.0 -``` +Last, `v2.2.0` was tagged on `main`, because a manual publish creates no tag and +`tools/version.mjs` reads the commits since the last `v*` tag. Every later tag +comes from the workflow. -**Do not skip the tag.** `tools/version.mjs` reads the commits since the last -`v*` tag, and a manual publish creates no tag. Without `v2.2.0` the next push -to `main` reads back past it, finds the `fix:` commits that 2.2.0 already -carries, and releases an identical 2.2.1. Every later tag comes from the -workflow, so this is a one-time step. +`2.2.0` carries no provenance attestation: npm generates one only for a trusted +publish from CI. Every version the workflow publishes has one. ### Acknowledgments This decoder was originally written by Helmut Dersch for Java. I added support for selection values 2 through 7, contributed bug fixes and ported to JavaScript. diff --git a/tools/check-pr-title.mjs b/tools/check-pr-title.mjs new file mode 100644 index 0000000..e3e7a3d --- /dev/null +++ b/tools/check-pr-title.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// +// Check that a pull request title is a conventional-commit subject, and say +// which release it will produce. +// +// This repository squash merges, so a merge writes ONE commit to main and its +// subject is the pull request title. tools/version.mjs then reads that subject +// to pick the next version. The title is therefore the release contract, and +// this check is what makes the contract visible BEFORE the merge rather than +// after it. +// +// Pull request #1 is why this exists: seven commits, four of them `fix:`, +// squashed under the title "chore: publish as +// @cornerstonejs/jpeg-lossless-decoder-js". `chore` releases nothing, so the +// release workflow published nothing and still reported success. +// +// Usage: +// node tools/check-pr-title.mjs "feat: add a thing" +// PR_TITLE="feat: add a thing" node tools/check-pr-title.mjs + +import { ALL_TYPES, bumpFor, parseSubject, RELEASING_TYPES, SILENT_TYPES } from './conventional.mjs'; + +const title = (process.argv[2] ?? process.env.PR_TITLE ?? '').trim(); + +// GitHub Actions renders these so the message lands on the pull request's +// checks rather than only in the log. Outside Actions they are harmless text. +const fail = (message) => { + console.log(`::error::${message}`); +}; + +if (!title) { + fail('No pull request title was given (pass it as an argument or set PR_TITLE).'); + process.exit(1); +} + +console.log(`Title: ${title}`); + +const parsed = parseSubject(title); + +if (!parsed) { + fail( + 'The pull request title is not a conventional-commit subject. ' + + 'Use ": ", for example "fix: decode the last sample of a scan".', + ); + console.log(''); + console.log('This repository squash merges, so this title becomes the only commit'); + console.log('subject on main, and tools/version.mjs reads it to pick the next version.'); + console.log(''); + console.log(`Types that release: ${Object.keys(RELEASING_TYPES).sort().join(', ')}`); + console.log(`Types that release none: ${SILENT_TYPES.join(', ')}`); + console.log('Add "!" before the colon, or a "BREAKING CHANGE:" footer, for a major release.'); + process.exit(1); +} + +if (!ALL_TYPES.includes(parsed.type)) { + fail( + `"${parsed.type}" is not a known type. Use one of: ${ALL_TYPES.join(', ')}. ` + + 'A type outside that list releases nothing, silently.', + ); + process.exit(1); +} + +// A title that parses but says nothing is a title that will read badly in the +// CHANGELOG, because the description IS the changelog entry. +if (parsed.description.length < 10) { + fail( + `The description "${parsed.description}" is too short. It becomes this release's ` + + 'CHANGELOG entry, so write what changed.', + ); + process.exit(1); +} + +const bump = bumpFor({ subject: title }); + +if (bump) { + console.log(`::notice::This title releases a ${bump} version when it merges.`); +} else { + console.log( + `::notice::"${parsed.type}" releases no version. That is valid — the merge publishes nothing.`, + ); +} diff --git a/tools/conventional.mjs b/tools/conventional.mjs new file mode 100644 index 0000000..a73ac18 --- /dev/null +++ b/tools/conventional.mjs @@ -0,0 +1,91 @@ +// The conventional-commit rules that decide a release, in one place. +// +// Two callers share this module, and they must agree: +// - tools/version.mjs, which reads the commits on main and picks the version. +// - tools/check-pr-title.mjs, which reads a pull request title. +// +// The second one exists because this repository squash merges. A squash merge +// writes ONE commit to main, and its subject is the pull request title, so the +// title is what decides the release. Pull request #1 showed the cost of that: +// seven commits, four of them `fix:`, merged under the title +// "chore: publish as @cornerstonejs/jpeg-lossless-decoder-js" — and `chore` +// releases nothing, so the release workflow correctly published nothing while +// the run still reported success. +// +// Node builtins only: the release job imports this without an install. + +// The release workflow commits with this subject and this author. Such a +// commit must never count towards the next bump. +export const RELEASE_SUBJECT = 'chore(release): publish'; +export const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com'; + +// Types that release, and what they release. This is the set that lerna's +// conventional-commits preset released on, which is what +// `cornerstonejs/codecs` preserves in its own tools/release/version.mjs. +export const RELEASING_TYPES = { + feat: 'minor', + fix: 'patch', + perf: 'patch', +}; + +// Types that are conventional, and deliberately release nothing on their own. +// A pull request titled with one of these is valid and publishes no version. +export const SILENT_TYPES = [ + 'build', + 'chore', + 'ci', + 'docs', + 'refactor', + 'revert', + 'style', + 'test', +]; + +export const ALL_TYPES = [...Object.keys(RELEASING_TYPES), ...SILENT_TYPES].sort(); + +export const RANK = { patch: 1, minor: 2, major: 3 }; + +// Splits a conventional subject. Returns null when the subject does not have a +// conventional prefix at all. +export function parseSubject(subject) { + const match = /^(?[a-z]+)(?\(([^)]*)\))?(?!)?:\s*(?.+)$/i.exec( + (subject || '').trim(), + ); + if (!match) return null; + + return { + type: match.groups.type.toLowerCase(), + scope: match.groups.scope ? match.groups.scope.slice(1, -1) : null, + breaking: Boolean(match.groups.breaking), + description: match.groups.description.trim(), + }; +} + +// Which bump a subject and body ask for, or null for no release. +// +// A subject with no conventional prefix releases nothing. This repository's +// older history is not conventional, and a guess at it would release on a +// commit that says only "remove map". +export function bumpFor({ subject, body = '' }) { + const parsed = parseSubject(subject); + if (!parsed) return null; + + // `feat!: ...` and a `BREAKING CHANGE:` footer both mean major. The footer is + // matched at the start of a line, so that a mention inside a sentence does + // not trigger a major release. + if (parsed.breaking || /^BREAKING[ -]CHANGE:/m.test(body)) return 'major'; + + return RELEASING_TYPES[parsed.type] ?? null; +} + +export function applyBump(version, bump) { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version); + if (!match) { + throw new Error(`version is not a plain x.y.z: ${version}`); + } + const [major, minor, patch] = match.slice(1).map(Number); + + if (bump === 'major') return `${major + 1}.0.0`; + if (bump === 'minor') return `${major}.${minor + 1}.0`; + return `${major}.${minor}.${patch + 1}`; +} diff --git a/tools/version.mjs b/tools/version.mjs index df51268..18eb916 100644 --- a/tools/version.mjs +++ b/tools/version.mjs @@ -21,16 +21,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { applyBump, BOT_EMAIL, bumpFor, RANK, RELEASE_SUBJECT } from './conventional.mjs'; + const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const MANIFEST = path.join(ROOT, 'package.json'); const CHANGELOG = path.join(ROOT, 'CHANGELOG.md'); const REPO_URL = 'https://github.com/cornerstonejs/JPEGLosslessDecoderJS'; -// The release workflow commits with this subject and this author. Such a -// commit must never itself count towards the next bump. -const RELEASE_SUBJECT = 'chore(release): publish'; -const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com'; - const argv = process.argv.slice(2); const dryRun = argv.includes('--dry-run'); const asJson = argv.includes('--json'); @@ -79,45 +76,10 @@ function commitsSince(tag) { }); } -// Which bump a single commit asks for, or null for a commit that releases -// nothing. This follows the same set that lerna's conventional-commits preset -// released on, which is what codecs' version.mjs preserves: -// - a breaking change -> major -// - feat -> minor -// - fix, perf -> patch -// - anything else -> no release -// So chore, docs, ci, test, refactor, style and build do not release on their -// own. A commit with no conventional prefix releases nothing either: this -// repository's older history is not conventional, and guessing at it would -// release on a commit that says only "remove map". -function bumpFor({ subject, body }) { - const header = /^(?[a-z]+)(?\([^)]*\))?(?!)?:/i.exec(subject); - if (!header) return null; - - // `feat!: ...` and a `BREAKING CHANGE:` footer both mean major. The footer - // is matched at the start of a line so that a mention inside a sentence does - // not trigger a major release. - if (header.groups.breaking || /^BREAKING[ -]CHANGE:/m.test(body)) return 'major'; - - const type = header.groups.type.toLowerCase(); - if (type === 'feat') return 'minor'; - if (type === 'fix' || type === 'perf') return 'patch'; - return null; -} - -const RANK = { patch: 1, minor: 2, major: 3 }; - -function applyBump(version, bump) { - const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version); - if (!match) { - throw new Error(`package.json version is not a plain x.y.z: ${version}`); - } - const [major, minor, patch] = match.slice(1).map(Number); - - if (bump === 'major') return `${major + 1}.0.0`; - if (bump === 'minor') return `${major}.${minor + 1}.0`; - return `${major}.${minor}.${patch + 1}`; -} +// bumpFor, applyBump and RANK come from ./conventional.mjs, which +// tools/check-pr-title.mjs shares. Under a squash merge the pull request title +// becomes the only commit subject on main, so the check and this script have to +// read the same rules. // --------------------------------------------------------------------------- // CHANGELOG