Deploy operator — PUBLIC surface #10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Deploy the PUBLIC operator surface (RFC-108 / epic #1320) to the always-on VPS. | |
| # | |
| # Standalone from deploy-prod.yml: brings up compose/docker-compose.operator-public.yml | |
| # (a low-privilege operator-read backend — no docker.sock, no provider keys — | |
| # PODCAST_SERVE_OPERATOR_PUBLIC + the gi-kg-viewer SPA) and drops the operator.caddy vhost | |
| # into the shared Caddy edge (ADR-114). The privileged operator/kg-gi surface is untouched. | |
| # Manual (workflow_dispatch); typed confirm; tailnet-only SSH. | |
| # | |
| # Prereqs (stage once, like deploy-prod #714): | |
| # secrets: TS_OAUTH_CLIENT_ID + TS_OAUTH_SECRET, PROD_SSH_PRIVATE_KEY, OPERATOR_PREVIEW_COOKIE, | |
| # PROD_SENTRY_DSN_API, and — reused from the player (same Google OAuth client, | |
| # RFC-108) — PLAYER_GOOGLE_CLIENT_SECRET, PLAYER_APP_SESSION_SECRET | |
| # vars: PROD_TAILNET_FQDN, OPERATOR_DOMAIN, PODCAST_CORPUS_VOLUME, APP_ADMIN_EMAILS, | |
| # OPERATOR_ALLOWED_EMAILS, PLAYER_GOOGLE_CLIENT_ID (reused) | |
| # The Caddy edge + firewall 80/443 must already be live on the box, and DNS for | |
| # OPERATOR_DOMAIN must point at the VPS (see docs/guides/PLAYER_PUBLIC_LAUNCH.md). | |
| name: Deploy operator — PUBLIC surface | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| confirm: | |
| description: 'Type OPERATOR_DEPLOY to confirm' | |
| required: true | |
| override_image_sha: | |
| description: 'Optional api image sha-<7> to deploy (blank = newest published from main)' | |
| required: false | |
| concurrency: | |
| group: deploy-operator | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| packages: read | |
| jobs: | |
| deploy: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| environment: prod | |
| steps: | |
| - name: Confirm OPERATOR_DEPLOY | |
| run: | | |
| if [ "${{ inputs.confirm }}" != "OPERATOR_DEPLOY" ]; then | |
| echo "::error::Must type OPERATOR_DEPLOY to confirm. Got: '${{ inputs.confirm }}'" | |
| exit 1 | |
| fi | |
| - name: Pre-flight — required secrets/variables present? | |
| id: preflight | |
| env: | |
| TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }} | |
| TS_OAUTH_SECRET: ${{ secrets.TS_OAUTH_SECRET }} | |
| PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }} | |
| PROD_TAILNET_FQDN: ${{ vars.PROD_TAILNET_FQDN }} | |
| OPERATOR_DOMAIN: ${{ vars.OPERATOR_DOMAIN }} | |
| PODCAST_CORPUS_VOLUME: ${{ vars.PODCAST_CORPUS_VOLUME }} | |
| # Coming-soon gate cookie secret (host-side; substituted into operator.caddy at | |
| # deploy). Required — an empty value would ship a guessable gate. | |
| OPERATOR_PREVIEW_COOKIE: ${{ secrets.OPERATOR_PREVIEW_COOKIE }} | |
| run: | | |
| set -euo pipefail | |
| MISSING="" | |
| [ -z "${TS_OAUTH_CLIENT_ID:-}" ] && MISSING="$MISSING TS_OAUTH_CLIENT_ID" | |
| [ -z "${TS_OAUTH_SECRET:-}" ] && MISSING="$MISSING TS_OAUTH_SECRET" | |
| [ -z "${PROD_SSH_PRIVATE_KEY:-}" ] && MISSING="$MISSING PROD_SSH_PRIVATE_KEY" | |
| [ -z "${PROD_TAILNET_FQDN:-}" ] && MISSING="$MISSING PROD_TAILNET_FQDN" | |
| [ -z "${OPERATOR_DOMAIN:-}" ] && MISSING="$MISSING OPERATOR_DOMAIN" | |
| [ -z "${PODCAST_CORPUS_VOLUME:-}" ] && MISSING="$MISSING PODCAST_CORPUS_VOLUME" | |
| [ -z "${OPERATOR_PREVIEW_COOKIE:-}" ] && MISSING="$MISSING OPERATOR_PREVIEW_COOKIE" | |
| if [ -n "$MISSING" ]; then | |
| echo "::warning::Missing prereqs:$MISSING — stage them then re-run." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Checkout (resolver scripts) | |
| if: steps.preflight.outputs.skip != 'true' | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 1 | |
| - name: Join the tailnet | |
| if: steps.preflight.outputs.skip != 'true' | |
| uses: tailscale/github-action@v4 | |
| with: | |
| oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} | |
| oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} | |
| tags: tag:gha-deployer | |
| version: 1.96.4 | |
| - name: Install SSH identity for deploy@ (PROD_SSH_PRIVATE_KEY) | |
| if: steps.preflight.outputs.skip != 'true' | |
| uses: ./.github/actions/prod-ssh-key | |
| with: | |
| ssh_private_key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} | |
| - name: Resolve prod MagicDNS host | |
| id: ts_host | |
| if: steps.preflight.outputs.skip != 'true' | |
| env: | |
| PROD_TAILNET_FQDN: ${{ vars.PROD_TAILNET_FQDN }} | |
| run: | | |
| set -euo pipefail | |
| R=$(bash scripts/ops/resolve_prod_tailnet_host.sh) | |
| echo "resolved_fqdn=$R" >> "$GITHUB_OUTPUT" | |
| - name: Refresh repo on the box to this ref | |
| if: steps.preflight.outputs.skip != 'true' | |
| env: | |
| SSH_TARGET: deploy@${{ steps.ts_host.outputs.resolved_fqdn }} | |
| # Deploy the ref the workflow was dispatched on — NOT a hardcoded branch. The edge | |
| # / operator work lives on `production`; hardcoding origin/main silently deployed the | |
| # wrong commit (chmod + conf.d fixes on production never reached the box; 2026-07-23). | |
| # Via env (not inline ${{ }}) to keep the ref out of the shell-injection surface. | |
| DEPLOY_REF: ${{ github.ref_name }} | |
| run: | | |
| ssh -i "$SSH_PROD_IDENTITY" -o IdentitiesOnly=yes \ | |
| -o StrictHostKeyChecking=accept-new -o BatchMode=yes "$SSH_TARGET" \ | |
| "set -euo pipefail; cd /srv/podcast-scraper; \ | |
| git fetch --depth=50 origin '+refs/heads/*:refs/remotes/origin/*'; \ | |
| git reset --hard 'origin/$DEPLOY_REF'" | |
| - name: Resolve api image tag | |
| id: imgsha | |
| if: steps.preflight.outputs.skip != 'true' | |
| env: | |
| OVERRIDE: ${{ inputs.override_image_sha }} | |
| run: | | |
| set -euo pipefail | |
| # "One engine, two surfaces" (ADR-116): by DEFAULT the operator-public runs the EXACT | |
| # same api image the privileged operator stack is currently running — resolved on the | |
| # box by deploy-operator.sh from the live operator api container. So the default here | |
| # is to emit an EMPTY tag and let the script pin it. An explicit override_image_sha | |
| # still wins (validated below + staged). Never the literal :main tag (CI stopped | |
| # updating it 2026-05-28 -> pre-ADR-116, no /api/app/* surface -> 404s). | |
| SHA_SHORT="${OVERRIDE:-}"; SHA_SHORT="${SHA_SHORT#sha-}" | |
| if [ -n "$SHA_SHORT" ]; then | |
| if ! [[ "$SHA_SHORT" =~ ^[a-f0-9]{7,40}$ ]]; then | |
| echo "::error::override_image_sha must match ^[a-f0-9]{7,40}$ (got: ${SHA_SHORT})"; exit 1 | |
| fi | |
| echo "tag=sha-$SHA_SHORT" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Operator api image pinned to override sha-$SHA_SHORT" | |
| else | |
| echo "tag=" >> "$GITHUB_OUTPUT" | |
| echo "::notice::No override — deploy-operator.sh will pin the operator surface to the privileged stack's running engine sha" | |
| fi | |
| - name: Validate GHCR api image manifest exists (override only, pre-SSH) | |
| if: steps.preflight.outputs.skip != 'true' && steps.imgsha.outputs.tag != '' | |
| env: | |
| TAG: ${{ steps.imgsha.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin | |
| docker manifest inspect "ghcr.io/chipi/podcast-scraper-stack-api:${TAG}" >/dev/null | |
| - name: Stage .env.operator (secrets via /dev/shm scp, never inline over ssh) | |
| if: steps.preflight.outputs.skip != 'true' | |
| env: | |
| SSH_TARGET: deploy@${{ steps.ts_host.outputs.resolved_fqdn }} | |
| OPERATOR_DOMAIN: ${{ vars.OPERATOR_DOMAIN }} | |
| PODCAST_CORPUS_VOLUME: ${{ vars.PODCAST_CORPUS_VOLUME }} | |
| # Empty by default -> deploy-operator.sh pins to the privileged stack's running engine sha. | |
| PODCAST_IMAGE_TAG: ${{ steps.imgsha.outputs.tag }} | |
| # Reuse the player's Google OAuth client + session secret (same client per RFC-108; | |
| # the operator's own domain redirect URI is added to it). The app env keys written | |
| # into .env.operator below stay APP_OAUTH_GOOGLE_* / APP_SESSION_SECRET. | |
| GOOGLE_CLIENT_ID: ${{ vars.PLAYER_GOOGLE_CLIENT_ID }} | |
| GOOGLE_CLIENT_SECRET: ${{ secrets.PLAYER_GOOGLE_CLIENT_SECRET }} | |
| APP_SESSION_SECRET: ${{ secrets.PLAYER_APP_SESSION_SECRET }} | |
| # Operator error DSN → self-hosted GlitchTip. Empty → no-op. | |
| PODCAST_SENTRY_DSN_API: ${{ secrets.PROD_SENTRY_DSN_API }} | |
| # ADR-115 Option A: when '1', the RUNTIME secrets (client secret, session secret, | |
| # backend DSN) are delivered as tmpfs files + exported by the shim, so they are | |
| # DROPPED from .env.operator. Default empty = today's env behaviour. Cutover = set | |
| # the GH var. Mirrors the player's PLAYER_SECRETS_VIA_FILES. | |
| OPERATOR_SECRETS_VIA_FILES: ${{ vars.OPERATOR_SECRETS_VIA_FILES }} | |
| # Coming-soon gate cookie secret. Consumed HOST-SIDE by deploy-operator.sh (sed'd | |
| # into operator.caddy) — it never enters the container, so it lives in .env.operator | |
| # (0600, scp'd, never inline over ssh), not the tmpfs container-secret set. | |
| OPERATOR_PREVIEW_COOKIE: ${{ secrets.OPERATOR_PREVIEW_COOKIE }} | |
| # THE operator differentiator: these emails become admin (creator via grant). | |
| # A signed-in listener is 403'd from the operator routes (require_viewer_access = ≥creator). | |
| APP_ADMIN_EMAILS: ${{ vars.APP_ADMIN_EMAILS }} | |
| # Backend sign-in access policy (server/app_access.py). Non-secret (emails/domains | |
| # + mode) → GH vars. Unset APP_SIGNUP_MODE defaults to `allowlist`; with an empty | |
| # email/domain list that is DEFAULT-DENY, so these must be set for anyone to sign in. | |
| APP_SIGNUP_MODE: ${{ vars.OPERATOR_SIGNUP_MODE }} | |
| APP_ALLOWED_EMAILS: ${{ vars.OPERATOR_ALLOWED_EMAILS }} | |
| APP_ALLOWED_DOMAINS: ${{ vars.OPERATOR_ALLOWED_DOMAINS }} | |
| run: | | |
| set -euo pipefail | |
| ENVTMP="$(mktemp -p /dev/shm)" | |
| { | |
| echo "OPERATOR_DOMAIN=${OPERATOR_DOMAIN}" | |
| echo "PODCAST_CORPUS_VOLUME=${PODCAST_CORPUS_VOLUME}" | |
| # Host-side gate cookie secret (sed'd into operator.caddy by deploy-operator.sh). | |
| echo "OPERATOR_PREVIEW_COOKIE=${OPERATOR_PREVIEW_COOKIE}" | |
| # Tags backend Sentry events (+ any env-gated behaviour) as prod — matches the | |
| # operator api. Without it the backend defaults to environment=dev (sentry_init). | |
| echo "PODCAST_ENV=prod" | |
| # THE operator differentiator: admin emails granting creator access. | |
| echo "APP_ADMIN_EMAILS=${APP_ADMIN_EMAILS}" | |
| # Backend sign-in access policy. Default (unset) is allowlist; with an empty | |
| # email/domain list that denies EVERYONE ("This account is not allowed to sign | |
| # in"). Set OPERATOR_ALLOWED_EMAILS (or _DOMAINS, or OPERATOR_SIGNUP_MODE=open). | |
| echo "APP_SIGNUP_MODE=${APP_SIGNUP_MODE:-allowlist}" | |
| echo "APP_ALLOWED_EMAILS=${APP_ALLOWED_EMAILS}" | |
| echo "APP_ALLOWED_DOMAINS=${APP_ALLOWED_DOMAINS}" | |
| # OTEL distributed tracing (ADR-119) → the SAME homelab VictoriaTraces OTLP | |
| # ingest the operator uses (deploy-prod.yml). Turns operator-public API request | |
| # spans on so a user-triggered error on the operator surface pivots to its trace. | |
| # The homelab tailnet IP for extra_hosts is resolved on the box by deploy-operator.sh. | |
| echo "OTEL_TRACES_EXPORTER=otlp" | |
| echo "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://homelab:10428/insert/opentelemetry/v1/traces" | |
| # Pin the api image to the resolved newest-published sha (never stale :main). | |
| echo "PODCAST_IMAGE_TAG=${PODCAST_IMAGE_TAG}" | |
| echo "APP_OAUTH_PROVIDER=google" | |
| echo "APP_OAUTH_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" | |
| # Runtime secrets (ADR-115 Option A): under OPERATOR_SECRETS_VIA_FILES=1 these are | |
| # delivered as /run/secrets files + exported by the shim, so DROPPED from | |
| # .env.operator (no secrets at rest on disk). Flag off = today's env behaviour. | |
| if [ "${OPERATOR_SECRETS_VIA_FILES:-}" != "1" ]; then | |
| echo "APP_OAUTH_GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" | |
| echo "APP_SESSION_SECRET=${APP_SESSION_SECRET}" | |
| fi | |
| echo "OPERATOR_PORT=8093" | |
| # Operator BACKEND (server-side) errors -> GlitchTip. RUNTIME secret | |
| # -> delivered as a file under Option A, so DROPPED from .env.operator when | |
| # OPERATOR_SECRETS_VIA_FILES=1. | |
| if [ "${OPERATOR_SECRETS_VIA_FILES:-}" != "1" ]; then | |
| echo "PODCAST_SENTRY_DSN_API=${PODCAST_SENTRY_DSN_API}" | |
| fi | |
| } > "$ENVTMP" | |
| scp -i "$SSH_PROD_IDENTITY" -o IdentitiesOnly=yes \ | |
| -o StrictHostKeyChecking=accept-new -o BatchMode=yes \ | |
| "$ENVTMP" "${SSH_TARGET}:/srv/podcast-scraper/.env.operator.staged" | |
| rm -f "$ENVTMP" | |
| ssh -i "$SSH_PROD_IDENTITY" -o IdentitiesOnly=yes \ | |
| -o StrictHostKeyChecking=accept-new -o BatchMode=yes "$SSH_TARGET" \ | |
| "set -euo pipefail; cd /srv/podcast-scraper; \ | |
| mv .env.operator.staged .env.operator; chmod 600 .env.operator" | |
| - name: Stage tmpfs secret files (ADR-115 Option A) | |
| # When OPERATOR_SECRETS_VIA_FILES=1, write the operator runtime secrets to HOST tmpfs | |
| # /dev/shm/operator-secrets/ (RAM, never disk). deploy-operator.sh joins the secrets | |
| # overlay so they mount as /run/secrets/* and the image shim exports them. Atomic: | |
| # build the dir on the runner in /dev/shm, scp, then swap into place. Mirrors the | |
| # player's "Stage tmpfs secret files" step. Filenames are the LOWERCASE of the env | |
| # the shim must export (app_session_secret -> APP_SESSION_SECRET). | |
| if: steps.preflight.outputs.skip != 'true' && vars.OPERATOR_SECRETS_VIA_FILES == '1' | |
| env: | |
| SSH_TARGET: deploy@${{ steps.ts_host.outputs.resolved_fqdn }} | |
| GOOGLE_CLIENT_SECRET: ${{ secrets.PLAYER_GOOGLE_CLIENT_SECRET }} | |
| APP_SESSION_SECRET: ${{ secrets.PLAYER_APP_SESSION_SECRET }} | |
| PODCAST_SENTRY_DSN_API: ${{ secrets.PROD_SENTRY_DSN_API }} | |
| run: | | |
| set -euo pipefail | |
| STAGE=$(mktemp -d -p /dev/shm opsecstage.XXXXXX) | |
| trap 'rm -rf "$STAGE" 2>/dev/null || true' EXIT | |
| printf '%s' "$GOOGLE_CLIENT_SECRET" > "$STAGE/app_oauth_google_client_secret" | |
| printf '%s' "$APP_SESSION_SECRET" > "$STAGE/app_session_secret" | |
| printf '%s' "$PODCAST_SENTRY_DSN_API" > "$STAGE/podcast_sentry_dsn_api" | |
| chmod 400 "$STAGE"/* | |
| ssh -i "$SSH_PROD_IDENTITY" -o IdentitiesOnly=yes \ | |
| -o StrictHostKeyChecking=accept-new -o BatchMode=yes "$SSH_TARGET" \ | |
| "rm -rf /dev/shm/operator-secrets.staged && mkdir -p /dev/shm/operator-secrets.staged && chmod 700 /dev/shm/operator-secrets.staged" | |
| scp -i "$SSH_PROD_IDENTITY" -o IdentitiesOnly=yes \ | |
| -o StrictHostKeyChecking=accept-new -o BatchMode=yes \ | |
| "$STAGE"/* "${SSH_TARGET}:/dev/shm/operator-secrets.staged/" | |
| ssh -i "$SSH_PROD_IDENTITY" -o IdentitiesOnly=yes \ | |
| -o StrictHostKeyChecking=accept-new -o BatchMode=yes "$SSH_TARGET" \ | |
| "set -euo pipefail; chmod 400 /dev/shm/operator-secrets.staged/*; \ | |
| rm -rf /dev/shm/operator-secrets; \ | |
| mv /dev/shm/operator-secrets.staged /dev/shm/operator-secrets" | |
| - name: Deploy (build + up + vhost drop + reload + health) | |
| if: steps.preflight.outputs.skip != 'true' | |
| env: | |
| SSH_TARGET: deploy@${{ steps.ts_host.outputs.resolved_fqdn }} | |
| # ADR-115 Option A: deploy-operator.sh joins the secrets overlay + runs the exit-5/6 | |
| # gates only when it SEES this flag. It is not in .env.operator and SSH does not | |
| # inherit the runner env, so it MUST be passed inline (same as deploy-prod passes | |
| # PODCAST_SECRETS_VIA_FILES to deploy.sh). Without this the script silently skips | |
| # the overlay while .env.operator has dropped the secrets -> keyless container -> 503. | |
| VIA_FILES: ${{ vars.OPERATOR_SECRETS_VIA_FILES }} | |
| run: | | |
| ssh -i "$SSH_PROD_IDENTITY" -o IdentitiesOnly=yes \ | |
| -o StrictHostKeyChecking=accept-new -o BatchMode=yes "$SSH_TARGET" \ | |
| "OPERATOR_SECRETS_VIA_FILES='${VIA_FILES}' /srv/podcast-scraper/infra/deploy/deploy-operator.sh" | |
| - name: External health probe (public operator domain) | |
| if: steps.preflight.outputs.skip != 'true' | |
| env: | |
| # Probe the public ROOT (edge + Cloudflare + TLS reachability), NOT an app path. | |
| # During the pre-launch coming-soon phase the edge gate returns the coming-soon | |
| # page (200) for every path incl. /api/app/*, so an app-path probe validates | |
| # nothing extra; and the backend itself is already validated in-container by the | |
| # deploy step's /api/health check. A 200 here means DNS+CF+origin-lock+TLS+edge | |
| # are all serving. | |
| PROBE: https://${{ vars.OPERATOR_DOMAIN || 'operator.closelistening.app' }}/ | |
| run: | | |
| set -euo pipefail | |
| for _ in $(seq 1 12); do | |
| code=$(curl -s -o /dev/null -w '%{http_code}' "$PROBE" || echo 000) | |
| if [ "$code" = "200" ]; then echo "operator public edge OK ($PROBE)"; exit 0; fi | |
| sleep 5 | |
| done | |
| echo "::error::public operator edge probe did not return 200 ($PROBE)"; exit 1 | |
| - name: Emit deploy event to VictoriaLogs (Tier-1 o11y, ADR-119) | |
| # Self-hosted sink over the tailnet (runner already joined above). Fires on | |
| # success AND failure. Non-fatal — never turns a deploy red. | |
| if: always() && steps.preflight.outputs.skip != 'true' | |
| uses: ./.github/actions/emit-ops-event | |
| with: | |
| event-type: deploy | |
| env: prod | |
| victorialogs-url: ${{ vars.HOMELAB_VICTORIALOGS_URL }} | |
| msg: "operator deploy ${{ job.status }}" | |
| fields: status=${{ job.status }} surface=operator triggered_by=${{ github.actor }} |