feat(deploy): prisma migration history, /version, galactus standalone deploy
Closes the gap between "what tag did I deploy" and "what is actually running", and gives the schema a history that can be reasoned about across releases. Migrations - Baseline the existing schema as 0000_init (migrate diff --from-empty). The schema had only ever been applied with `prisma db push`, so no history existed and schema state was disconnected from app version. Existing databases must be baselined once with `migrate resolve --applied 0000_init`; the workflows print this remedy on P3005. - Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a CMD, N replicas would race each other applying the same migration. Version reporting - GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that build.yml already baked into both images but nothing ever read. - The web footer shows the web build and flags an api/web mismatch. The two cannot drift at build time (one matrix run) but can at deploy time. - Both deploy workflows now fail if the running API does not report the tag that was dispatched — a stack naming a tag is not proof of what is running. - scripts/set-version.mjs stamps every package.json, which had all sat at 0.1.0 while real releases shipped as v1.x. Pre-migrate backup - deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the still-running old API container over Portainer's Docker API, so the file lands in the volume the Operaciones restore screen reads. A dump taken on the CI runner would be unreachable by the only restore path we have. Verifies the artefact with `gzip -t` before letting the migration proceed. galactus - deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks. Plain compose silently ignores `deploy:`, so restart_policy becomes `restart: unless-stopped` — without it nothing returns after a host reboot. - .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets. Fixes - deploy.yml passed `endpoint_id` and `pull_image` to cssnr/portainer-stack-deploy-action, which has no such inputs (they are `endpoint` and `pull`). The endpoint was silently never set. docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema changes: Prisma has no down-migrations, so a code rollback never rolls the schema back, and restoring the replication master from a dump diverges every replica. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
# 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
|
||||
|
||||
# --- 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
|
||||
Reference in New Issue
Block a user