name: Publish # Builds the application image and pushes it to a container registry. # # push to main -> :main and :sha- # tag 1.2.3 -> :1.2.3, :1.2, :1 and :latest # pull request -> builds without pushing, so a broken Dockerfile is # caught before it can move a published tag # # Configure with repository variables and secrets: # # vars.REGISTRY required, e.g. registry.example.com # vars.IMAGE_NAME optional, defaults to this repository's owner/name # vars.REGISTRY_USER optional, defaults to the actor running the workflow # secrets.REGISTRY_TOKEN required to push # secrets.RELEASE_BOT_SIGNING_KEY required to sign the version bump commit # # Point REGISTRY at a host the runner reaches directly, without an intermediate # proxy that caps request bodies: a browser image has layers well over 100MB, # and such a proxy rejects them mid-push with `413 Payload Too Large`. # # If that host serves plain HTTP, the builder's Docker daemon also needs it in # `insecure-registries` — that is daemon configuration, not something a # workflow can set. on: push: branches: - main tags: # Glob, not regex: `[0-9]` is one digit and `*` is the rest, so these # match 1.2.3 and 1.2.3-rc1 while ignoring tags that are not versions. # A `+` here would be read as a literal plus. - '[0-9]*.[0-9]*.[0-9]*' - 'v[0-9]*.[0-9]*.[0-9]*' pull_request: paths: - 'Dockerfile' - 'package.json' - 'package-lock.json' - '.gitea/workflows/publish.yml' workflow_dispatch: jobs: build: name: Build and push runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check the runner can build images run: | if ! docker info >/dev/null 2>&1; then echo "No usable Docker daemon in the job container." >&2 exit 1 fi docker version --format 'client {{.Client.Version}} / server {{.Server.Version}} / arch {{.Server.Arch}}' # Images are built natively, so each carries the architecture of the # runner that built it. A runner of a different architecture joining the # pool would overwrite these tags with its own arch, at which point this # needs buildx and a manifest list. - name: Work out the tags id: meta env: REGISTRY: ${{ vars.REGISTRY }} IMAGE_NAME: ${{ vars.IMAGE_NAME }} run: | set -euo pipefail if [ -z "${REGISTRY}" ]; then echo "The REGISTRY repository variable is not set." >&2 echo "Set it to the registry host to publish to, e.g. registry.example.com" >&2 exit 1 fi image="${REGISTRY}/$(echo "${IMAGE_NAME:-${{ github.repository }}}" | tr '[:upper:]' '[:lower:]')" tags="" if [ "${{ github.ref_type }}" = "tag" ]; then version="${{ github.ref_name }}" version="${version#v}" tags="${version}" # Only a final release moves the rolling aliases; a prerelease is # published under its own exact version and nothing else. case "${version}" in *-*) ;; *) major="${version%%.*}" minor="${version%.*}" tags="${tags} ${minor} ${major} latest" ;; esac else tags="main sha-$(git rev-parse --short HEAD)" fi args="" for tag in ${tags}; do args="${args} --tag ${image}:${tag}"; done { echo "image=${image}" echo "tags=${tags}" echo "args=${args}" } >> "$GITHUB_OUTPUT" echo "Publishing ${image} as:${tags// /, :}" # The Actions task token is rejected by some registries, so pushing uses # a token that belongs to a real user. - name: Log in to the container registry if: github.event_name != 'pull_request' run: | if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then echo "REGISTRY_TOKEN is not set." >&2 exit 1 fi if ! echo "${{ secrets.REGISTRY_TOKEN }}" \ | docker login "${{ vars.REGISTRY }}" -u "${{ vars.REGISTRY_USER || github.actor }}" --password-stdin then echo >&2 echo "If that failed with 'server gave HTTP response to HTTPS client', the" >&2 echo "registry is plain HTTP and the builder's Docker daemon has to be told" >&2 echo "to allow it: add ${{ vars.REGISTRY }} to insecure-registries in the" >&2 echo "daemon config on the runner. The workflow cannot configure that." >&2 exit 1 fi - name: Build run: docker build --pull ${{ steps.meta.outputs.args }} --file Dockerfile . - name: Push if: github.event_name != 'pull_request' run: | set -euo pipefail for tag in ${{ steps.meta.outputs.tags }}; do docker push "${{ steps.meta.outputs.image }}:${tag}" done echo "Published ${{ steps.meta.outputs.image }} as: ${{ steps.meta.outputs.tags }}" - name: Log out if: always() && github.event_name != 'pull_request' run: docker logout "${{ vars.REGISTRY }}" || true # Once a release is out, the version in package.json has already shipped. # Moving it on to the next patch means the working tree is never sitting on # a number that is published and immutable, and that a build from main is # always identifiable as "after 1.2.0" rather than "1.2.0, but not really". # # `needs: build` is the point of putting this here rather than in a workflow # of its own: a version that failed to publish has not been released, and # bumping past it would say it had. bump: name: Move the working version on needs: build # Tags only, and only final ones. A prerelease has not shipped the version # 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. The full history # because a shallow clone cannot reliably push a branch back. - uses: actions/checkout@v4 with: ref: main fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Work out the next patch version id: next run: | set -euo pipefail version="${{ github.ref_name }}" version="${version#v}" major="${version%%.*}" rest="${version#*.}" minor="${rest%%.*}" patch="${rest##*.}" # `10#` forces base ten: a patch number written 08 would otherwise be # read as octal and fail to parse. next="${major}.${minor}.$((10#${patch} + 1))" echo "next=${next}" >> "$GITHUB_OUTPUT" echo "Released ${version}; the working version becomes ${next}" - name: Bump package.json id: bump env: NEXT: ${{ steps.next.outputs.next }} run: | set -euo pipefail current="$(node -p "require('./package.json').version")" if [ "${current}" = "${NEXT}" ]; then echo "package.json is already ${NEXT}; nothing to do." echo "changed=false" >> "$GITHUB_OUTPUT" exit 0 fi # npm rather than editing the file: the version is in the lockfile # too, in more than one place, and they have to agree. npm version "${NEXT}" --no-git-tag-version --allow-same-version >/dev/null echo "changed=true" >> "$GITHUB_OUTPUT" echo "package.json ${current} -> ${NEXT}" # main requires signed commits, and a pull request carrying an unsigned # one cannot be merged. The key the server signs merge commits with lives # on the server and no runner can reach it, so the bump commit is signed # here with a dedicated key the instance trusts through # `[repository.signing] TRUSTED_SSH_KEYS`. Setting that up is in # CLAUDE.md; nothing about it is committed here. - name: Configure signing as the release bot if: steps.bump.outputs.changed == 'true' env: SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }} run: | set -euo pipefail if [ -z "${SIGNING_KEY}" ]; then echo "RELEASE_BOT_SIGNING_KEY is not set; the bump commit would be unsigned and unmergeable." >&2 exit 1 fi if ! command -v ssh-keygen >/dev/null; then echo "ssh-keygen is missing from this image; git cannot make SSH signatures without it." >&2 exit 1 fi # The secret holds an OpenSSH private key. git signs by shelling out # to ssh-keygen, which wants the key on disk beside the `.pub` it is # pointed at, readable only by us, and rejects it unless the trailing # newline survived the round trip through the secret store. keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing" install -m 700 -d "${keydir}" printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key" chmod 600 "${keydir}/key" # Doubles as a format check: a truncated, re-wrapped or # passphrase-protected key fails here rather than as "gpg failed to # sign the data" three steps later. if ! ssh-keygen -y -f "${keydir}/key" "${keydir}/key.pub"; then echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2 exit 1 fi # A name that is not a person, and an address no account backs: the # signature verifies against the trusted key rather than against a # user, so this is a label on the commit and not an identity. git config user.name 'Release Bot' git config user.email 'release-bot@unsupervised.ca' # `gpg.format` is the historical name; `ssh` is what switches git to # signing with the key above rather than with a GPG key. git config gpg.format ssh git config user.signingkey "${keydir}/key.pub" git config commit.gpgsign true # 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}" git checkout -b "${branch}" git add package.json package-lock.json git commit -m "Set the working version to ${NEXT}" # An unsigned commit would go unnoticed until someone tried to merge # the pull request, so it fails here instead, where the cause is in # front of you. if ! grep -q "^gpgsig" <<<"$(git cat-file commit HEAD)"; then echo "The bump commit came out unsigned; refusing to push it." >&2 exit 1 fi git push origin "${branch}" # 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