276 lines
12 KiB
YAML
276 lines
12 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}"
|
|
|
|
# TEMP diagnostics for the missing release notes.
|
|
- name: TEMP workspace probe
|
|
run: |
|
|
set -u
|
|
echo "pwd: $(pwd)"
|
|
echo "--- top level ---"
|
|
ls -la | head -30
|
|
echo "--- docs ---"
|
|
ls -la docs 2>&1 || echo "no docs/"
|
|
echo "--- docs/releases ---"
|
|
ls -la docs/releases 2>&1 || echo "no docs/releases/"
|
|
echo "--- git ---"
|
|
git rev-parse HEAD
|
|
git ls-files docs | head
|
|
echo "--- direct test ---"
|
|
test -f docs/releases/0.1.3.md && echo "TEST -f PASSES" || echo "TEST -f FAILS"
|
|
cat docs/releases/0.1.3.md 2>&1 | head -3
|
|
|
|
# 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 }}"
|
|
|
|
# Read the version from the checked-out commit rather than from a
|
|
# previous step's output. The tag points at this commit, so the two
|
|
# agree by construction, and this avoids the release quietly falling
|
|
# back to generic notes if the output ever arrives empty.
|
|
VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
|
echo "publishing release for ${VERSION} (tag ${TAG})"
|
|
|
|
# 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, and says which it used.
|
|
if [ -f "docs/releases/${VERSION}.md" ]; then
|
|
NOTES=$(cat "docs/releases/${VERSION}.md")
|
|
echo "using docs/releases/${VERSION}.md (${#NOTES} bytes)"
|
|
else
|
|
NOTES=$(printf 'Release %s.\n\nSee the repository README for deployment instructions.' "$VERSION")
|
|
echo "::warning::docs/releases/${VERSION}.md is missing; writing a placeholder"
|
|
fi
|
|
|
|
VERSION="$VERSION" NOTES="$NOTES" 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
|