The first successful galactus deploy came up all-green while the web tier was running a build from two commits earlier. The registry held web:latest from 3ff56e6; the host still had a web:latest cached from 4ee7ec7; the deploy reported success and served the old one. The API was only current because it had been pulled by hand during earlier debugging. Two independent failures, both fixed here. 1. Images are not pulled. The deploy action's `pull: true` does not reliably refresh an already-cached moving tag on a standalone endpoint. Added a Pull images step (deploy/scripts/pull-images.mjs) that pulls each image through Portainer's Docker API with registry credentials and fails the deploy if a pull fails — note the endpoint answers 200 even when the pull errored, so the stream body has to be inspected, not just the status. 2. The drift check could not see it. Both the verify step and the web footer compared APP_VERSION, but on a branch build BOTH tiers report "master", so equality proved nothing. They now compare gitSha, which is the only field that differs between two builds of the same branch. api and web come from one matrix run, so a difference can only mean an image was not replaced. This needed a /version on the web tier too — previously its build identity was only readable by scraping window.__APP_BUILD__ out of the HTML. pull-images.mjs builds the X-Registry-Auth header as URL-safe base64 WITH padding: Node's "base64url" omits the padding and Portainer's Go decoder rejects it with "Illegal base64 data at input byte N". Verified against galactus: pulls both images, and exits non-zero on a nonexistent tag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
14 KiB
YAML
316 lines
14 KiB
YAML
# Manual PROD deploy to Portainer.
|
|
#
|
|
# This does NOT build — build.yml already builds + pushes the api/web images.
|
|
# This workflow (re)applies the deploy/*.stack.yml files to the Portainer Swarm.
|
|
# Trigger it by hand from the Actions tab ("Run workflow") and choose:
|
|
# - tag: which already-published image tag to ship (default: latest)
|
|
# - scope: how much to deploy
|
|
# app = web + api only (the usual app release) [default]
|
|
# full = db + minio + web + api (bring up / update the whole platform)
|
|
#
|
|
# The `tag` input carries NO leading `v`: metadata-action's {{version}} turns
|
|
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
|
|
#
|
|
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
|
|
# app -> verify the API reports the version you asked for. Rollback = dispatch
|
|
# an older tag; that rolls back CODE only, never the schema, which is why every
|
|
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
|
|
#
|
|
# galactus (the office server) is standalone Docker, not this Swarm — it has its
|
|
# own workflow, .gitea/workflows/deploy-galactus.yml.
|
|
#
|
|
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates
|
|
# it on every run, so no manual stack pre-creation in the Portainer UI. On a
|
|
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
|
|
# on them). db + minio are stateful + pinned to node label jorgecuadros_db=true
|
|
# (see their stack files) — re-applying them is idempotent and keeps their data.
|
|
#
|
|
# Prereqs (once):
|
|
# - one swarm node labelled jorgecuadros_db=true (db + minio + api pin there).
|
|
# - Gitea repo secrets set (Settings > Actions > Secrets):
|
|
# # Portainer
|
|
# PORTAINER_URL https://192.168.4.212:9443
|
|
# PORTAINER_API_KEY Portainer access token
|
|
# PORTAINER_ENDPOINT_ID 2 (the local Swarm endpoint)
|
|
# PORTAINER_APP_STACK_NAME e.g. jorgecuadros-prod-app
|
|
# PORTAINER_DB_STACK_NAME e.g. jorgecuadros-prod-db (full only)
|
|
# PORTAINER_MINIO_STACK_NAME e.g. jorgecuadros-prod-minio (full only)
|
|
# # App runtime
|
|
# DATABASE_URL mysql://jorgecuadros:<pass>@192.168.4.212:3306/jorgecuadros
|
|
# SESSION_SECRET 64-hex (openssl rand -hex 32)
|
|
# APP_API_ORIGIN http://192.168.4.212:3001 (browser-facing API URL)
|
|
# APP_WEB_ORIGIN http://192.168.4.212:3000 (web public origin, API CORS)
|
|
# APP_S3_ENDPOINT http://192.168.4.212:9000 (server-side minio URL)
|
|
# # Object storage (app + minio stack)
|
|
# MINIO_ROOT_USER minio access key
|
|
# MINIO_ROOT_PASSWORD minio secret key
|
|
# # Database stack (full only)
|
|
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
|
# MYSQL_ROOT_PASSWORD mysql root password
|
|
# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the
|
|
# migration step connects to the database directly. If it cannot reach 3306,
|
|
# migrate by hand and dispatch with skip_migrate=true.
|
|
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
|
|
# that exists today): baseline it before the first run, or the migrate step
|
|
# fails with P3005 "database schema is not empty":
|
|
# npx prisma@5 migrate resolve --applied 0000_init \
|
|
# --schema packages/database/prisma/schema.prisma
|
|
|
|
name: Deploy to Portainer
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: "Image tag to deploy (latest, sha-<short>, or vX.Y.Z)"
|
|
required: true
|
|
default: "latest"
|
|
scope:
|
|
description: "What to deploy"
|
|
type: choice
|
|
required: true
|
|
default: "app"
|
|
options:
|
|
- app
|
|
- full
|
|
bootstrap:
|
|
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
|
|
type: boolean
|
|
required: false
|
|
default: false
|
|
skip_migrate:
|
|
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
|
type: boolean
|
|
required: false
|
|
default: false
|
|
|
|
env:
|
|
REGISTRY: git.mancinas.io
|
|
|
|
jobs:
|
|
deploy:
|
|
name: Deploy (${{ github.event.inputs.scope }})
|
|
runs-on: docker
|
|
container:
|
|
image: node:20-alpine
|
|
steps:
|
|
- name: Install tools
|
|
# openssl: prisma's migration engine picks its musl/openssl build at
|
|
# runtime and cannot resolve one without it.
|
|
run: apk add --no-cache openssl ca-certificates git
|
|
|
|
- uses: actions/checkout@v4
|
|
|
|
# An unset secret arrives as an empty string, and the deploy action then
|
|
# fails with "Input required and not supplied: token" — which names the
|
|
# action's input, not the secret you forgot.
|
|
- name: Preflight — required secrets
|
|
env:
|
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
|
PORTAINER_APP_STACK_NAME: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
|
PORTAINER_DB_STACK_NAME: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
|
PORTAINER_MINIO_STACK_NAME: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
|
|
APP_API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
|
|
APP_WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN }}
|
|
APP_S3_ENDPOINT: ${{ secrets.APP_S3_ENDPOINT }}
|
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
|
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
|
SCOPE: ${{ github.event.inputs.scope }}
|
|
run: |
|
|
REQUIRED="PORTAINER_URL PORTAINER_API_KEY PORTAINER_ENDPOINT_ID
|
|
PORTAINER_APP_STACK_NAME DATABASE_URL SESSION_SECRET
|
|
APP_API_ORIGIN APP_WEB_ORIGIN APP_S3_ENDPOINT
|
|
MINIO_ROOT_USER MINIO_ROOT_PASSWORD"
|
|
if [ "$SCOPE" = "full" ]; then
|
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME PORTAINER_MINIO_STACK_NAME
|
|
MYSQL_PASSWORD MYSQL_ROOT_PASSWORD"
|
|
fi
|
|
missing=""
|
|
for name in $REQUIRED; do
|
|
eval "value=\${$name}"
|
|
[ -z "$value" ] && missing="$missing $name"
|
|
done
|
|
if [ -n "$missing" ]; then
|
|
echo "::error::missing repo secrets:$missing"
|
|
echo "::error::set them under Settings > Actions > Secrets"
|
|
exit 1
|
|
fi
|
|
echo "all required secrets present for scope=$SCOPE"
|
|
|
|
# --- full only: database ---------------------------------------------
|
|
- name: Deploy database stack
|
|
if: ${{ github.event.inputs.scope == 'full' }}
|
|
uses: cssnr/portainer-stack-deploy-action@v1
|
|
with:
|
|
url: ${{ secrets.PORTAINER_URL }}
|
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
|
file: deploy/jorgecuadros-db.stack.yml
|
|
type: file
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
|
env_data: |
|
|
{
|
|
"MYSQL_SERVER_ID": "1",
|
|
"MYSQL_PORT": "3306",
|
|
"MYSQL_DATABASE": "jorgecuadros",
|
|
"MYSQL_USER": "jorgecuadros",
|
|
"MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}",
|
|
"MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}"
|
|
}
|
|
|
|
# --- full only: object storage ---------------------------------------
|
|
- name: Deploy minio stack
|
|
if: ${{ github.event.inputs.scope == 'full' }}
|
|
uses: cssnr/portainer-stack-deploy-action@v1
|
|
with:
|
|
url: ${{ secrets.PORTAINER_URL }}
|
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
|
file: deploy/jorgecuadros-minio.stack.yml
|
|
type: file
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
|
env_data: |
|
|
{
|
|
"MINIO_API_PORT": "9000",
|
|
"MINIO_CONSOLE_PORT": "9001",
|
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
|
}
|
|
|
|
# --- restore point, taken while the OLD api container is still up ------
|
|
# Dumped INSIDE the running api container so the file lands in the volume
|
|
# the "Operaciones" restore screen reads — a dump on the runner would be
|
|
# unreachable by the only restore path this platform has.
|
|
- name: Pre-migrate backup
|
|
env:
|
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
BACKUP_TAG: ${{ github.event.inputs.tag }}
|
|
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
|
|
# Portainer serves a self-signed certificate. Scoped to this step
|
|
# only, which does nothing but talk to Portainer.
|
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
|
|
|
# --- schema, forward-only ---------------------------------------------
|
|
# Prisma has no down-migrations: a code rollback does NOT roll the schema
|
|
# back. See docs/DEPLOY_AND_MIGRATIONS.md — every change must be
|
|
# expand/contract so the previous release still runs against the new
|
|
# schema. Run as a deploy STEP, never as the container CMD: N replicas
|
|
# would race each other applying the same migration.
|
|
- name: Apply database migrations
|
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
|
env:
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
run: |
|
|
set -e
|
|
SCHEMA=packages/database/prisma/schema.prisma
|
|
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
|
|
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
|
|
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
|
|
echo "::error::the database predates migration history — baseline it once with:"
|
|
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
|
|
exit 1
|
|
fi
|
|
|
|
# --- make sure the host actually has the images ------------------------
|
|
# The deploy action's `pull: true` does not reliably refresh an already
|
|
# cached moving tag; without this a "successful" deploy can leave the host
|
|
# serving an older build of the same tag.
|
|
- name: Pull images
|
|
env:
|
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
|
REGISTRY: ${{ env.REGISTRY }}
|
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
|
|
TAG: ${{ github.event.inputs.tag }}
|
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
|
run: node deploy/scripts/pull-images.mjs
|
|
|
|
# --- always: the app (web + api) -------------------------------------
|
|
- name: Deploy app stack
|
|
uses: cssnr/portainer-stack-deploy-action@v1
|
|
with:
|
|
url: ${{ secrets.PORTAINER_URL }}
|
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
|
file: deploy/jorgecuadros-app.stack.yml
|
|
type: file
|
|
pull: true
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
|
env_data: |
|
|
{
|
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
|
"API_PORT": "3001",
|
|
"WEB_PORT": "3000",
|
|
"S3_BUCKET": "jorgecuadros-documents",
|
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN }}",
|
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
|
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET }}",
|
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
|
}
|
|
|
|
# --- prove it ----------------------------------------------------------
|
|
# A stack naming a tag is not proof the container is running it — a
|
|
# skipped pull leaves the old code up. Ask the API what it actually is.
|
|
- name: Verify running version
|
|
env:
|
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
|
|
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN }}
|
|
WANT: ${{ github.event.inputs.tag }}
|
|
run: |
|
|
set -e
|
|
apk add --no-cache curl >/dev/null
|
|
fetch_version() {
|
|
for i in $(seq 1 30); do
|
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
|
echo "waiting for $1 ($i/30)..."
|
|
sleep 5
|
|
done
|
|
echo "::error::$1/version never answered"
|
|
return 1
|
|
}
|
|
fetch_version "$API_ORIGIN" /tmp/api.json
|
|
fetch_version "$WEB_ORIGIN" /tmp/web.json
|
|
cat /tmp/api.json; echo; cat /tmp/web.json; echo
|
|
|
|
API_SHA=$(node -e 'console.log(require("/tmp/api.json").gitSha)')
|
|
WEB_SHA=$(node -e 'console.log(require("/tmp/web.json").gitSha)')
|
|
API_VER=$(node -e 'console.log(require("/tmp/api.json").version)')
|
|
|
|
# Compare the COMMIT, not the version string: on a branch build both
|
|
# tiers report "master", so version equality proves nothing.
|
|
if [ "$API_SHA" != "$WEB_SHA" ]; then
|
|
echo "::error::api and web are different builds — api $API_SHA, web $WEB_SHA"
|
|
echo "::error::one of the images was not replaced; check the Pull images step"
|
|
exit 1
|
|
fi
|
|
echo "api and web agree: $API_SHA"
|
|
|
|
case "$WANT" in
|
|
[0-9]*.[0-9]*.[0-9]*)
|
|
if [ "$API_VER" != "$WANT" ]; then
|
|
echo "::error::deployed $WANT but the API reports $API_VER"
|
|
exit 1
|
|
fi
|
|
echo "verified: running $API_VER"
|
|
;;
|
|
*)
|
|
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
|
;;
|
|
esac
|