Files
antisocial/.gitea/workflows/publish.yml
T
KydoimosandClaude Opus 5 51dfade4f9
CI / Typecheck, test, build (pull_request) Successful in 38s
Publish / Build and push (pull_request) Successful in 1m48s
Publish / Move the working version on (pull_request) Skipped
Sign the automated version bump commit
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]>
2026-09-05 20:30:09 -03:00

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