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
48 changes: 48 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -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"
51 changes: 51 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.5.0"
}
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,38 @@ by hand:
| `.bgrun-used-<hash>` | Per-project evidence that bgrun has run here (digest nudge). |
| `.digest-nudge-<hash>` | 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.
146 changes: 146 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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: <description>`.

## 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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
23 changes: 23 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -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": {
".": {}
}
}
Loading
Loading