Two leftovers from making the browser derive the API origin. The stack env still injected API_ORIGIN from a repo secret, which pinned the origin again on every deploy and would have re-broken an https front door with mixed active content. Drop it from both env_data blocks; the secret stays, now purely as the URL the verify step probes. That verify step was also about to break on its own: WEB_ORIGIN is a comma-separated CORS list now, and `curl "$WEB_ORIGIN/version"` on a list retries thirty times and fails a deploy whose app is perfectly healthy. Probe the first entry, so keep the runner-reachable origin first in the secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
379 lines
19 KiB
YAML
379 lines
19 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 fallback only — the summary recipients
|
|
# are edited in the UI and stored in
|
|
# app_settings; this is what a deployment
|
|
# uses until somebody saves them there
|
|
# 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 }}
|
|
# NOTE: the block below is parsed as JSON — no comments inside it.
|
|
#
|
|
# API_ORIGIN is deliberately absent. The browser derives the API origin
|
|
# from the page it loaded (apps/web/src/lib/api.ts), so the deployment
|
|
# survives the box moving between the tailnet, the office LAN and a
|
|
# demo domain. Setting it here would pin it again and re-break an https
|
|
# front door with mixed active content. APP_API_ORIGIN_GALACTUS lives
|
|
# on only as the URL the verify step probes.
|
|
env_data: |
|
|
{
|
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
|
"API_PORT": "3001",
|
|
"WEB_PORT": "3000",
|
|
"S3_BUCKET": "jorgecuadros-documents",
|
|
"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 }}",
|
|
"REPLICA_DB_HOST": "${{ secrets.REPLICA_DB_HOST }}",
|
|
"REPLICA_DB_USER": "${{ secrets.REPLICA_DB_USER }}",
|
|
"REPLICA_DB_PASS": "${{ secrets.REPLICA_DB_PASS }}",
|
|
"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
|
|
# These secrets are CORS origin LISTS as far as the app is concerned
|
|
# (WEB_ORIGIN is comma-separated so one deployment can be reached by
|
|
# LAN IP, tailnet name and demo domain at once). A list is not a URL,
|
|
# so probe the FIRST entry — keep the runner-reachable origin first.
|
|
API_ORIGIN=${API_ORIGIN%%,*}
|
|
WEB_ORIGIN=${WEB_ORIGIN%%,*}
|
|
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
|