Build and release image / build (push) Failing after 3m5s
Two workflows, both aimed at the same problem: this container runs unattended
on someone's Unraid box, so the things that keep it correct have to happen
without anyone remembering to do them.
## dependency-check.yml
Runs every six hours and reports everything that has drifted in one place:
crates from Cargo.lock, the Rust pin in rust-toolchain.toml and the Dockerfile,
and the Dockerfile's base images. The result lands in a single issue titled
"Dependency updates" that is rewritten in place rather than commented on, and
is closed automatically when nothing is out of date — an open issue that never
changes is one people stop reading.
Nothing is upgraded automatically. `cargo update` on a schedule would put
unreviewed code into a service holding the operator's second factor, so the
report separates what `cargo update` covers from what crosses a breaking
boundary, and leaves the decision alone.
`.gitea/scripts/dependency-report.mjs` does the work and is deliberately
standalone, so the same file serves the sibling Rust repos. Two details worth
knowing:
* Crates are read from the sparse index, not the crates.io API. The API rate
limits around 130 lookups and answers 429, which silently turned most of
the first report into "could not check"; the index is one request per crate
with no limit.
* The pre-1.0 rule is applied properly. semver treats `0.x.y` as unstable, so
`argon2 0.5 -> 0.6` breaks exactly as hard as `1.5 -> 2.0`. Getting this
wrong labelled half the RustCrypto family "minor or patch" when `cargo
update` will not touch any of them.
## release.yml
Tag `vX.Y.Z` and this builds the image, starts it on a fresh root-owned volume,
waits for /healthz, checks the binary reports the tagged version, pushes to the
Gitea registry, and only then creates the release with the image digest in the
notes. Order matters: a release announcing an image that isn't in the registry
is worse than no release, and a broken build must not overwrite a tag that
already works.
It also asserts the tag and Cargo.toml agree, and rebuilds `:latest` on a
Dockerfile change without touching releases. Hand-written notes come from
docs/releases/<version>.md.
Requires a REGISTRY_TOKEN secret with write:package. Note that Gitea reserves
the `GITEA_` prefix for secret names and rejects it, which is why the secret is
not called GITEA_TOKEN.
242 lines
9.7 KiB
YAML
242 lines
9.7 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: ''
|
|
|
|
concurrency:
|
|
group: release-${{ github.ref }}
|
|
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
|