# 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:@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-, 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 # --- 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