diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index d69aa9e..d157095 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -157,19 +157,26 @@ jobs: # it is a candidate for, so there is nothing yet to move past. if: github.ref_type == 'tag' && !contains(github.ref_name, '-') runs-on: ubuntu-latest + # npm does the bump, so this one job wants node. A job in a container is + # given `sh -e {0}` as its shell rather than the bash the runner's own jobs + # get, and dash has neither `pipefail` nor the `10#` below — which failed + # the first line of the first step the one time this ran. node:22 is Debian + # and carries bash, so asking for it keeps these scripts the same as the + # ones in the job above. container: image: node:22 + defaults: + run: + shell: bash steps: # The tag names a commit in main's history, but the bump belongs on the - # branch, so this checks out main rather than the tag. + # branch, so this checks out main rather than the tag. The full history + # because a shallow clone cannot reliably push a branch back. - uses: actions/checkout@v4 with: ref: main - # The task token can push only if the instance allows Actions to - # write to the repository. Where it does not, set VERSION_BUMP_TOKEN - # to a personal access token with write access and it is used - # instead. - token: ${{ secrets.VERSION_BUMP_TOKEN || secrets.GITEA_TOKEN }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: Work out the next patch version id: next @@ -211,30 +218,59 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" echo "package.json ${current} -> ${NEXT}" - - name: Commit it to main + # The bump arrives as a pull request rather than as a commit straight to + # main. Pushing a branch asks nothing of the task token beyond ordinary + # write access, so nothing here depends on being allowed past whatever + # protects main; and the pull request puts the changed package.json + # through the build before it lands. Since main is never pushed, the + # `[skip ci]` that would otherwise be needed to stop this rebuilding the + # image just published is not. + - name: Open a pull request for it if: steps.bump.outputs.changed == 'true' env: NEXT: ${{ steps.next.outputs.next }} + RELEASED: ${{ github.ref_name }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail + branch="release/bump-${NEXT}" + # A name that is not a person, and a reserved address that can never # resolve to one. Nothing here names the instance it runs on. git config user.name 'Release bot' git config user.email 'release-bot@noreply.invalid' + git checkout -b "${branch}" git add package.json package-lock.json - # `[skip ci]` because this commit is a number and nothing else: - # without it the push to main starts another build of the very image - # that was just published. - git commit -m "Set the working version to ${NEXT} [skip ci]" + git commit -m "Set the working version to ${NEXT}" + git push origin "${branch}" - if ! git push origin HEAD:main; then - echo >&2 - echo "Could not push the version bump to main. Either the Actions" >&2 - echo "token has no write access to this repository, or main is" >&2 - echo "protected against direct pushes. Set VERSION_BUMP_TOKEN to a" >&2 - echo "token that may push to main, or allow that token past the" >&2 - echo "branch protection." >&2 - exit 1 - fi + # node rather than jq to build the request body: jq is not in this + # image, and node is the one thing that certainly is. + payload="$(BRANCH="${branch}" node -e 'process.stdout.write(JSON.stringify({ + head: process.env.BRANCH, + base: "main", + title: `Set the working version to ${process.env.NEXT}`, + body: `${process.env.RELEASED} has shipped, so the tree was left on a version that is published and immutable. This moves it on to ${process.env.NEXT}, which is deliberately not a version that exists.`, + }))')" + + api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + response="$(mktemp)" + code="$(curl -sS -o "${response}" -w '%{http_code}' \ + -X POST "${api}/pulls" \ + -H "Authorization: token ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "${payload}")" + + case "${code}" in + 201) echo "Opened ${branch} against main." ;; + # A release re-run that got this far: the branch and its pull + # request are already there, which is the state we wanted anyway. + 409) echo "A pull request for ${branch} is already open." ;; + *) + echo "Could not open the pull request (HTTP ${code}):" >&2 + cat "${response}" >&2 + exit 1 + ;; + esac diff --git a/CLAUDE.md b/CLAUDE.md index da93618..3f5bb7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,16 +210,22 @@ The registry comes from the `REGISTRY` repository variable, the image name from `IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the `REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here. -A release also moves `package.json` on to the next patch version, committed to main by -the `bump` job — so the number in the tree is never one that has already shipped and -been made immutable. It lives in `publish.yml` rather than a workflow of its own so it -can say `needs: build`: a version that failed to publish has not been released, and -bumping past it would claim otherwise. Prereleases are skipped, being candidates for a -version that has not shipped. The bump goes through `npm version` rather than an edit in -place, because the version is in the lockfile too, in more than one place, and the two -have to agree. The commit carries `[skip ci]`, or pushing it would rebuild the image -that was just published. Pushing to main needs a token with write access — -`VERSION_BUMP_TOKEN` overrides the task token where that one cannot. +A release also moves `package.json` on to the next patch version, opened as a pull +request by the `bump` job — so the number in the tree is never one that has already +shipped and been made immutable. It lives in `publish.yml` rather than a workflow of its +own so it can say `needs: build`: a version that failed to publish has not been +released, and bumping past it would claim otherwise. Prereleases are skipped, being +candidates for a version that has not shipped. The bump goes through `npm version` +rather than an edit in place, because the version is in the lockfile too, in more than +one place, and the two have to agree. + +It arrives as a pull request rather than as a commit straight to main: pushing a branch +asks nothing of the task token beyond ordinary write access, so it does not matter what +protects main, and the changed `package.json` goes through a build before it lands. +Nothing pushes to main, so no `[skip ci]` is needed to stop the bump rebuilding the +image just published. The job is the only one that runs in a container (`node:22`, for +npm), and a job in a container is handed `sh`, not bash — hence the explicit +`shell: bash`, without which `set -o pipefail` fails the first line of the first step. Two things any deployment has to get right, both learned the hard way: