Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -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
77 changes: 47 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 \
Expand All @@ -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.
Expand Down
81 changes: 81 additions & 0 deletions tools/check-pr-title.mjs
Original file line number Diff line number Diff line change
@@ -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 "<type>: <description>", 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.`,
);
}
91 changes: 91 additions & 0 deletions tools/conventional.mjs
Original file line number Diff line number Diff line change
@@ -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 = /^(?<type>[a-z]+)(?<scope>\(([^)]*)\))?(?<breaking>!)?:\s*(?<description>.+)$/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}`;
}
Loading
Loading