From 57e6051a4810804d1c655ccf90a18ad280293692 Mon Sep 17 00:00:00 2001 From: Rob Decker <852280+robdecker@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:36:30 -0700 Subject: [PATCH 1/4] ci: publish to npm with trusted publishing and automate the release The release process documented in MAINTAINING.md stopped working when branch protection was added: `lerna version` commits to `main`, and `main` now requires a pull request and a review with admin enforcement on. Publishing was broken separately, because `yarn publish` prompts for a 2FA code and CI has no terminal, and because the npm token expired unnoticed after 90 days. Replace both halves. Versioning moves to release-please. It reads the conventional commits already enforced by commitlint and keeps a release pull request open with the version bump and changelog. That pull request is merged like any other, so branch protection is satisfied normally and nothing needs to bypass it. Publishing moves to npm trusted publishing. The runner mints a short-lived OIDC credential scoped to this workflow, so there is no npm token to store, rotate or leak, and no 2FA prompt to answer. npm records provenance automatically because the repository and the package are both public. `release-pr.yml` keeps publishing experimental versions from a labelled pull request, but on the same OIDC path, with `yarn publish` replaced by `npm publish` and the NPM_TOKEN reference removed. `googleapis/release-please-action` is pinned to a full commit SHA. Lerna stays for running workspace scripts. Its publish configuration is removed because it no longer publishes. This change is inert until the trusted publisher entries are configured on npmjs.com, and the NPM_TOKEN secret should only be deleted once a release has succeeded on the new path. --- .github/workflows/release-pr.yml | 20 ++++-- .github/workflows/release.yml | 76 ++++++++++++++++++++ .release-please-manifest.json | 3 + MAINTAINING.md | 118 ++++++++++++++++--------------- lerna.json | 5 -- release-please-config.json | 12 ++++ 6 files changed, 164 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 89e399f41..168c16060 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -19,6 +19,10 @@ jobs: contains(github.event.pull_request.labels.*.name, format('release-pr{0} next-drupal', ':')) && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name environment: Preview + permissions: + contents: read + pull-requests: write + id-token: write # Required for trusted publishing steps: - name: Init uses: actions/checkout@v4 @@ -28,8 +32,10 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 - registry-url: https://registry.npmjs.org/ + node-version: 24 + # Trusted publishing needs npm 11.5.1 or later. + - name: Ensure npm supports trusted publishing + run: npm install -g npm@latest - name: Install dependencies run: yarn install --frozen-lockfile - name: Determine version @@ -37,12 +43,12 @@ jobs: id: determine-version env: PR_NUMBER: ${{ github.event.number }} + # Publishes with an OIDC credential minted for this workflow. npm is + # configured to trust this filename, so renaming this file breaks + # publishing until the trusted publisher entry is updated to match. - name: Publish to npm - run: | - cd packages/next-drupal - yarn publish --no-git-checks --access public --tag experimental - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --tag experimental + working-directory: packages/next-drupal - name: Comment version on PR env: VERSION: ${{ steps.determine-version.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..d1aa6c6ef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: release + +on: + push: + branches: + - main + +# Default to read-only. Each job widens only what it needs. +permissions: + contents: read + +# Never let two releases run at once, and never cancel one partway. +concurrency: + group: release + cancel-in-progress: false + +jobs: + # Keeps a release pull request open with the version bump and changelog, + # built from the conventional commits since the last release. Merging that + # pull request is what triggers an actual release: this job then tags it and + # sets releases_created, which gates the publish job below. + release-please: + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.repository == 'chapter-three/next-drupal' + permissions: + contents: write + pull-requests: write + outputs: + releases_created: ${{ steps.release.outputs.releases_created }} + steps: + - name: Run release-please + id: release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Publishes to npm with trusted publishing. There is no npm token: the + # id-token permission lets the runner mint a short-lived OIDC credential + # that npm accepts only from this workflow in this repository. Because the + # repository and the package are both public, npm also records provenance + # automatically, so no --provenance flag is needed. + publish: + needs: release-please + if: needs.release-please.outputs.releases_created == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + steps: + - name: Init + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + # Trusted publishing needs npm 11.5.1 or later. Node 24 ships a new + # enough npm today, but pinning the floor here means a future runner + # image change cannot silently drop below it. + - name: Ensure npm supports trusted publishing + run: npm install -g npm@latest + + - name: Install dependencies + run: yarn install --frozen-lockfile + + # `prepare` (tsup) runs as part of publish, so the tarball is built from + # this checkout rather than from whatever happens to be in dist/. + - name: Publish to npm + run: npm publish + working-directory: packages/next-drupal diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 000000000..e4094fba3 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + "packages/next-drupal": "2.0.1" +} diff --git a/MAINTAINING.md b/MAINTAINING.md index 26344f687..6f7396e7b 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -34,85 +34,87 @@ While maintaining releases for packages, starters and examples is done with Lern ### `next-drupal` (NPM package) -Since we are using semantic commits, Lerna is able to read the git commits since the last release to auto-generate a CHANGELOG and to determine if the next release should be a: +Releases are automated. You do not run `npm publish` or `lerna publish`, and you +do not need an npm token on your machine. -- major version bump (e.g. 1.0.0 to 2.0.0). This will happen when Lerna finds a `BREAKING CHANGE` commit message. -- minor version bump (e.g. 1.0.0 to 1.1.0). This will happen when Lerna finds a `feat` commit message. -- patch version bump (e.g. 1.0.0 to 1.0.1). This is the default version bump for bug fixes, etc. -- prerelease version bump (e.g. 1.0.0-alpha.0 to 1.0.0-alpha.1) +#### How it works -We’ll be using Lerna’s `--no-push` flag so that Lerna does not push git tags and commits automatically. This allows us to delete any commits and tags locally if we make a mistake. +[release-please](https://github.com/googleapis/release-please) reads the +conventional commits merged to `main` and keeps a pull request open titled +something like `chore(main): release next-drupal 2.1.0`. That pull request holds +the version bump and the generated `CHANGELOG.md`. It updates itself as more +commits land. -1. **Tag a new release** +The commit type decides the version: - - **to make the next logical semantic version**, run: +- `feat` gives a minor bump (2.0.1 to 2.1.0). +- `fix`, `perf` and similar give a patch bump (2.0.1 to 2.0.2). +- A `BREAKING CHANGE:` footer gives a major bump (2.0.1 to 3.0.0). Add this only + when you mean it. +- Other types (`chore`, `docs`, `ci`, `test`) do not trigger a release on their + own. - ``` - npx lerna version --no-push - ``` +Merging the release pull request tags the release and publishes to npm. - - **to make a new alpha prerelease version:** +#### Making a release - If the current version is not a prerelease version, you’ll need to explicitly tell Lerna that the next version should be an alpha release with: +1. **Check the release pull request.** Confirm the version is what you expect + and the changelog reads well. Edit the changelog in the pull request if a + commit message produced an unhelpful entry. - ``` - npx lerna version --conventional-prerelease --no-push - ``` +2. **Merge it.** Squash and merge, like any other pull request. `main` requires + a review, and the release pull request is authored by a bot, so you can + approve it yourself. - When creating a new prerelease version of `next-drupal`, Lerna will automatically determine if it needs to be a `premajor` (2.0.0-alpha.0), `preminor` (1.1.0-alpha.0), or `prepatch` (1.0.1-alpha.0) version. +3. **Watch the `release` workflow.** Merging tags the release and runs the + publish job. - - **to make a new beta prerelease version:** +4. **Confirm.** Check the "Current Tags" section of + [next-drupal's npm page](https://www.npmjs.com/package/next-drupal?activeTab=versions) + and confirm `latest` points at the new version. - If the current version is not a beta version, you’ll need to explicitly tell Lerna that the next version should be a beta release with: +That is the whole process. There is no local step. - ``` - npx lerna version --conventional-prerelease --preid beta --no-push - ``` +#### Publishing credentials - - **to make a new regular version from a prerelease version:** +Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). +The runner mints a short-lived OIDC credential that npm accepts only from +`.github/workflows/release.yml` in this repository. There is no npm token stored +anywhere, so there is nothing to rotate and nothing to leak. - If the current version is a prerelease version, you’ll need to explicitly tell Lerna that the next version should no longer be a prerelease version with: +Two consequences worth knowing: - ``` - npx lerna version --conventional-graduate --no-push - ``` +- **Renaming `release.yml` breaks publishing.** npm matches on the workflow + filename. If you rename or move it, update the trusted publisher entry in the + package settings on npmjs.com first. +- **Publishing from a laptop will fail**, by design. The package has + "Require two-factor authentication and disallow tokens" enabled, so token + authentication is rejected. Release through the pull request. - **Confirm changes** +Because both the repository and the package are public, npm records +[provenance](https://docs.npmjs.com/generating-provenance-statements) +automatically. No flag is needed. - When Lerna asks “Are you sure you want to create these versions?”, carefully check if the versions listed are the ones you expect. +#### Experimental releases from a pull request -2. **Push git changes** with: +To publish a throwaway version from an open pull request, for example so another +project can test a fix before it is released, add the label +`release-pr: next-drupal` to the pull request. The `release-pr` workflow +publishes under the `experimental` dist-tag and comments the install command on +the pull request. - ``` - git push - git push --tags - ``` - -3. **Publish the release** - - Ensure you have authenticated with npmjs.com using `npm login`. - - Then, while your local git working area is clean of changes and `HEAD` is pointing to the commit created in step 1, have Lerna build, prepare, package and publish your release. - - For a new prerelease version, specify the `canary` dist-tag with: - - ``` - npx lerna publish --dist-tag canary from-git - ``` - - Otherwise, use: - - ``` - npx lerna publish from-git - ``` - - Maintainers will need permission to publish to `next-drupal` on npmjs.com. http://npmjs.com/package/next-drupal - -4. **Confirm the release** +This only works for branches in this repository, not forks. - Look at the “Current Tags” section of [next-drupal’s npmjs page](https://www.npmjs.com/package/next-drupal?activeTab=versions) and confirm that the newest release is listed and that the `latest` tag and the `canary` tag point at the expected versions. +#### If the release does not happen -For more information, see Lerna’s [version docs](https://github.com/lerna/lerna/tree/main/libs/commands/version) and [publish docs](https://github.com/lerna/lerna/tree/main/libs/commands/publish). +- **No release pull request appeared.** Nothing since the last release changed a + releasable file, or every commit was a type that does not trigger a release. + Check that the commit touched `packages/next-drupal`. +- **The publish job failed.** Read the job log rather than guessing. If npm + rejects the credential, the likely cause is that `release.yml` was renamed, or + the trusted publisher entry on npmjs.com was deleted or recreated. +- **The version is wrong.** The version comes from commit types. To force a + specific version, add a `Release-As: 2.2.0` footer to a commit on `main`. ### Examples diff --git a/lerna.json b/lerna.json index 4db5e70de..37fd6507e 100644 --- a/lerna.json +++ b/lerna.json @@ -10,11 +10,6 @@ }, "run": { "loglevel": "verbose" - }, - "publish": { - "allowBranch": "main", - "conventionalCommits": true, - "message": "chore(release): publish" } } } diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 000000000..42e4a4639 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-component-in-tag": true, + "include-v-in-tag": false, + "tag-separator": "@", + "packages": { + "packages/next-drupal": { + "release-type": "node", + "changelog-path": "CHANGELOG.md" + } + } +} From 7c700d23744540097a3f6bcd36755c1baac57f26 Mon Sep 17 00:00:00 2001 From: Rob Decker <852280+robdecker@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:02:57 -0700 Subject: [PATCH 2/4] ci: open the release pull request with an app token, not GITHUB_TOKEN Review found that the release job could not have opened its pull request. This repository does not allow GitHub Actions to create pull requests, so the default GITHUB_TOKEN that release-please falls back to is refused and no release pull request would ever appear. The repository setting that would allow it is a single switch covering both creating and approving pull requests. Turning it on would let any workflow approve, which would weaken the review requirement on `main`, so this mints an app installation token scoped to this repository instead. It also means CI runs on the release pull request without someone first releasing the held runs a GITHUB_TOKEN-authored pull request produces. Two smaller fixes from the same review: Drop `npm install -g npm@latest` from both publish jobs. It fetched an unpinned executable into a job holding `id-token: write`, next to an action pinned to a full SHA for that exact reason. Every current Node 24 release ships npm 11.19.0, past the 11.5.1 trusted publishing needs, so the requirement is now asserted and fails the job loudly if a future runner image regresses. Document how to cut a prerelease. Removing the Lerna steps took away the only documented way to produce one, while later sections still branch on prereleases and a `canary` branch. Requires two new repository secrets, RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY, before the release workflow can run. --- .github/workflows/release-pr.yml | 14 +++++++++++--- .github/workflows/release.yml | 30 +++++++++++++++++++++++++----- MAINTAINING.md | 16 +++++++++++++++- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 168c16060..a0e3778be 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -33,9 +33,17 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 - # Trusted publishing needs npm 11.5.1 or later. - - name: Ensure npm supports trusted publishing - run: npm install -g npm@latest + # Trusted publishing needs npm 11.5.1 or later, which every current Node + # 24 release ships. This asserts it rather than installing a newer npm, + # so nothing unpinned is fetched into a job that can publish. + - name: Verify npm supports trusted publishing + run: | + version="$(npm --version)" + echo "npm $version" + if [ "$(printf '11.5.1\n%s\n' "$version" | sort -V | head -1)" != "11.5.1" ]; then + echo "::error::npm $version is older than the 11.5.1 that trusted publishing requires" + exit 1 + fi - name: Install dependencies run: yarn install --frozen-lockfile - name: Determine version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1aa6c6ef..415ae09bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,24 @@ jobs: outputs: releases_created: ${{ steps.release.outputs.releases_created }} steps: + # The default GITHUB_TOKEN cannot open a pull request unless the + # repository allows Actions to create and approve them, and that setting + # is a single switch covering both. Granting approval to every workflow + # would undercut the review requirement on `main`, so this uses an app + # installation token scoped to this repository instead. It also means the + # release pull request triggers CI, which a GITHUB_TOKEN one would not. + - name: Mint app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Run release-please id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: + token: ${{ steps.app-token.outputs.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json @@ -60,11 +74,17 @@ jobs: with: node-version: 24 - # Trusted publishing needs npm 11.5.1 or later. Node 24 ships a new - # enough npm today, but pinning the floor here means a future runner - # image change cannot silently drop below it. - - name: Ensure npm supports trusted publishing - run: npm install -g npm@latest + # Trusted publishing needs npm 11.5.1 or later, which every current Node + # 24 release ships. This asserts it rather than installing a newer npm, + # so nothing unpinned is fetched into a job that can publish. + - name: Verify npm supports trusted publishing + run: | + version="$(npm --version)" + echo "npm $version" + if [ "$(printf '11.5.1\n%s\n' "$version" | sort -V | head -1)" != "11.5.1" ]; then + echo "::error::npm $version is older than the 11.5.1 that trusted publishing requires" + exit 1 + fi - name: Install dependencies run: yarn install --frozen-lockfile diff --git a/MAINTAINING.md b/MAINTAINING.md index 6f7396e7b..42e436fba 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -75,6 +75,20 @@ Merging the release pull request tags the release and publishes to npm. That is the whole process. There is no local step. +#### Prereleases + +To cut a prerelease, add a `Release-As:` footer naming the exact version to a +commit on `main`: + +``` +Release-As: 2.2.0-alpha.0 +``` + +release-please then proposes that version instead of the one it calculated. The +starters and the Docs sections below still refer to prereleases and to a +`canary` branch; those steps are about their own git repositories and release +notes, not about publishing this package. + #### Publishing credentials Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). @@ -114,7 +128,7 @@ This only works for branches in this repository, not forks. rejects the credential, the likely cause is that `release.yml` was renamed, or the trusted publisher entry on npmjs.com was deleted or recreated. - **The version is wrong.** The version comes from commit types. To force a - specific version, add a `Release-As: 2.2.0` footer to a commit on `main`. + specific version, use the `Release-As:` footer described under "Prereleases". ### Examples From fbd1f1852d3b2125d2390971d7baf9264852e202 Mon Sep 17 00:00:00 2001 From: Rob Decker <852280+robdecker@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:39:34 -0700 Subject: [PATCH 3/4] docs(ci): correct what a GITHUB_TOKEN pull request does to CI The comment said a GITHUB_TOKEN-authored pull request would not trigger CI at all. It does trigger it, but the runs are held in an approval-required state until someone releases them. The reason for the app token is unchanged; only the description of the side benefit was overstated. --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 415ae09bc..d6ae0198f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,8 +33,9 @@ jobs: # repository allows Actions to create and approve them, and that setting # is a single switch covering both. Granting approval to every workflow # would undercut the review requirement on `main`, so this uses an app - # installation token scoped to this repository instead. It also means the - # release pull request triggers CI, which a GITHUB_TOKEN one would not. + # installation token scoped to this repository instead. It also means CI + # runs on the release pull request without someone first releasing the + # held runs a GITHUB_TOKEN-authored pull request produces. - name: Mint app token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 From 925d312ee40b56c1cc7b6a007d8254841603239c Mon Sep 17 00:00:00 2001 From: Rob Decker <852280+robdecker@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:58:48 -0700 Subject: [PATCH 4/4] docs: record the release setup in MAINTAINING.md, and require Issues on the app Review raised that everything needed to stand this up lived only in a pull request body, which is how the previous process went stale without anyone noticing. Add a "First-time setup" section covering the two npm trusted publisher entries, the app and its permissions, and the two repository secrets. That section also corrects a real gap: the app needs Issues read and write, not just Contents and Pull requests. release-please applies its `autorelease` labels through the issues API, so without it a release tags and then fails partway. Two smaller fixes from the same review. The "Publishing credentials" section described the npm account settings as a statement of current fact rather than as setup, so a maintainer could read it and skip configuring them. And the Drupal module section still told the reader packages are released with Lerna, which the section below it contradicts. Drop the permissions block from the release-please job. Both of its steps authenticate with the app token, so the block only widened a GITHUB_TOKEN that is never used, in a job whose purpose is to avoid widening anything. --- .github/workflows/release.yml | 6 ++--- MAINTAINING.md | 45 ++++++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6ae0198f..1e90c2012 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,9 +23,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 if: github.repository == 'chapter-three/next-drupal' - permissions: - contents: write - pull-requests: write + # No permissions block: both steps authenticate with the app token, so the + # job's own GITHUB_TOKEN stays at the workflow default of contents: read. + # The app's permissions are set on the app itself, not here. outputs: releases_created: ${{ steps.release.outputs.releases_created }} steps: diff --git a/MAINTAINING.md b/MAINTAINING.md index 42e436fba..7ac094b66 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -6,7 +6,7 @@ This document is for maintainers to explain the various procedures for all the p ### `next` (Drupal Module) -While maintaining releases for packages, starters and examples is done with Lerna, releases for Drupal modules are controlled by drupal.org’s infrastructure, so these steps don’t involve Lerna. +The `next-drupal` package is released automatically, and starters and examples are tagged by hand. Releases for Drupal modules are different again: they are controlled by drupal.org’s infrastructure, so none of that applies here. 1. Optionally, create a new branch on drupal.org. @@ -93,22 +93,55 @@ notes, not about publishing this package. Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). The runner mints a short-lived OIDC credential that npm accepts only from -`.github/workflows/release.yml` in this repository. There is no npm token stored -anywhere, so there is nothing to rotate and nothing to leak. +`.github/workflows/release.yml` in this repository. No npm token is stored, so +there is nothing to rotate and nothing to leak. Two consequences worth knowing: - **Renaming `release.yml` breaks publishing.** npm matches on the workflow filename. If you rename or move it, update the trusted publisher entry in the package settings on npmjs.com first. -- **Publishing from a laptop will fail**, by design. The package has - "Require two-factor authentication and disallow tokens" enabled, so token - authentication is rejected. Release through the pull request. +- **Publishing from a laptop is not supported.** The package rejects token + authentication, so release through the pull request. Because both the repository and the package are public, npm records [provenance](https://docs.npmjs.com/generating-provenance-statements) automatically. No flag is needed. +#### First-time setup + +Only needed when wiring this up from scratch, or when rebuilding it after the +app or the npm settings are lost. Everything here is configured outside the +repository, which is why none of it is visible in the codebase. + +On npmjs.com, under the package's Settings, add two trusted publisher entries +for the `chapter-three/next-drupal` repository: + +| Workflow filename | Environment | +| ----------------- | ----------- | +| `release.yml` | none | +| `release-pr.yml` | `Preview` | + +The environment field must match the workflow exactly, so `release.yml` is left +blank and `release-pr.yml` is set to `Preview`. Also enable "Require two-factor +authentication and disallow tokens" under Publishing access, which is what makes +the workflows the only way to publish. + +On GitHub, create an app owned by the `chapter-three` organization and install +it on this repository alone. It needs three repository permissions: **Contents** +read and write, **Issues** read and write, and **Pull requests** read and write. +Issues is not optional: release-please applies its `autorelease` labels through +the issues API, so without it a release tags and then fails. + +Add the app's ID and private key as the repository secrets `RELEASE_APP_ID` and +`RELEASE_APP_PRIVATE_KEY`. Add app managers so more than one person can +administer it. + +Leave "Allow GitHub Actions to create and approve pull requests" disabled. The +app exists so that this setting can stay off: it is a single switch that also +grants approval, which would let automation satisfy the review requirement on +`main`. + #### Experimental releases from a pull request To publish a throwaway version from an open pull request, for example so another