Skip to content

Deploy operator — PUBLIC surface #10

Deploy operator — PUBLIC surface

Deploy operator — PUBLIC surface #10

# 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 }}