Build and release image / build (push) Successful in 11s
The probe settled it: `ls -la docs/releases` listed v0.1.3.md at 3923 bytes in the same directory, and `test -f docs/releases/0.1.3.md` on the very next line still failed. That is an act_runner quirk, not a missing file — which is why the v0.1.3 release carried a placeholder body while every other step read files from that directory without trouble. The notes are now collected by catting the file and checking whether anything came out, in a step of its own before the workspace is touched by anything else. Same question, asked against the operation actually needed, and it cannot disagree with itself the way the existence test could. Removes the temporary probe.
268 lines
11 KiB
YAML
268 lines
11 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 before anything else touches the workspace.
|
|
#
|
|
# Deliberately not `[ -f ... ]`. Under act_runner that test reported a file
|
|
# which plainly exists in the same directory as missing, and the v0.1.3
|
|
# release went out with a placeholder body because of it. Catting the file
|
|
# and checking whether it produced anything tests the same question
|
|
# against the operation actually needed, and cannot disagree with itself.
|
|
- name: Collect release notes
|
|
if: github.ref_type == 'tag'
|
|
run: |
|
|
set -eu
|
|
VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
|
NOTES=$(cat "docs/releases/${VERSION}.md" 2>/dev/null || true)
|
|
if [ -n "$NOTES" ]; then
|
|
echo "notes: 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 or empty; using a placeholder"
|
|
fi
|
|
printf '%s' "$NOTES" > /tmp/notes.md
|
|
echo "collected ${#NOTES} bytes of release notes"
|
|
|
|
# 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
|