From 841d2ec41e9573da5f94fddb2b25344e11308dd4 Mon Sep 17 00:00:00 2001 From: Spikel Date: Tue, 25 Aug 2026 00:03:01 +0800 Subject: [PATCH] ci: a release pull request, and the merge of it publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintenance system had a hole at the end of it: everything could merge itself, and then nothing happened. Publishing meant remembering to draft a release by hand, which is the step most likely to be forgotten and the one where forgetting is least visible. release-please now keeps a single pull request open against main, holding the next version and the changelog it derived from the commit subjects since the last release, rewriting itself as more lands. Nothing is published while it sits there. Merging it is the decision to ship — the one step this whole system asks a person to perform — and that merge creates the tag and the release, from which publish.yml publishes. That pull request is deliberately absent from plugin-automerge's list of automation branches. It is the gate, and a gate that opens itself is furniture. publish.yml gains a workflow_call trigger and keeps its release one. release-please creates the release with GITHUB_TOKEN, and a release created by that token does not trigger workflows, so the publish has to be invoked rather than waited for. The release trigger stays for a release drafted by hand — which is how the first version goes out, before there is a previous release for release-please to count from. The daily schedule on release.yml exists for the same reason in reverse: a merge performed by GitHub's auto-merge is attributed to github-actions[bot], and a push from that token triggers nothing — so a Dependabot bump that merges itself would never reach the push trigger, and the release pull request would quietly stop reflecting it. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish.yml | 63 ++++++++++++++++++++++++++--------- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++++++++ .release-please-manifest.json | 3 ++ release-please-config.json | 15 +++++++++ 4 files changed, 126 insertions(+), 16 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/publish.yml b/.github/workflows/publish.yml index b2816de..d9fb485 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,12 @@ name: Publish -# The human gate. +# The step that reaches somebody. # # Everything else in this repository is allowed to merge itself: a Dependabot -# bump or an automated compatibility fix that goes green on `main` needs no -# review, because `main` reaches nobody. This workflow is the step that does -# reach somebody, so it is the one a person has to perform — by drafting a -# GitHub release and pressing publish. +# bump or an automated compatibility fix that goes green needs no review, +# because `main` reaches nobody. Publishing does, so it happens only from a +# tag a person decided to create — either by merging the release pull request +# (see release.yml) or by drafting a release by hand. # # Deliberately NOT `on: push: branches: [main]`. That trigger would collapse # the merge gate and the release gate into one, and the whole safety of @@ -14,6 +14,12 @@ name: Publish on: release: types: [published] + workflow_call: + inputs: + tag: + description: "Tag to publish. Required when called; taken from the release otherwise." + required: true + type: string permissions: {} @@ -28,9 +34,36 @@ jobs: # this workflow from this commit, which is checkable by anyone. id-token: write steps: + - name: resolve the tag + id: tag + env: + CALLED: ${{ inputs.tag }} + FROM_RELEASE: ${{ github.event.release.tag_name }} + MARKED_PRERELEASE: ${{ github.event.release.prerelease }} + run: | + set -euo pipefail + TAG="${CALLED:-$FROM_RELEASE}" + if [ -z "$TAG" ]; then + echo "::error::no tag to publish"; exit 1 + fi + echo "name=$TAG" >> "$GITHUB_OUTPUT" + + # Prerelease if the semver says so or the release was marked as one. + # The union rather than either alone: a hyphenated version published + # as `latest` would be handed to everyone running a plain install, + # and a release somebody deliberately marked prerelease should be + # honoured whatever its tag looks like. + case "$TAG" in + *-*) PRE=true ;; + *) PRE="${MARKED_PRERELEASE:-false}" ;; + esac + [ "$PRE" = "true" ] || PRE=false + echo "prerelease=$PRE" >> "$GITHUB_OUTPUT" + echo "publishing $TAG (prerelease=$PRE)" + - uses: actions/checkout@v6 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ steps.tag.outputs.name }} persist-credentials: false - uses: actions/setup-node@v4 with: @@ -38,12 +71,12 @@ jobs: cache: npm registry-url: https://registry.npmjs.org - # A release tag that disagrees with package.json publishes a version - # nobody asked for, under a git ref that does not contain it. Cheaper to - # refuse here than to deprecate afterwards. + # A tag that disagrees with package.json publishes a version nobody + # asked for, under a git ref that does not contain it. Cheaper to refuse + # here than to deprecate afterwards. - name: check the tag against package.json env: - TAG: ${{ github.event.release.tag_name }} + TAG: ${{ steps.tag.outputs.name }} run: | set -euo pipefail PKG=$(node -p "require('./package.json').version") @@ -53,20 +86,18 @@ jobs: fi echo "publishing $PKG from $TAG" - # The same checks `main` had to pass, run once more against the tagged - # tree. A release can be cut from any ref, so being green on `main` is - # not by itself evidence about what is in the tarball. + # The same checks main had to pass, run once more against the tagged + # tree. A release can be cut from any ref, so being green on main is not + # by itself evidence about what is in the tarball. - run: npm ci - run: npm run build - run: npx tsc --noEmit -p tsconfig.test.json - run: npm test - # A prerelease goes out under the `next` tag, so `npm i @bitrouter/opencode` - # keeps resolving to the last stable one. - name: publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - PRERELEASE: ${{ github.event.release.prerelease }} + PRERELEASE: ${{ steps.tag.outputs.prerelease }} run: | set -euo pipefail if [ "$PRERELEASE" = "true" ]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..59cf9c9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: Release + +# Turns merged work into a released version, in two steps with a person +# between them. +# +# release-please keeps one pull request open against `main`, holding the next +# version and the changelog entries it derived from the commit subjects since +# the last release. It rewrites that pull request as more lands. Nothing is +# published while it sits there. +# +# Merging it is the decision to ship — the one step this whole maintenance +# system asks a person to perform. That merge creates the tag and the GitHub +# release, and this workflow then publishes to npm from the tag. +# +# The release pull request is deliberately NOT in plugin-automerge's list of +# automation branches. It is the gate; a gate that opens itself is furniture. +on: + push: + branches: [main] + schedule: + # A merge performed by GitHub's auto-merge is attributed to + # `github-actions[bot]`, and a push from that token does not trigger + # workflows — so a Dependabot bump that merges itself never reaches the + # `push` trigger above. Without this, the release pull request would + # silently stop reflecting anything that merged on its own. + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: {} + +jobs: + prepare: + name: keep the release pull request current + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + released: ${{ steps.release.outputs.release_created }} + tag: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Called rather than left to `publish.yml`'s own `release: published` + # trigger: release-please creates that release with GITHUB_TOKEN, and a + # release created by that token does not trigger workflows. The publish has + # to be invoked, not waited for. + publish: + needs: prepare + if: needs.prepare.outputs.released == 'true' + uses: ./.github/workflows/publish.yml + permissions: + contents: read + id-token: write + secrets: inherit + with: + tag: ${{ needs.prepare.outputs.tag }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..1650357 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "$comment": "Release PRs are the human gate. Everything else in this repository may merge itself; merging one of these is the decision to ship, and it is the only one a person makes.", + "packages": { + ".": { + "release-type": "node", + "changelog-path": "CHANGELOG.md", + "include-v-in-tag": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "draft": false, + "prerelease": false + } + } +}