Files
Discord-Authenticator/.gitea/workflows/release.yml
T
DSH Agent 1f4697a885
Build and release image / build (push) Failing after 8s
Serialize image builds across refs
A tag push and a main push can land together and both want to build the same
version and move the same :latest. A per-ref concurrency group let them run in
parallel, which is exactly the race the group is supposed to prevent.
2026-09-20 02:49:40 -04:00

246 lines
9.9 KiB
YAML

name: Build and release image
# Builds the container image and publishes a Gitea release whenever a version
# tag is pushed:
#
# git tag -a v0.1.3 -m "..." && git push origin v0.1.3
#
# The image is built, started, and checked for a working /healthz before it is
# pushed, so a broken build cannot overwrite a tag that already works. The image
# is pushed before the release is created: a release announcing an image that
# isn't in the registry yet is worse than no release.
#
# Pushing to main with a Dockerfile change rebuilds and repushes the current
# version so :latest tracks the fix, but deliberately does not touch releases.
on:
push:
tags:
- 'v*'
branches: [main]
paths:
- 'deploy/docker/**'
- '.gitea/workflows/release.yml'
- 'docker-compose.yml'
workflow_dispatch:
inputs:
version:
description: 'Version to build (defaults to the version in Cargo.toml)'
required: false
default: ''
# One release at a time regardless of ref. A tag push and a main push can land
# together and both want to build the same version and move the same `:latest`;
# serializing them is the only way to avoid two runs pushing identical tags
# concurrently.
concurrency:
group: release
cancel-in-progress: false
permissions:
contents: write
packages: write
env:
REGISTRY: gitea.milesward.dev
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
# Everything downstream needs the version and the image name, so both are
# resolved once here.
#
# A tag is the source of truth when there is one; otherwise the workspace
# version in Cargo.toml, which is what a main-branch rebuild should ship.
# The two are asserted to agree, because a tag that disagrees with the
# binary's own version is a support conversation waiting to happen.
#
# The image name comes from the repository, lowercased because an OCI
# registry rejects uppercase paths. That derivation is what lets this file
# be copied to a sibling repo unchanged.
- name: Resolve version and image
id: version
run: |
set -eu
MANIFEST=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
if [ "${{ github.ref_type }}" = "tag" ]; then
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
if [ "$VERSION" != "$MANIFEST" ]; then
echo "::error::tag ${TAG} says ${VERSION} but Cargo.toml says ${MANIFEST}"
exit 1
fi
else
VERSION="${{ inputs.version }}"
[ -n "$VERSION" ] || VERSION="$MANIFEST"
fi
OWNER=$(printf '%s' "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
NAME=$(printf '%s' "${{ github.repository }}" | sed 's#.*/##' | tr '[:upper:]' '[:lower:]')
IMAGE="${OWNER}/${NAME}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "ref=${REGISTRY}/${IMAGE}" >> "$GITHUB_OUTPUT"
echo "building ${REGISTRY}/${IMAGE}:${VERSION}"
# The port the health check below probes. Read from the Dockerfile rather
# than hard-coded so this workflow travels with the repo.
- name: Resolve health port
id: port
run: |
set -eu
PORT=$(grep -E '^EXPOSE ' deploy/docker/Dockerfile | head -1 | awk '{print $2}' | cut -d/ -f1)
[ -n "$PORT" ] || { echo "::error::no EXPOSE in deploy/docker/Dockerfile"; exit 1; }
echo "port=${PORT}" >> "$GITHUB_OUTPUT"
echo "health port ${PORT}"
- name: Build image
run: |
set -eu
docker build \
--pull \
--platform linux/amd64 \
--file deploy/docker/Dockerfile \
--tag "${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }}" \
--tag "${{ steps.version.outputs.ref }}:latest" \
--build-arg "GIT_SHA=$(git rev-parse HEAD)" \
.
# The image is only as good as its ability to boot. This runs the real
# entrypoint with a real (root-owned, empty) data volume — the state a
# fresh Unraid appdata directory is in — and waits for /healthz.
- name: Smoke test
run: |
set -eu
REF="${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }}"
PORT="${{ steps.port.outputs.port }}"
NAME="da-smoke-${{ github.run_id }}"
VOL="da-smoke-data-${{ github.run_id }}"
cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; docker volume rm -f "$VOL" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker run -d --name "$NAME" \
--mount "type=volume,src=${VOL},dst=/var/lib/discord-authenticator" \
"$REF"
OK=""
for _ in $(seq 1 30); do
if docker exec "$NAME" wget -qO- "http://127.0.0.1:${PORT}/healthz" 2>/dev/null | grep -qx ok; then
OK=1
break
fi
sleep 1
done
if [ -z "$OK" ]; then
echo "::error::the image did not become healthy"
docker logs "$NAME" || true
exit 1
fi
# A normal boot must have created the vault and the push state, and
# the binary must report the version we tagged — proof that the tag,
# the Cargo.toml bump and the image all agree.
docker exec "$NAME" sh -ec '
test -f /var/lib/discord-authenticator/vault.key
test -f /var/lib/discord-authenticator/push-state.json
/usr/local/bin/discord-authenticator --version
' | tee /tmp/smoke-version
grep -q "${{ steps.version.outputs.version }}" /tmp/smoke-version \
|| { echo "::error::the image reports a different version than the tag"; exit 1; }
docker logs "$NAME"
# LOGIN uses the repository owner rather than a fixed name: Gitea's
# registry authenticates against the account that owns the token.
- name: Push to the registry
id: push
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
[ -n "${GITEA_TOKEN:-}" ] || {
echo "::error::no REGISTRY_TOKEN secret. Add one under Settings -> Actions -> Secrets"
echo "::error::with the write:package scope, then re-run this workflow."
exit 1
}
echo "$GITEA_TOKEN" | docker login "$REGISTRY" --username "${{ github.repository_owner }}" --password-stdin
docker push "${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }}"
docker push "${{ steps.version.outputs.ref }}:latest"
docker logout "$REGISTRY" || true
# Record what shipped, so the release body can name the exact digest.
DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' \
"${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }}" | sed 's/.*@//')
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "pushed ${{ steps.version.outputs.ref }}:${{ steps.version.outputs.version }} @ ${DIGEST}"
# Releases are created only for tags. A main-branch rebuild ships the same
# version again and must not rewrite the notes of an existing release.
- name: Publish the release
if: github.ref_type == 'tag'
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REPO: ${{ github.repository }}
VERSION: ${{ steps.version.outputs.version }}
IMAGE: ${{ steps.version.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -eu
API="https://${REGISTRY}/api/v1"
TAG="${{ github.ref_name }}"
# Hand-written notes when they exist, because the interesting part of
# a release here is what the operator has to do, not which commits
# landed. Falls back to a short factual note.
if [ -f "docs/releases/${VERSION}.md" ]; then
NOTES=$(cat "docs/releases/${VERSION}.md")
else
NOTES=$(printf 'Release %s.\n\nSee the repository README for deployment instructions.' "$VERSION")
fi
BODY=$(NOTES="$NOTES" node -e '
const { NOTES, VERSION, IMAGE, DIGEST, REGISTRY } = process.env;
const ref = REGISTRY + "/" + IMAGE + ":" + VERSION;
process.stdout.write(JSON.stringify({
body: NOTES +
"\n\n---\n\n" +
"**Image:** `" + ref + "`\n\n" +
"**Digest:** `" + DIGEST + "`\n\n" +
"```\n" + ref + "\n```\n",
name: "Discord Authenticator " + VERSION,
tag_name: "v" + VERSION,
draft: false,
prerelease: false,
}));')
# A re-run of the same tag updates the release in place rather than
# failing on the duplicate.
curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/releases/tags/v${VERSION}" > /tmp/release.json || echo '{}' > /tmp/release.json
EXISTING=$(node -e '
const fs = require("fs");
try {
const r = JSON.parse(fs.readFileSync("/tmp/release.json", "utf8"));
process.stdout.write(r && r.id ? String(r.id) : "");
} catch { process.stdout.write(""); }
')
if [ -n "$EXISTING" ]; then
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" -d "$BODY" \
"${API}/repos/${REPO}/releases/${EXISTING}" > /dev/null
echo "updated release v${VERSION}"
else
curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" -d "$BODY" \
"${API}/repos/${REPO}/releases" > /dev/null
echo "created release v${VERSION}"
fi