ci: manual workflow to build and publish the QA image (#25)
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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user