ci: manual workflow to build and publish the QA image (#25)
SonarQube Analysis / sonarqube (pull_request) Successful in 2m34s
Tests / backend-unit (pull_request) Successful in 36s
Tests / backend-integration (pull_request) Failing after 3h3m42s
Tests / frontend-e2e (pull_request) Failing after 7m32s

Adds a workflow_dispatch job that builds a chosen ref, pushes it to the
Gitea container registry as :qa plus a commit-sha tag, and emails when it
is ready. It deliberately does not restart the QA stack — redeploying
stays a human action in Portainer.

The build runs against a Docker-in-Docker service rather than the NAS's
Docker socket. Mounting the host socket into the runner would give every
workflow on every branch root-equivalent control of the NAS, production
included; pushing to a registry means the image does not need to survive
in the build daemon.

The QA stack now pulls that image instead of requiring a local build. The
previous arrangement meant the image existed only if someone remembered
to build it, which produced two confusing failures already: a Docker Hub
"pull access denied" when the tag was missing, and a silent stale-image
deploy when the build had not been rerun.

Two runner capabilities cannot be verified from here — privileged service
containers for dind, and a docker CLI in the runner image. The workflow
checks both and fails with an explanation rather than a connection
refused, and validates all five required secrets and variables up front
rather than part-way through a build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:10:04 -05:00
co-authored by Claude Opus 5
parent db7c61c89d
commit 2a20c0e05b
2 changed files with 164 additions and 12 deletions
+152
View File
@@ -0,0 +1,152 @@
name: Build QA Image
# Manual only. Builds the QA image from a chosen ref, pushes it to the Gitea
# container registry, and emails when it is ready to redeploy.
#
# It deliberately does NOT restart the QA stack. Redeploying stays a human
# action in Portainer, so nothing changes what is running without someone
# deciding it should.
on:
workflow_dispatch:
inputs:
ref:
description: Branch, tag, or commit to build
required: true
default: main
env:
IMAGE: gitea.bermudalamb.synology.me/bermudalamb/redefined-designs
# The build runs against a Docker-in-Docker service rather than the host
# daemon. Mounting the host socket into the runner would give every workflow
# on every branch root-equivalent control of the NAS, production included.
# Because the image is pushed to a registry, it does not need to survive in
# the build daemon.
DOCKER_HOST: tcp://docker:2375
jobs:
build:
runs-on: ubuntu-latest
services:
docker:
image: docker:27-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ""
steps:
- name: Check required configuration
run: |
missing=""
[ -n "${{ secrets.REGISTRY_TOKEN }}" ] || missing="$missing REGISTRY_TOKEN"
[ -n "${{ vars.REGISTRY_USER }}" ] || missing="$missing REGISTRY_USER"
[ -n "${{ secrets.BREVO_API_KEY }}" ] || missing="$missing BREVO_API_KEY"
[ -n "${{ vars.QA_NOTIFY_TO }}" ] || missing="$missing QA_NOTIFY_TO"
[ -n "${{ vars.QA_NOTIFY_FROM }}" ] || missing="$missing QA_NOTIFY_FROM"
if [ -n "$missing" ]; then
echo "::error::Missing configuration:$missing"
echo "Secrets go in Settings > Actions > Secrets; variables in Settings > Actions > Variables."
exit 1
fi
echo "All required secrets and variables are present."
- name: Checkout ${{ inputs.ref }}
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Ensure a docker CLI is available
run: |
if command -v docker >/dev/null 2>&1; then
echo "docker CLI already present: $(docker --version)"
exit 0
fi
echo "docker CLI missing from the runner image; installing the static binary."
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz
tar -xzf /tmp/docker.tgz -C /tmp
install -m 0755 /tmp/docker/docker /usr/local/bin/docker
docker --version
- name: Wait for the build daemon
run: |
# A privileged service container is the one runner capability this
# workflow cannot verify in advance. Fail here with an explanation
# rather than at `docker build` with a connection refused.
for i in $(seq 1 30); do
if docker info >/dev/null 2>&1; then
echo "Build daemon reachable after ${i}s."
exit 0
fi
sleep 1
done
echo "::error::No Docker daemon at $DOCKER_HOST after 30s."
echo "The dind service needs privileged containers. If the runner"
echo "forbids them, this workflow cannot build without host socket access."
exit 1
- name: Record what is being built
id: meta
run: |
echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo "full_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
{
echo "subject<<EOF"
git log -1 --pretty=%s
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Log in to the Gitea registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login gitea.bermudalamb.synology.me \
-u "${{ vars.REGISTRY_USER }}" --password-stdin
- name: Build and push
run: |
# Tagged twice: :qa is what the stack pulls, and the commit tag makes
# it possible to tell what is actually deployed and to roll back to a
# specific build rather than "the previous one".
docker build --no-cache \
-t "$IMAGE:qa" \
-t "$IMAGE:${{ steps.meta.outputs.sha }}" \
.
docker push "$IMAGE:qa"
docker push "$IMAGE:${{ steps.meta.outputs.sha }}"
- name: Email that QA is ready to redeploy
run: |
cat > /tmp/mail.json <<JSON
{
"sender": { "email": "${{ vars.QA_NOTIFY_FROM }}", "name": "Redefined Designs CI" },
"to": [ { "email": "${{ vars.QA_NOTIFY_TO }}" } ],
"subject": "QA image ready — ${{ inputs.ref }} @ ${{ steps.meta.outputs.sha }}",
"htmlContent": "<p>A QA image has been built and pushed.</p><ul><li><b>Ref:</b> ${{ inputs.ref }}</li><li><b>Commit:</b> ${{ steps.meta.outputs.sha }}</li><li><b>Subject:</b> ${{ steps.meta.outputs.subject }}</li></ul><p><b>To deploy it:</b> open the <code>redefined-designs-qa</code> stack in Portainer and redeploy with <i>Pull latest image</i> enabled.</p><p>Migrations run automatically as the container starts — check <code>docker logs redefined-designs-qa-syn</code> shows the migration output before <code>listening on 3000</code>, and that it appears only once.</p>"
}
JSON
code=$(curl -sS -o /tmp/mail-response.json -w '%{http_code}' \
-X POST https://api.brevo.com/v3/smtp/email \
-H "api-key: ${{ secrets.BREVO_API_KEY }}" \
-H "Content-Type: application/json" \
--data @/tmp/mail.json)
echo "Brevo responded $code"
if [ "$code" -ge 300 ]; then
cat /tmp/mail-response.json
# The image is already pushed and usable at this point, so a failed
# notification must not report the build as failed.
echo "::warning::Image pushed successfully, but the notification email failed."
fi
- name: Summary
run: |
{
echo "### QA image pushed"
echo ""
echo "| | |"
echo "|---|---|"
echo "| Ref | \`${{ inputs.ref }}\` |"
echo "| Commit | \`${{ steps.meta.outputs.full_sha }}\` |"
echo "| Tags | \`$IMAGE:qa\`, \`$IMAGE:${{ steps.meta.outputs.sha }}\` |"
echo ""
echo "Redeploy the \`redefined-designs-qa\` stack in Portainer with **Pull latest image** enabled."
} >> "$GITHUB_STEP_SUMMARY"
+12 -12
View File
@@ -17,23 +17,23 @@
# QA_DB_PASSWORD — deliberately not named DB_PASSWORD, so pasting the
# production stack's variables here does nothing silently.
#
# BUILD THE IMAGE BEFORE DEPLOYING THIS STACK. redefined-designs:qa exists only
# on the NAS and is never pushed to a registry, so deploying first makes Compose
# fall back to pulling from Docker Hub and fail with a misleading
# "pull access denied ... repository does not exist or may require docker login".
# The image comes from the Gitea container registry, built by the manual
# "Build QA Image" workflow. Redeploy this stack with Portainer's
# "Pull latest image" toggle ON, or it will keep running the image it already
# has and the redeploy will appear to do nothing.
#
# sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
# Portainer needs registry credentials for gitea.bermudalamb.synology.me once
# (Registries > Add registry, custom, with a Gitea token that has read:package).
#
# For the same reason, leave Portainer's "Pull latest image" toggle off.
# `pull_policy: never` below makes a missing image report itself as missing
# rather than as a registry authentication problem. If the Docker Compose
# version on the NAS ever rejects that key, it is safe to delete the line — it
# only improves the error message.
# This replaced a locally-built `redefined-designs:qa` with `pull_policy: never`.
# That arrangement meant the image existed only if someone had remembered to
# build it, which produced two confusing deploy failures: a "pull access denied"
# from Docker Hub when the tag was missing entirely, and a silent stale-image
# deploy when the build had not been rerun.
services:
redefined-designs-qa:
image: redefined-designs:qa
pull_policy: never
image: gitea.bermudalamb.synology.me/bermudalamb/redefined-designs:qa
container_name: redefined-designs-qa-syn
environment:
- TZ=America/Chicago