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 + } + } +}