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
38 changes: 22 additions & 16 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,29 @@ 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.
# creates the GitHub Release with generated notes.
#
# WHY THE TOKEN MATTERS — and why release.yml needs no changes:
# NO SECRET IS REQUIRED. This runs on GITHUB_TOKEN, and the two things GITHUB_TOKEN
# normally blocks are handled as follows — see docs/releasing.md for the full flow.
#
# `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.
# 1. Checks on the Release PR. `main` requires `lint-test` and enforce_admins is true,
# so there is no bypass. GitHub exempts `pull_request` events with the `opened`,
# `synchronize`, and `reopened` activity types: a PR created by GITHUB_TOKEN gets
# its runs started in an APPROVAL-REQUIRED state rather than suppressed. A user
# with write access clicks "Approve workflows to run" in the merge box, `lint-test`
# reports, and the PR merges normally. Nothing is skipped.
#
# 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.)
# 2. Publishing. `release` is NOT an exempt event, so a Release created here does not
# fire release.yml's `release: published` trigger — npm would silently not update.
# Publish with: gh workflow run release.yml -f dry-run=false
# (That trigger remains live for Releases created by a person, which is how 0.6.0
# was published.)
#
# Scope the PAT to this repository only: contents: write + pull_requests: write.
# To remove both manual steps, set a RELEASE_PLEASE_TOKEN secret — a token-created
# Release carries a user/App identity, so `release: published` fires and no click is
# needed. Scope it to this repo: contents: write + pull_requests: write. Note a secret
# that exists but is UNAUTHORISED (e.g. a fine-grained PAT still pending org approval)
# short-circuits the `||` fallback below and hard-fails with 403 — delete it instead.
on:
push:
branches: [main]
Expand All @@ -44,8 +49,9 @@ jobs:
- 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.
# Defaults to GITHUB_TOKEN, which is a fully supported path — not a
# degraded one. It costs one "Approve workflows to run" click on the
# Release PR and a manual publish dispatch; see the header above.
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ Every merge to `main` updates a single open **Release PR** holding the `package.
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:
Process, the required repository settings, and the two manual steps per release:
[`docs/releasing.md`](docs/releasing.md).

## Status
Expand Down
76 changes: 46 additions & 30 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,36 +109,52 @@ 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.
## Releasing by hand — no token, no secret

`release-please.yml` runs on `GITHUB_TOKEN` and needs **no secret at all**. That is
possible because GitHub exempts `pull_request` events with the `opened`,
`synchronize`, or `reopened` activity types: when `GITHUB_TOKEN` creates a pull
request, the resulting runs are started in an **approval-required** state instead of
being suppressed outright. Approving them restores the required check, so branch
protection is satisfied normally rather than bypassed.

The full flow, and the two places a human is involved:

1. Merge a `feat:`/`fix:` to `main` → release-please opens or updates the Release PR.
2. On that PR, click **Approve workflows to run** in the merge box. `ci.yml` and
`pr-title.yml` then execute, `lint-test` reports, and the PR becomes mergeable like
any other. (Nothing is skipped — the runs are held, not allowed through.)
3. Merge the Release PR → release-please tags the commit, writes the `CHANGELOG.md`
entry, and creates the GitHub Release with generated notes.
4. Publish, because the Release cannot do it for you:

```
gh workflow run release.yml -f dry-run=false
```

**Why step 4 is manual.** GitHub's exemption list covers `workflow_dispatch`,
`repository_dispatch`, and those three `pull_request` activity types — `release` is
**not** on it. So the Release that release-please creates with `GITHUB_TOKEN` does not
fire `release: published`, and npm would silently never be updated. That trigger is
therefore live only for Releases created by a person, which is how 0.6.0 was published.

### Making it automatic again

Give release-please a real token and both manual steps disappear — no approve click,
no dispatch. Set `RELEASE_PLEASE_TOKEN` and `release-please.yml` picks it up; the chain
is already `RELEASE_PLEASE_TOKEN || GITHUB_TOKEN`. A token-created Release carries a
user or App identity, so `release: published` fires and `release.yml` stays untouched.

Scope it to this repository with **Contents: Read and write** and **Pull requests:
Read and write**. A fine-grained PAT needs org-owner approval (the org default is
"Require administrator approval"); a classic PAT with `public_repo` does not, and a
GitHub App needs neither approval nor rotation.

One trap: `||` falls through only on *empty*, so a secret whose value is
**unauthorised** — a fine-grained PAT still pending approval — short-circuits the
fallback and makes release-please hard-fail with
`403 Resource not accessible by personal access token`. Delete it rather than leaving
it pending; a missing secret is strictly better than a broken one.

## See also

Expand Down
Loading