Files
Discord-Authenticator/.gitea/workflows/release.yml
T
DSH Agent 72b9cbcab8
Build and release image / build (push) Successful in 11s
Fetch the tag annotation explicitly; checkout was shadowing it
`actions/checkout` fetches the tag by commit sha into refs/tags/<tag>, which
replaces the annotated tag with a lightweight one. `git tag -l` then reports
the *commit* message as the tag message, which is why the last attempt
published my commit message as the release notes. The annotation was never
lost, it just was not what that ref named any more.

The steps that read it are gone and the tag object is now fetched by its own
sha into refs/release-notes/<tag>, a namespace checkout cannot collide with.

Checked against a simulated runner checkout — fetch by sha into refs/tags, then
the workflow's own commands — and the notes come out byte-identical to
docs/releases/v0.1.3.md.
2026-09-20 03:38:32 -04:00

298 lines
13 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
# The binary must report the version we tagged — proof that the tag,
# the Cargo.toml bump and the image all agree. A wrong version here
# means someone tagged without bumping the workspace version.
docker exec "$NAME" /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; }
# The entrypoint's chown must have made the volume writable by the
# service user. A fresh boot only mints vault.key — vault.json and
# push-state.json appear once there is something to store — so key
# creation is the honest proof that a first run succeeded.
docker exec "$NAME" sh -ec '
test -f /var/lib/discord-authenticator/vault.key
test -s /var/lib/discord-authenticator/vault.key
' || { echo "::error::the vault key was not created — check volume permissions"; 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}"
# Read the release notes from the tag object itself.
#
# This is not paranoia about the notes file. Across six runs of this
# workflow, act_runner produced worktrees where `git ls-files docs` and
# `ls docs/releases` both showed docs/releases/v0.1.3.md while the file
# itself was absent — intermittently, from the same commit — and then
# `git show HEAD:...` came back empty too, because the shallow fetch had
# not brought that blob across. v0.1.3 shipped a placeholder body four
# times before this landed.
#
# There is a second trap, which cost two more runs: `actions/checkout`
# fetches the tag *by commit sha* into refs/tags/<tag> (see the
# `+<sha>:refs/tags/v0.1.3` in its fetch line). That overwrites the
# annotated tag with a lightweight one pointing at the commit, so
# `git tag -l` happily reports the commit message as the tag message. The
# annotation is still in the object database; it just is not what
# `refs/tags/v0.1.3` names any more. Hence the explicit fetch by tag
# object sha below, into a namespace checkout cannot collide with.
- name: Collect release notes
if: github.ref_type == 'tag'
run: |
set -u
TAG="${{ github.ref_name }}"
VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
NOTES=""
TAG_SHA=$(git ls-remote --tags origin "refs/tags/${TAG}" 2>/dev/null | awk '{print $1}')
if [ -n "$TAG_SHA" ]; then
git fetch -q --depth=1 origin "+${TAG_SHA}:refs/release-notes/${TAG}" 2>/dev/null || true
if [ "$(git cat-file -t "refs/release-notes/${TAG}" 2>/dev/null)" = "tag" ]; then
# cat-file prints the object verbatim: three header lines, a
# blank, then the message. `sed '1,/^$/d'` drops the headers,
# `tail -n +2` the one-line subject, and the last sed any blank
# line left between them. Format specifiers like %(contents:body)
# are avoided because they also treat a `#` line as a comment,
# which would delete every heading in these notes.
NOTES=$(git cat-file -p "refs/release-notes/${TAG}" \
| sed '1,/^$/d' | tail -n +2 | sed '/./,$!d' || true)
fi
fi
if [ -z "$NOTES" ]; then
NOTES=$(git show "HEAD:docs/releases/${VERSION}.md" 2>/dev/null || true)
fi
if [ -n "$NOTES" ]; then
echo "notes: ${#NOTES} bytes from the ${TAG} annotation"
else
NOTES=$(printf 'Release %s.\n\nSee the repository README for deployment instructions.' "$VERSION")
echo "::warning::tag ${TAG} carries no notes body; using a placeholder"
fi
printf '%s' "$NOTES" > /tmp/notes.md
# 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 }}
DIGEST: ${{ steps.push.outputs.digest }}
IMAGE: ${{ steps.version.outputs.image }}
run: |
set -eu
API="https://${REGISTRY}/api/v1"
TAG="${{ github.ref_name }}"
# The tag points at this commit, so reading the version here agrees
# with the tag by construction — no dependency on an earlier step's
# output surviving intact.
VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
echo "publishing release for ${VERSION} (tag ${TAG})"
VERSION="$VERSION" NOTES="$(cat /tmp/notes.md)" node -e '
const { NOTES, VERSION, IMAGE, DIGEST, REGISTRY } = process.env;
const ref = REGISTRY + "/" + IMAGE + ":" + VERSION;
require("fs").writeFileSync("/tmp/release.json", 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/${TAG}" > /tmp/existing.json || echo '{}' > /tmp/existing.json
EXISTING=$(node -e '
const fs = require("fs");
try {
const r = JSON.parse(fs.readFileSync("/tmp/existing.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" --data-binary @/tmp/release.json \
"${API}/repos/${REPO}/releases/${EXISTING}" > /dev/null
echo "updated release ${TAG}"
else
curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" --data-binary @/tmp/release.json \
"${API}/repos/${REPO}/releases" > /dev/null
echo "created release ${TAG}"
fi