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>
360 lines
17 KiB
YAML
360 lines
17 KiB
YAML
# Manual PROD deploy to galactus — the office server, Portainer endpoint 3.
|
|
#
|
|
# galactus is STANDALONE Docker (`swarm: inactive`), so this workflow applies
|
|
# the compose files under deploy/galactus/, NOT the Swarm files in deploy/.
|
|
# .gitea/workflows/deploy.yml is the cubex/Swarm equivalent; the two are kept
|
|
# separate on purpose because plain compose silently ignores Swarm's `deploy:`
|
|
# keys rather than failing on them.
|
|
#
|
|
# This does NOT build. build.yml already built + pushed both images from one
|
|
# matrix run, so api and web at the same tag are always in step.
|
|
#
|
|
# Order of operations, and why:
|
|
# 1. db + minio (scope=full only) — the API depends on both.
|
|
# 2. pre-migrate backup dumped INSIDE the still-running OLD api container,
|
|
# so the file lands in the volume the Operaciones
|
|
# restore screen reads. Must precede the migration.
|
|
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
|
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
|
# the rule, the backup is the emergency lever.
|
|
# 4. app (api + web) the new images.
|
|
# 5. verify ask the running API what it actually is.
|
|
#
|
|
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
|
|
# schema stays forward. This is exactly why every schema change must be
|
|
# backward-compatible with the previous release.
|
|
#
|
|
# Prereqs (once):
|
|
# - Gitea repo secrets, galactus-specific (suffix _GALACTUS so the cubex
|
|
# secrets keep working side by side):
|
|
# PORTAINER_URL_GALACTUS https://100.103.77.46:9443
|
|
# PORTAINER_API_KEY_GALACTUS Portainer access token for galactus
|
|
# PORTAINER_ENDPOINT_ID_GALACTUS 3
|
|
# PORTAINER_APP_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-app
|
|
# PORTAINER_DB_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-db
|
|
# PORTAINER_MINIO_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-minio
|
|
# DATABASE_URL_GALACTUS mysql://jorgecuadros:<pass>@<galactus>:3306/jorgecuadros
|
|
# APP_API_ORIGIN_GALACTUS browser-facing API URL
|
|
# APP_WEB_ORIGIN_GALACTUS web public origin (API CORS)
|
|
# APP_S3_ENDPOINT_GALACTUS server-side minio URL
|
|
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
|
|
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
|
|
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
|
|
# Optional — outbound mail. Not needed to deploy; needed for
|
|
# /notificaciones to send anything at all (the image sets
|
|
# NODE_ENV=production, which disables MailService's stdout fallback, so
|
|
# a blank config fails every send loudly):
|
|
# SES_REGION e.g. us-west-2
|
|
# SES_FROM a VERIFIED SES sending identity
|
|
# SES_FROM_NAME display name, optional
|
|
# SES_ACCESS_KEY / SES_SECRET_KEY
|
|
# SES_CONFIGURATION_SET optional, for bounce/complaint events
|
|
# NOTIFICATION_ADMIN_EMAILS comma-separated summary recipients
|
|
# These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity
|
|
# serves every deployment.
|
|
# - The runner (which lives on cubex) must be able to reach BOTH
|
|
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
|
|
# If it cannot reach 3306, run the migration by hand from a host that can
|
|
# and dispatch with skip_migrate=true.
|
|
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
|
# with `prisma db push`): baseline it before the first run, or step 3 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 galactus
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: "Image tag to deploy (1.2.3 — no leading v — or sha-<short>, or latest)"
|
|
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.tag }} (${{ 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. Check them up front and say
|
|
# exactly which ones are missing.
|
|
- name: Preflight — required secrets
|
|
env:
|
|
PORTAINER_URL_GALACTUS: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
PORTAINER_API_KEY_GALACTUS: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
PORTAINER_ENDPOINT_ID_GALACTUS: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
PORTAINER_APP_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
|
PORTAINER_DB_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
|
PORTAINER_MINIO_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
|
DATABASE_URL_GALACTUS: ${{ secrets.DATABASE_URL_GALACTUS }}
|
|
SESSION_SECRET_GALACTUS: ${{ secrets.SESSION_SECRET_GALACTUS }}
|
|
APP_API_ORIGIN_GALACTUS: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
|
APP_WEB_ORIGIN_GALACTUS: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
|
|
APP_S3_ENDPOINT_GALACTUS: ${{ secrets.APP_S3_ENDPOINT_GALACTUS }}
|
|
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 }}
|
|
# Not required — the app boots fine without mail. Warned about below,
|
|
# because the failure mode is remote: everything looks healthy until
|
|
# someone clicks "Ejecutar" and every send fails.
|
|
SES_REGION: ${{ secrets.SES_REGION }}
|
|
SES_FROM: ${{ secrets.SES_FROM }}
|
|
SES_ACCESS_KEY: ${{ secrets.SES_ACCESS_KEY }}
|
|
SES_SECRET_KEY: ${{ secrets.SES_SECRET_KEY }}
|
|
SCOPE: ${{ github.event.inputs.scope }}
|
|
run: |
|
|
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
|
|
PORTAINER_ENDPOINT_ID_GALACTUS PORTAINER_APP_STACK_NAME_GALACTUS
|
|
DATABASE_URL_GALACTUS SESSION_SECRET_GALACTUS
|
|
APP_API_ORIGIN_GALACTUS APP_WEB_ORIGIN_GALACTUS
|
|
APP_S3_ENDPOINT_GALACTUS MINIO_ROOT_USER MINIO_ROOT_PASSWORD
|
|
MYSQL_ROOT_PASSWORD"
|
|
if [ "$SCOPE" = "full" ]; then
|
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME_GALACTUS
|
|
PORTAINER_MINIO_STACK_NAME_GALACTUS 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"
|
|
|
|
# Mail is optional to deploy but not optional to work. Say so loudly
|
|
# rather than letting /notificaciones fail one send at a time.
|
|
mail_missing=""
|
|
for name in SES_REGION SES_FROM SES_ACCESS_KEY SES_SECRET_KEY; do
|
|
eval "value=\${$name}"
|
|
[ -z "$value" ] && mail_missing="$mail_missing $name"
|
|
done
|
|
if [ -n "$mail_missing" ]; then
|
|
echo "::warning::outbound mail is NOT configured, missing:$mail_missing"
|
|
echo "::warning::the deploy will succeed, but every notification and"
|
|
echo "::warning::renewal aviso will fail with 'El envío de correo no"
|
|
echo "::warning::está configurado.' See docs/MASS_EMAIL_NOTIFICATIONS.md"
|
|
fi
|
|
|
|
# --- 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_GALACTUS }}
|
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
|
file: deploy/galactus/jorgecuadros-db.compose.yml
|
|
type: file
|
|
standalone: true
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
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_GALACTUS }}
|
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
|
file: deploy/galactus/jorgecuadros-minio.compose.yml
|
|
type: file
|
|
standalone: true
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
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 ------
|
|
- name: Pre-migrate backup
|
|
env:
|
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
|
# 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 ---------------------------------------------
|
|
- name: Apply database migrations
|
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
|
env:
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
|
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. Pull explicitly, or a "successful" deploy can leave
|
|
# the host serving an older build of the same tag.
|
|
- name: Pull images
|
|
env:
|
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
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_GALACTUS }}
|
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
|
file: deploy/galactus/jorgecuadros-app.compose.yml
|
|
type: file
|
|
standalone: true
|
|
pull: true
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
env_data: |
|
|
{
|
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
|
"API_PORT": "3001",
|
|
"WEB_PORT": "3000",
|
|
"S3_BUCKET": "jorgecuadros-documents",
|
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
|
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
|
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}",
|
|
"SESSION_COOKIE_SECURE": "false",
|
|
"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 ----------------------------------------------------------
|
|
- name: Verify running version
|
|
env:
|
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
|
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
|
|
WANT: ${{ github.event.inputs.tag }}
|
|
# A stack naming a tag is not proof the containers run it. Ask BOTH
|
|
# tiers what they are, and require them to be the same commit: api and
|
|
# web are built from one matrix run, so a difference can only mean one
|
|
# of them did not actually get replaced.
|
|
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"
|
|
|
|
# A semver dispatch is additionally comparable to the tag itself:
|
|
# metadata-action's {{version}} turns tag v1.2.3 into image 1.2.3,
|
|
# while `latest` and `sha-*` report the branch or short sha instead.
|
|
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
|