From 2a20c0e05be56f9a1796828086640e1438777114 Mon Sep 17 00:00:00 2001
From: Thom Lamb
Date: Mon, 17 Aug 2026 19:10:04 -0500
Subject: [PATCH] ci: manual workflow to build and publish the QA image (#25)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.gitea/workflows/qa-build.yml | 152 ++++++++++++++++++++++++++++++++++
docker-compose.qa.yml | 24 +++---
2 files changed, 164 insertions(+), 12 deletions(-)
create mode 100644 .gitea/workflows/qa-build.yml
diff --git a/.gitea/workflows/qa-build.yml b/.gitea/workflows/qa-build.yml
new file mode 100644
index 0000000..db2659b
--- /dev/null
+++ b/.gitea/workflows/qa-build.yml
@@ -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<> "$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 <A QA image has been built and pushed.
- Ref: ${{ inputs.ref }}
- Commit: ${{ steps.meta.outputs.sha }}
- Subject: ${{ steps.meta.outputs.subject }}
To deploy it: open the redefined-designs-qa stack in Portainer and redeploy with Pull latest image enabled.
Migrations run automatically as the container starts — check docker logs redefined-designs-qa-syn shows the migration output before listening on 3000, and that it appears only once.
"
+ }
+ 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"
diff --git a/docker-compose.qa.yml b/docker-compose.qa.yml
index 0ae7831..1aaad97 100644
--- a/docker-compose.qa.yml
+++ b/docker-compose.qa.yml
@@ -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