main requires signed commits, so the pull request the bump job opens after a release cannot be merged while the commit in it is unsigned. The key the server signs merge commits with is not reachable from a runner, so the job signs with a dedicated release-bot SSH key that the instance trusts through TRUSTED_SSH_KEYS — no bot account, because an account key is only consulted after the web Verify flow and that flow has no API. Inert until the key is trusted and RELEASE_BOT_SIGNING_KEY is set, and loudly so: the step checks the secret and ssh-keygen before it starts, runs the key through ssh-keygen -y so a truncated or re-wrapped one is caught as itself rather than as "gpg failed to sign the data", and the commit is re-read for a gpgsig header before it is pushed. Co-Authored-By: Claude Opus 5 <[email protected]>
332 lines
14 KiB
YAML
332 lines
14 KiB
YAML
name: Publish
|
|
|
|
# Builds the application image and pushes it to a container registry.
|
|
#
|
|
# push to main -> :main and :sha-<short>
|
|
# 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" </dev/null > "${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 '[email protected]'
|
|
# `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
|