Files
jorgecuadros-platform/.gitea/workflows/deploy-galactus.yml
T
rmancinasandClaude Opus 5 27f04f1073 fix(deploy): preflight missing secrets instead of failing opaquely
The first deploy attempt (run 705) died on "Input required and not supplied:
token", which names the action's input rather than the secret that was unset —
the repo had only REGISTRY_USERNAME and REGISTRY_PASSWORD, so every deploy
secret was missing on both workflows. That is also why the endpoint_id /
pull_image input-name bug had gone unnoticed: neither workflow had ever got
far enough to use them.

Both workflows now check their required secrets up front and fail listing the
ones that are empty. The scope=full-only secrets are only required when the
dispatch is actually scope=full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:24:43 -07:00

274 lines
13 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
# - 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 }}
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"
if [ "$SCOPE" = "full" ]; then
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME_GALACTUS
PORTAINER_MINIO_STACK_NAME_GALACTUS
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_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 }}
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
# --- 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 }}",
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
}
# --- prove it ----------------------------------------------------------
- name: Verify running version
env:
API_ORIGIN: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
WANT: ${{ github.event.inputs.tag }}
# The stack file naming a tag is not proof the container is running it —
# a skipped pull or a cached layer can leave the old code up. Ask it.
run: |
set -e
apk add --no-cache curl >/dev/null
for i in $(seq 1 30); do
if curl -fsS "$API_ORIGIN/version" > /tmp/version.json; then break; fi
echo "waiting for API ($i/30)..."
sleep 5
done
cat /tmp/version.json
GOT=$(node -e 'console.log(require("/tmp/version.json").version)')
# Only a semver dispatch is directly comparable: 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 [ "$GOT" != "$WANT" ]; then
echo "::error::deployed $WANT but the API reports $GOT"
exit 1
fi
echo "verified: API is running $GOT"
;;
*)
echo "dispatched '$WANT'; API reports '$GOT' (not directly comparable)"
;;
esac