The stack env is assembled from Gitea repo secrets by the deploy workflows' `env_data` block — there is no .env file on the host for the app stack. SES was in neither, so `MailService` came up unconfigured on every deployment and, with NODE_ENV=production killing the stdout dev fallback, every notification and renewal aviso failed. Wire SES_REGION / SES_FROM / SES_FROM_NAME / SES_ACCESS_KEY / SES_SECRET_KEY / SES_CONFIGURATION_SET / NOTIFICATION_ADMIN_EMAILS through both galactus and cubex. No `_GALACTUS` suffix: one SES identity serves every deployment. Kept out of the required-secrets preflight — mail is not needed to boot, and failing a deploy over it would be wrong. Preflight warns instead, since the failure is otherwise invisible until someone clicks "Ejecutar". Also corrects the comments added in the previous commit, which claimed these belonged in a host env file rather than in CI secrets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
15 KiB
YAML
329 lines
15 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 MYSQL_ROOT_PASSWORD"
|
|
if [ "$SCOPE" = "full" ]; then
|
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME PORTAINER_MINIO_STACK_NAME
|
|
MYSQL_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 }}
|
|
# The dump runs as root: --single-transaction issues FLUSH TABLES,
|
|
# which needs the global RELOAD privilege the application user
|
|
# deliberately does not have.
|
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
|
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 }}",
|
|
"OPS_DB_ADMIN_USER": "root",
|
|
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}",
|
|
"SES_REGION": "${{ secrets.SES_REGION }}",
|
|
"SES_FROM": "${{ secrets.SES_FROM }}",
|
|
"SES_FROM_NAME": "${{ secrets.SES_FROM_NAME }}",
|
|
"SES_ACCESS_KEY": "${{ secrets.SES_ACCESS_KEY }}",
|
|
"SES_SECRET_KEY": "${{ secrets.SES_SECRET_KEY }}",
|
|
"SES_CONFIGURATION_SET": "${{ secrets.SES_CONFIGURATION_SET }}",
|
|
"NOTIFICATION_ADMIN_EMAILS": "${{ secrets.NOTIFICATION_ADMIN_EMAILS }}"
|
|
}
|
|
|
|
# --- 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
|