Files
jorgecuadros-platform/.gitea/workflows/deploy.yml
T
rmancinasandClaude Opus 5 cf40cd22ef fix(deploy): stop pinning API_ORIGIN, and probe one origin not the list
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>
2026-08-11 13:14:04 -07:00

341 lines
16 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 }}
# 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 host moving. Setting it here would pin it again and
# re-break an https front door with mixed active content. APP_API_ORIGIN
# 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 }}",
"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
# These secrets are CORS origin LISTS as far as the app is concerned
# (WEB_ORIGIN is comma-separated so one deployment can be reached under
# several origins 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"
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