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>
282 lines
13 KiB
YAML
282 lines
13 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"
|
|
if [ "$SCOPE" = "full" ]; then
|
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME PORTAINER_MINIO_STACK_NAME
|
|
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 }}
|
|
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 }}
|
|
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
|
|
|
|
# --- 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 }}
|
|
env_data: |
|
|
{
|
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
|
"API_PORT": "3001",
|
|
"WEB_PORT": "3000",
|
|
"S3_BUCKET": "jorgecuadros-documents",
|
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN }}",
|
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
|
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET }}",
|
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
|
}
|
|
|
|
# --- 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 }}
|
|
WANT: ${{ github.event.inputs.tag }}
|
|
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
|