# 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:@: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-, 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 # --- make sure the host actually has the images ------------------------ # The deploy action's `pull: true` does not reliably refresh an already # cached moving tag. Pull explicitly, or a "successful" deploy can leave # the host serving an older build of the same tag. - name: Pull images env: PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }} PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }} PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }} 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_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 }}", "SESSION_COOKIE_SECURE": "false", "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 }} WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }} WANT: ${{ github.event.inputs.tag }} # A stack naming a tag is not proof the containers run it. Ask BOTH # tiers what they are, and require them to be the same commit: api and # web are built from one matrix run, so a difference can only mean one # of them did not actually get replaced. run: | set -e apk add --no-cache curl >/dev/null 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" # A semver dispatch is additionally comparable to the tag itself: # 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 [ "$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