diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..d94e222 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,48 @@ +name: PR Title + +# Guards the one message that actually reaches main. This repo squash-merges, so a +# PR title becomes the commit subject on main — and that subject is what +# release-please parses to derive the version bump and the changelog. A title it +# cannot parse is dropped silently: no changelog entry, and no release on its own. +# This check is what makes that documented contract real instead of aspirational. +# +# Deliberately a separate workflow rather than a job in ci.yml, so this check can be +# made required on its own without also requiring `lint-test`. Requiring `lint-test` +# would deadlock the Release PR (see docs/releasing.md) because release-please opens +# that PR with GITHUB_TOKEN, and GitHub suppresses workflow runs for +# GITHUB_TOKEN-created events — so no check would ever report on it. +on: + pull_request: + # `edited` matters: fixing the title must re-run the check so the PR can be + # merged. Without it a bad title is a dead end. + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + conventional-title: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + # bun, not node: the checker is TypeScript and bun runs it unbuilt, matching + # how the rest of this repo's tooling works. + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + # The title is attacker-controlled on a fork PR, so it is passed through env + # and quoted as "$PR_TITLE" — never interpolated into the script body, which + # would make this a command-injection sink. + - name: Validate PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: bun scripts/check-conventional-commit.ts "$PR_TITLE" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..c11726e --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,51 @@ +name: Release Please + +# Opens/updates the Release PR (package.json version bump + CHANGELOG.md) on every +# push to main. Merging that PR is the release: release-please tags the commit and +# creates the GitHub Release with generated notes, which fires release.yml's +# existing `release: published` trigger to publish to npm over OIDC. +# +# WHY THE TOKEN MATTERS — and why release.yml needs no changes: +# +# `main` requires the `lint-test` status check and enforce_admins is true, so there +# is no bypass. release-please opens the Release PR, and GITHUB_TOKEN-created events +# start no workflow run — so with the default token `lint-test` never reports on that +# PR and it can never be merged. +# +# A Personal Access Token fixes that, because PAT-created events DO start workflows. +# It also fixes publishing for free: with a PAT the GitHub Release is created by a +# user identity, so `release: published` fires normally and release.yml stays +# untouched. (Under GITHUB_TOKEN that event is suppressed, and avoiding it would +# otherwise mean chaining release.yml as a reusable workflow — plus registering +# release-please.yml as a second npm trusted publisher. The PAT removes all of that.) +# +# Scope the PAT to this repository only: contents: write + pull_requests: write. +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +# Mutating workflow — do NOT cancel in progress. Serializing also stops two runs +# from rebasing the same open Release PR against each other. +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + + steps: + - name: Release Please + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + # Falls back to GITHUB_TOKEN until the secret exists, so this degrades to + # "Release PRs open but cannot be merged" instead of failing outright. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..f1c1e58 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.5.0" +} diff --git a/README.md b/README.md index d24c1b9..2dfca2b 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,38 @@ by hand: | `.bgrun-used-` | Per-project evidence that bgrun has run here (digest nudge). | | `.digest-nudge-` | Per-project: the one-shot digest nudge was already shown. | +## Releasing + +Version numbers and the changelog are derived from commit messages via +[release-please](https://github.com/googleapis/release-please), so the prefix on a +squash-merged PR title is load-bearing: + +| Prefix | Release | +| --- | --- | +| `fix:` / `feat:` / `deps:` | yes — patch / minor / patch | +| `feat!:` / `fix!:` / `BREAKING CHANGE:` | yes — minor (pre-1.0) | +| `refactor:` `docs:` `test:` `ci:` `build:` `chore:` `style:` | no | +| no prefix, e.g. `Address review findings (#11)` | no | + +An unprefixed commit is ignored outright: no changelog entry, and it cannot trigger +a release on its own. `pr-title.yml` enforces the format on every PR +(`bun run lint:pr-title` locally). + +**PRs are squash-merged, and that is structural rather than stylistic:** the squash +collapses the PR to a single commit whose subject is the *title*, which is the +message release-please parses. That is why the title — not the branch commits — is +what CI validates, and why work-in-progress commit messages never surface. Rebase +and merge-commit methods would put branch subjects on `main` and break that mapping, +so repository settings must disable both ([`docs/releasing.md`](docs/releasing.md) +lists the exact toggles). + +Every merge to `main` updates a single open **Release PR** holding the `package.json` +bump and `CHANGELOG.md` entry. Nothing is published until that PR is merged — +ordinary merges only update it. + +Process, the required repository settings, and the one secret: +[`docs/releasing.md`](docs/releasing.md). + ## Status Early / pre-release. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..71d403a --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,146 @@ +# Releasing pi-background-run + +Version numbers, `CHANGELOG.md`, and the GitHub Release notes are all derived from +Conventional Commits, via [release-please](https://github.com/googleapis/release-please). +Nobody edits a version by hand. + +Every merge to `main` runs release-please, which keeps **one** open **Release PR** +holding the `package.json` bump plus the changelog entry for everything merged since +the last tag. Ordinary merges only *update* that PR — nothing is published. Merging +it **is** the release: release-please tags the commit and creates the GitHub Release +with generated notes, which fires `release.yml`'s `release: published` trigger to +publish to npm over OIDC. + +## What triggers a release + +| Commit | Release PR | Version | +| --- | --- | --- | +| `fix:` | yes | patch | +| `feat:` | yes | minor | +| `deps:` | yes | patch | +| `feat!:` / `fix!:` / `BREAKING CHANGE:` | yes | minor | +| `refactor:` `docs:` `test:` `ci:` `build:` `chore:` `style:` | no | — | +| no prefix, e.g. `Address review findings (#11)` | no | — | + +An unprefixed commit is ignored outright: **no changelog entry, and it cannot trigger +a release on its own.** `pr-title.yml` exists so that is a red check on the PR instead +of a silent omission. + +A `!` bumps the *minor* while pre-1.0 (`bump-minor-pre-major: true`), so a breaking +change never ends pre-release status by accident. Forcing an exact version takes a +`Release-As:` footer in a commit *body*: +`git commit --allow-empty -m "chore: release 2.0.0" -m "Release-As: 2.0.0"`. + +GitHub's auto-generated `Revert "feat: …"` title is not conventional and would be +dropped; rewrite it as `revert: `. + +## Enforcement + +`pr-title.yml` rejects a PR title release-please cannot parse. +`bun run lint:pr-title "feat(ci): add a thing"` runs the same check locally. + +The checker is `scripts/check-conventional-commit.ts`. It reads its type vocabulary +from `release-please-config.json`'s `changelog-sections`, so there is one source of +truth: adding a type there immediately permits it in a title. It fails **closed** — an +unreadable or empty config is an error, never a free pass. + +It runs on `opened`, `edited`, `reopened`, and `synchronize`; `edited` is what makes a +bad title recoverable, since fixing the title re-runs the check. Making +`conventional-title` a required check is optional and safe. + +## Commit and merge conventions + +**Squash merge, and nothing else.** This is structural, not cosmetic: the squash +collapses a PR to one commit whose subject is the *title*, and that subject is what +release-please parses. Rebase would land every branch commit individually (WIP +subjects would enter the changelog and the title check would be irrelevant); a merge +commit adds a fixed non-conventional subject that release-please ignores. + +These settings are **currently wrong for this design** — verified 2026-09-20: + +| Setting | Current | Required | +| --- | --- | --- | +| `allow_merge_commit` | `true` | **`false`** | +| `allow_rebase_merge` | `true` | **`false`** | +| `squash_merge_commit_title` | `COMMIT_OR_PR_TITLE` | **`PR_TITLE`** | +| `squash_merge_commit_message` | `COMMIT_MESSAGES` | **`BLANK`** | + +``` +gh api -X PATCH repos/stablekernel/pi-background-run \ + -F allow_merge_commit=false \ + -F allow_rebase_merge=false \ + -F squash_merge_commit_title=PR_TITLE \ + -F squash_merge_commit_message=BLANK +``` + +`PR_TITLE` is the load-bearing one. The default `COMMIT_OR_PR_TITLE` means *the +commit's title if only one commit, otherwise the PR title* — so on a **single-commit +PR the squash subject is the branch commit's message**, which `pr-title.yml` never +sees. A one-commit PR with a good title and a sloppy commit message would land a +non-conventional subject and vanish from the changelog. + +`COMMIT_MESSAGES` is the default body and should go too: it appends the branch's +commit messages to the squash body, where release-please may parse them as extra +changelog entries. + +`required_linear_history` is **not** enabled and `enforce_admins` is `true`, so +nothing else stops a merge commit or a rebase — these settings are the only guard. + +## Configuration + +| File | Holds | +| --- | --- | +| `release-please-config.json` | Release type, tag format, changelog sections. | +| `.release-please-manifest.json` | Last released version, used to find commits since. | + +`include-component-in-tag: false` is **required**. The manifest default looks for +`pi-background-run-vX.Y.Z`, which matches none of this repo's `vX.Y.Z` tags; the run +fails rather than guessing. + +Changelog sections are declared explicitly so only user-visible change surfaces: +`chore`, `docs`, `refactor`, `test`, `ci`, `build`, and `style` are `hidden`, which is +why a refactor-heavy release still reads as a short list of features and fixes. + +`CHANGELOG.md` is deliberately **not** in `package.json` `files[]`, so it does not ship +in the npm tarball — the allowlist still emits 7 files. + +## The one secret + +`release-please.yml` uses `secrets.RELEASE_PLEASE_TOKEN`, falling back to +`GITHUB_TOKEN` if unset. It should be a **fine-grained PAT scoped to this repository +only**, with: + +- **Contents: Read and write** — pushes the changelog commit, creates the tag and Release +- **Pull requests: Read and write** — opens and updates the Release PR + +Why it is needed at all: `main` requires the `lint-test` status check and +`enforce_admins` is `true`, so there is no bypass. Checks only report from a real +workflow run, and `GITHUB_TOKEN`-created events start none — so a `GITHUB_TOKEN` +Release PR can never report `lint-test` and can never be merged. + +It also removes a whole class of machinery. Because a PAT-created Release carries a +*user* identity, `release: published` fires normally and **`release.yml` needs no +changes**; under `GITHUB_TOKEN` that event is suppressed, which would otherwise +require chaining `release.yml` as a reusable workflow — and, since npm validates a +`workflow_call` publish against the *calling* workflow's filename, registering +`release-please.yml` as a **second trusted publisher on both packages**. + +So: **npm trusted publishing needs no changes.** `release.yml` remains the publisher +and stays registered as-is. A fine-grained PAT expires, so it needs periodic rotation +— the one recurring cost of this design, and the reason a GitHub App token +(short-lived per run, no expiry) would be strictly better if the org is willing to +own one. `release-please`'s own docs recommend a PAT for exactly the check-reporting +reason above. + +Until the secret exists the fallback keeps release-please running, so Release PRs +still open — they just cannot be merged. + +## See also + +- `release.yml` — untouched by the release-automation work: dual publish (unscoped + primary + scoped alias) and the version-consistency guard that refuses to publish + when the tag and `package.json` disagree. +- `pr-title.yml` — the Conventional Commits check, with its logic in + `scripts/check-conventional-commit.ts`. +- README: [Status](../README.md#status) — pre-1.0, which is why + `bump-minor-pre-major` is set. diff --git a/package.json b/package.json index 41c392f..21f835c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ }, "scripts": { "test": "bun test extension/index.test.ts", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "lint:pr-title": "bun scripts/check-conventional-commit.ts" }, "files": [ "extension/", diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..1897286 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "node", + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "revert", "section": "Reverts" }, + { "type": "refactor", "section": "Code Refactoring", "hidden": true }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true } + ], + "packages": { + ".": {} + } +} diff --git a/scripts/check-conventional-commit.ts b/scripts/check-conventional-commit.ts new file mode 100755 index 0000000..fbf5f0a --- /dev/null +++ b/scripts/check-conventional-commit.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env bun +/** + * Validates a PR title (or any commit subject) against Conventional Commits. + * + * Why the PR title and not branch commits: this repo squash-merges, so the PR + * title becomes the single commit subject on `main`, and that subject is exactly + * what release-please parses. Intermediate branch commits never reach `main`, so + * validating them would reject working history for no benefit. + * + * Why the type vocabulary comes from release-please-config.json: two lists would + * drift. A type added to `changelog-sections` becomes valid in a title + * immediately; a type removed there stops being accepted. One source of truth — + * and it is the same file that decides which types get a changelog section. + * + * Fails CLOSED: a missing or unusable config is an error, not a free pass, since + * a guardrail that silently stops guarding is worse than one that is noisy. + * + * Usage: + * bun scripts/check-conventional-commit.ts "feat(ci): add a thing" + * printf 'fix: a thing' | bun scripts/check-conventional-commit.ts + */ +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +const CONFIG_URL = new URL("../release-please-config.json", import.meta.url); +const CONFIG_PATH = basename(CONFIG_URL.pathname); + +interface Section { + type?: unknown; +} + +/** GitHub renders `::error …::` as an annotation; elsewhere it is plain noise. */ +const IN_ACTIONS = process.env.GITHUB_ACTIONS === "true"; + +function fail(message: string, detail?: string): never { + if (IN_ACTIONS) { + console.error(`::error title=Non-conventional title::${message}`); + } else { + console.error(`error: ${message}`); + } + if (detail) console.error(`\n${detail}`); + process.exit(1); +} + +function readConfig(): { "changelog-sections"?: Section[] } { + let raw: string; + try { + raw = readFileSync(CONFIG_URL, "utf8"); + } catch { + return fail( + `${CONFIG_PATH} is unreadable — it is the source of the valid commit types.`, + ); + } + try { + return JSON.parse(raw) as { "changelog-sections"?: Section[] }; + } catch (err) { + return fail(`${CONFIG_PATH} is not valid JSON: ${(err as Error).message}`); + } +} + +function typesFrom(config: { "changelog-sections"?: Section[] }): string[] { + const sections = config["changelog-sections"]; + const types = Array.isArray(sections) + ? sections + .map((section) => section?.type) + .filter((type): type is string => typeof type === "string") + : []; + if (types.length === 0) { + return fail( + `${CONFIG_PATH} declares no changelog-sections[].type — every title would be rejected.`, + ); + } + return types; +} + +function readMessage(): string { + const arg = process.argv.slice(2).join(" ").trim(); + if (arg) return arg; + // No argument: accept a commit message on a pipe, but never block on a TTY. + if (!process.stdin.isTTY) { + try { + return readFileSync(0, "utf8").trim(); + } catch { + return ""; + } + } + return ""; +} + +const types = typesFrom(readConfig()); + +// Conventional Commits validates the SUBJECT, so only the first line is checked. +// That makes this script equally usable on a PR title (always single-line) and on +// a raw commit message (header + body). +const message = readMessage(); +const subject = message.split(/\r?\n/, 1)[0]?.trim() ?? ""; + +if (!subject) { + fail("no commit subject or PR title given. Pass it as an argument or on stdin."); +} + +// type(scope)!: description — scope optional, `!` optional, description required. +// `: ` (colon + space) is mandatory per the spec; a bare `type:text` is rejected. +const pattern = new RegExp( + `^(${types.join("|")})(?:\\(([^()\\s]+)\\))?(!)?: (.+)$`, +); + +if (pattern.test(subject)) { + console.log(`ok: ${subject}`); + process.exit(0); +} + +const example = `fix(ci): grant pull-requests: write`; +fail( + `"${subject}" is not a Conventional Commit.`, + [ + `Valid types: ${types.join(", ")}`, + `Format: ()!: e.g. "${example}"`, + "", + "The type must be lowercase and followed by a colon and a space; the scope is", + "optional; `!` marks a breaking change.", + "", + "Why this is enforced: release-please derives both the version bump and the", + "changelog from this message. A title it cannot parse gets no changelog entry", + "and cannot trigger a release on its own — it is dropped silently.", + "", + "Note: GitHub's auto-generated Revert \"...\" title is not conventional.", + 'Rewrite it as `revert: ` so the revert reaches the changelog.', + ].join("\n"), +); diff --git a/tsconfig.json b/tsconfig.json index a1b60b4..fd511b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "allowImportingTsExtensions": true, "types": ["node"] }, - "include": ["extension/**/*.ts"] + "include": ["extension/**/*.ts", "scripts/**/*.ts"] }