# 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 -> # (the api container also migrates at start; see docker/api-entrypoint.sh) # 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 Portainer (9443). It should also reach MySQL (3306) # for the migrate step, but that is no longer load-bearing: dispatch with # skip_migrate=true and the api container applies the migrations itself at # start (docker/api-entrypoint.sh). `migrate deploy` is idempotent, so the # two never conflict. # - 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 the runner-side migrate step (safe: the api container migrates at start)" 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