Merge pull request 'Open the version bump as a pull request' (#6) from bump-version-by-pull-request into main
Publish / Build and push (push) Successful in 6s
Publish / Move the working version on (push) Skipped
CI / Typecheck, test, build (push) Successful in 13s

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-08-27 23:57:23 +00:00
4 changed files with 75 additions and 33 deletions
+56 -20
View File
@@ -157,19 +157,26 @@ jobs:
# it is a candidate for, so there is nothing yet to move past. # it is a candidate for, so there is nothing yet to move past.
if: github.ref_type == 'tag' && !contains(github.ref_name, '-') if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
runs-on: ubuntu-latest 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: container:
image: node:22 image: node:22
defaults:
run:
shell: bash
steps: steps:
# The tag names a commit in main's history, but the bump belongs on the # 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 - uses: actions/checkout@v4
with: with:
ref: main ref: main
# The task token can push only if the instance allows Actions to fetch-depth: 0
# write to the repository. Where it does not, set VERSION_BUMP_TOKEN token: ${{ secrets.GITHUB_TOKEN }}
# to a personal access token with write access and it is used
# instead.
token: ${{ secrets.VERSION_BUMP_TOKEN || secrets.GITEA_TOKEN }}
- name: Work out the next patch version - name: Work out the next patch version
id: next id: next
@@ -211,30 +218,59 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
echo "package.json ${current} -> ${NEXT}" 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' if: steps.bump.outputs.changed == 'true'
env: env:
NEXT: ${{ steps.next.outputs.next }} NEXT: ${{ steps.next.outputs.next }}
RELEASED: ${{ github.ref_name }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
branch="release/bump-${NEXT}"
# A name that is not a person, and a reserved address that can never # 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. # resolve to one. Nothing here names the instance it runs on.
git config user.name 'Release bot' git config user.name 'Release bot'
git config user.email '[email protected]' git config user.email '[email protected]'
git checkout -b "${branch}"
git add package.json package-lock.json git add package.json package-lock.json
# `[skip ci]` because this commit is a number and nothing else: git commit -m "Set the working version to ${NEXT}"
# without it the push to main starts another build of the very image git push origin "${branch}"
# that was just published.
git commit -m "Set the working version to ${NEXT} [skip ci]"
if ! git push origin HEAD:main; then # node rather than jq to build the request body: jq is not in this
echo >&2 # image, and node is the one thing that certainly is.
echo "Could not push the version bump to main. Either the Actions" >&2 payload="$(BRANCH="${branch}" node -e 'process.stdout.write(JSON.stringify({
echo "token has no write access to this repository, or main is" >&2 head: process.env.BRANCH,
echo "protected against direct pushes. Set VERSION_BUMP_TOKEN to a" >&2 base: "main",
echo "token that may push to main, or allow that token past the" >&2 title: `Set the working version to ${process.env.NEXT}`,
echo "branch protection." >&2 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.`,
exit 1 }))')"
fi
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
+16 -10
View File
@@ -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 `IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the
`REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here. `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 A release also moves `package.json` on to the next patch version, opened as a pull
the `bump` job — so the number in the tree is never one that has already shipped and request by the `bump` job — so the number in the tree is never one that has already
been made immutable. It lives in `publish.yml` rather than a workflow of its own so it shipped and been made immutable. It lives in `publish.yml` rather than a workflow of its
can say `needs: build`: a version that failed to publish has not been released, and own so it can say `needs: build`: a version that failed to publish has not been
bumping past it would claim otherwise. Prereleases are skipped, being candidates for a released, and bumping past it would claim otherwise. Prereleases are skipped, being
version that has not shipped. The bump goes through `npm version` rather than an edit in candidates for a version that has not shipped. The bump goes through `npm version`
place, because the version is in the lockfile too, in more than one place, and the two rather than an edit in place, because the version is in the lockfile too, in more than
have to agree. The commit carries `[skip ci]`, or pushing it would rebuild the image one place, and the two have to agree.
that was just published. Pushing to main needs a token with write access —
`VERSION_BUMP_TOKEN` overrides the task token where that one cannot. 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: Two things any deployment has to get right, both learned the hard way:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.2.1", "version": "1.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "antisocial", "name": "antisocial",
"version": "1.2.1", "version": "1.2.2",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@fastify/static": "10.1.3", "@fastify/static": "10.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.2.1", "version": "1.2.2",
"private": true, "private": true,
"description": "Reads social posts back to you without the app.", "description": "Reads social posts back to you without the app.",
"license": "UNLICENSED", "license": "UNLICENSED",