Skip to content

Retire the paid status monitor and its deployment #1531

Retire the paid status monitor and its deployment

Retire the paid status monitor and its deployment #1531

Workflow file for this run

name: Modtale CI/CD
on:
push:
branches:
- main
- develop
- '**'
delete:
workflow_dispatch:
inputs:
prepare_preview_source:
type: boolean
default: false
required: false
cleanup_branches:
type: string
default: '[]'
required: false
schedule:
- cron: '17 3 * * *'
env:
PROJECT_ID: gen-lang-client-0244308719
REGION: us-central1
jobs:
deploy:
concurrency:
group: modtale-deploy-${{ github.ref }}
cancel-in-progress: false
if: github.repository == 'Modtale/modtale' && github.event_name == 'push' && github.event.deleted != true
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'main' && 'production' || github.ref_name == 'develop' && 'develop' || 'branch-preview' }}
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_R2_PROVISIONER: ${{ secrets.CLOUDFLARE_R2_PROVISIONER }}
CLOUDFLARE_API_TOKEN_PROVISIONER: ${{ secrets.CLOUDFLARE_API_TOKEN_PROVISIONER }}
CLOUDFLARE_API_TOKEN_OWNER: ${{ vars.CLOUDFLARE_API_TOKEN_OWNER }}
CLOUDFLARE_R2_JURISDICTION: ${{ vars.CLOUDFLARE_R2_JURISDICTION }}
permissions:
contents: 'read'
actions: 'read'
id-token: 'write'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve last successful deployment
id: previous_deploy
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
previous=$(gh run list --workflow ci-cd.yml --branch "$GITHUB_REF_NAME" --event push --status success --limit 1 --json headSha --jq '.[0].headSha // empty')
echo "sha=${previous:-${{ github.event.before }}}" >> "$GITHUB_OUTPUT"
- name: Detect changed components
id: filter
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE_SHA: ${{ steps.previous_deploy.outputs.sha }}
run: bash .github/scripts/detect-component-changes.sh
- name: Configure Environment
id: config
run: |
BRANCH_NAME=${GITHUB_REF#refs/heads/}
echo "Detected branch: $BRANCH_NAME"
BRANCH_SLUG=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9-]/-/g' | tr '[:upper:]' '[:lower:]' | sed 's/--*/-/g' | cut -c 1-20 | sed 's/^-//;s/-$//')
if [ -z "$BRANCH_SLUG" ]; then
BRANCH_SLUG="${GITHUB_SHA:0:7}"
fi
echo "GIT_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
echo "BRANCH_SLUG=$BRANCH_SLUG" >> $GITHUB_ENV
if [ "$BRANCH_NAME" = "main" ]; then
echo "ENV_TYPE=prod" >> $GITHUB_ENV
echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV
echo "RUNTIME_SERVICE_ACCOUNT=${{ vars.GCP_PROD_RUNTIME_SERVICE_ACCOUNT }}" >> $GITHUB_ENV
echo "BACKEND_SERVICE=modtale-backend" >> $GITHUB_ENV
echo "FRONTEND_SERVICE=modtale-frontend" >> $GITHUB_ENV
echo "DB_NAME=modtale" >> $GITHUB_ENV
echo "SEEDING_ENABLED=false" >> $GITHUB_ENV
echo "SEEDING_MODE=mock" >> $GITHUB_ENV
echo "SEEDING_RESET=false" >> $GITHUB_ENV
echo "SEEDING_SOURCE_DB=modtale-mock-template" >> $GITHUB_ENV
echo "SEEDING_SOURCE_R2_BUCKET_NAME=" >> $GITHUB_ENV
echo "MONGODB_SECRET_NAME=MONGODB_URI" >> $GITHUB_ENV
echo "BACKEND_DOMAIN=https://api.modtale.net" >> $GITHUB_ENV
echo "FRONTEND_DOMAIN=https://modtale.net" >> $GITHUB_ENV
echo "TAG=latest" >> $GITHUB_ENV
echo "R2_BUCKET_NAME=modtale-binaries" >> $GITHUB_ENV
echo "R2_ACCESS_KEY_SECRET_NAME=R2_ACCESS_KEY" >> $GITHUB_ENV
echo "R2_SECRET_KEY_SECRET_NAME=R2_SECRET_KEY" >> $GITHUB_ENV
echo "R2_ENDPOINT_SECRET_NAME=R2_ENDPOINT" >> $GITHUB_ENV
echo "R2_PUBLIC_DOMAIN=https://cdn.modtale.net" >> $GITHUB_ENV
echo "WARDEN_ENABLED=true" >> $GITHUB_ENV
echo "OAUTH_ENABLED=true" >> $GITHUB_ENV
echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV
elif [ "$BRANCH_NAME" = "develop" ]; then
echo "ENV_TYPE=dev" >> $GITHUB_ENV
echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV
echo "RUNTIME_SERVICE_ACCOUNT=${{ vars.GCP_DEV_RUNTIME_SERVICE_ACCOUNT }}" >> $GITHUB_ENV
echo "BACKEND_SERVICE=modtale-backend-dev" >> $GITHUB_ENV
echo "FRONTEND_SERVICE=modtale-frontend-dev" >> $GITHUB_ENV
echo "DB_NAME=modtale" >> $GITHUB_ENV
echo "SEEDING_ENABLED=false" >> $GITHUB_ENV
echo "SEEDING_MODE=mock" >> $GITHUB_ENV
echo "SEEDING_RESET=false" >> $GITHUB_ENV
echo "SEEDING_SOURCE_DB=modtale-mock-template" >> $GITHUB_ENV
echo "SEEDING_SOURCE_R2_BUCKET_NAME=" >> $GITHUB_ENV
echo "MONGODB_SECRET_NAME=MONGODB_URI" >> $GITHUB_ENV
echo "BACKEND_DOMAIN=https://dev.api.modtale.net" >> $GITHUB_ENV
echo "FRONTEND_DOMAIN=https://dev.modtale.net" >> $GITHUB_ENV
echo "TAG=dev" >> $GITHUB_ENV
echo "R2_BUCKET_NAME=modtale-binaries" >> $GITHUB_ENV
echo "R2_ACCESS_KEY_SECRET_NAME=R2_ACCESS_KEY" >> $GITHUB_ENV
echo "R2_SECRET_KEY_SECRET_NAME=R2_SECRET_KEY" >> $GITHUB_ENV
echo "R2_ENDPOINT_SECRET_NAME=R2_ENDPOINT" >> $GITHUB_ENV
echo "R2_PUBLIC_DOMAIN=https://cdn.modtale.net" >> $GITHUB_ENV
echo "WARDEN_ENABLED=true" >> $GITHUB_ENV
echo "OAUTH_ENABLED=true" >> $GITHUB_ENV
echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV
else
SLUG="$BRANCH_SLUG"
BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME="${{ vars.GCP_BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME }}"
if [ -z "$BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME" ]; then
BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME="${{ vars.GCP_PREVIEW_SOURCE_R2_BUCKET_NAME }}"
fi
if [ -z "$BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME" ]; then
BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME="modtale-preview-template"
fi
BRANCH_PREVIEW_SOURCE_R2_ACCESS_KEY_SECRET_NAME="${{ vars.GCP_BRANCH_PREVIEW_SOURCE_R2_ACCESS_KEY_SECRET_NAME }}"
if [ -z "$BRANCH_PREVIEW_SOURCE_R2_ACCESS_KEY_SECRET_NAME" ]; then
BRANCH_PREVIEW_SOURCE_R2_ACCESS_KEY_SECRET_NAME="${{ vars.GCP_PREVIEW_SOURCE_R2_ACCESS_KEY_SECRET_NAME }}"
fi
if [ -z "$BRANCH_PREVIEW_SOURCE_R2_ACCESS_KEY_SECRET_NAME" ]; then
BRANCH_PREVIEW_SOURCE_R2_ACCESS_KEY_SECRET_NAME="R2_TEMPLATE_READ_ACCESS_KEY"
fi
BRANCH_PREVIEW_SOURCE_R2_SECRET_KEY_SECRET_NAME="${{ vars.GCP_BRANCH_PREVIEW_SOURCE_R2_SECRET_KEY_SECRET_NAME }}"
if [ -z "$BRANCH_PREVIEW_SOURCE_R2_SECRET_KEY_SECRET_NAME" ]; then
BRANCH_PREVIEW_SOURCE_R2_SECRET_KEY_SECRET_NAME="${{ vars.GCP_PREVIEW_SOURCE_R2_SECRET_KEY_SECRET_NAME }}"
fi
if [ -z "$BRANCH_PREVIEW_SOURCE_R2_SECRET_KEY_SECRET_NAME" ]; then
BRANCH_PREVIEW_SOURCE_R2_SECRET_KEY_SECRET_NAME="R2_TEMPLATE_READ_SECRET_KEY"
fi
BRANCH_PREVIEW_SOURCE_R2_ENDPOINT_SECRET_NAME="${{ vars.GCP_BRANCH_PREVIEW_SOURCE_R2_ENDPOINT_SECRET_NAME }}"
if [ -z "$BRANCH_PREVIEW_SOURCE_R2_ENDPOINT_SECRET_NAME" ]; then
BRANCH_PREVIEW_SOURCE_R2_ENDPOINT_SECRET_NAME="${{ vars.GCP_PREVIEW_SOURCE_R2_ENDPOINT_SECRET_NAME }}"
fi
if [ -z "$BRANCH_PREVIEW_SOURCE_R2_ENDPOINT_SECRET_NAME" ]; then
BRANCH_PREVIEW_SOURCE_R2_ENDPOINT_SECRET_NAME="R2_TEMPLATE_READ_ENDPOINT"
fi
echo "ENV_TYPE=preview" >> $GITHUB_ENV
echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV
echo "BACKEND_SERVICE=modtale-backend-$SLUG" >> $GITHUB_ENV
echo "FRONTEND_SERVICE=modtale-frontend-$SLUG" >> $GITHUB_ENV
echo "DB_NAME=modtale-$SLUG" >> $GITHUB_ENV
echo "SEEDING_ENABLED=true" >> $GITHUB_ENV
echo "SEEDING_MODE=template" >> $GITHUB_ENV
echo "SEEDING_RESET=false" >> $GITHUB_ENV
echo "SEEDING_SOURCE_DB=modtale-mock-template" >> $GITHUB_ENV
echo "SEEDING_SOURCE_R2_BUCKET_NAME=$BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME" >> $GITHUB_ENV
echo "MONGODB_SECRET_NAME=BRANCH_PREVIEW_MONGODB_URI" >> $GITHUB_ENV
echo "TAG=$SLUG" >> $GITHUB_ENV
echo "R2_BUCKET_NAME=modtale-branch-$SLUG" >> $GITHUB_ENV
echo "R2_ACCESS_KEY_SECRET_NAME=branch-preview-$SLUG-r2-access-key" >> $GITHUB_ENV
echo "R2_SECRET_KEY_SECRET_NAME=branch-preview-$SLUG-r2-secret-key" >> $GITHUB_ENV
echo "R2_ENDPOINT_SECRET_NAME=branch-preview-$SLUG-r2-endpoint" >> $GITHUB_ENV
echo "R2_TOKEN_ID_SECRET_NAME=branch-preview-$SLUG-r2-token-id" >> $GITHUB_ENV
echo "R2_TOKEN_NAME=modtale-branch-$SLUG-runtime" >> $GITHUB_ENV
echo "RUNTIME_SERVICE_ACCOUNT=${{ vars.GCP_BRANCH_PREVIEW_RUNTIME_SERVICE_ACCOUNT }}" >> $GITHUB_ENV
echo "SEEDING_SOURCE_R2_ACCESS_KEY_SECRET_NAME=$BRANCH_PREVIEW_SOURCE_R2_ACCESS_KEY_SECRET_NAME" >> $GITHUB_ENV
echo "SEEDING_SOURCE_R2_SECRET_KEY_SECRET_NAME=$BRANCH_PREVIEW_SOURCE_R2_SECRET_KEY_SECRET_NAME" >> $GITHUB_ENV
echo "SEEDING_SOURCE_R2_ENDPOINT_SECRET_NAME=$BRANCH_PREVIEW_SOURCE_R2_ENDPOINT_SECRET_NAME" >> $GITHUB_ENV
echo "R2_PUBLIC_DOMAIN=" >> $GITHUB_ENV
echo "WARDEN_ENABLED=false" >> $GITHUB_ENV
echo "OAUTH_ENABLED=false" >> $GITHUB_ENV
fi
- id: 'auth'
uses: 'google-github-actions/auth@v2'
with:
credentials_json: '${{ secrets.GCP_CREDENTIALS }}'
- name: 'Set up Cloud SDK'
uses: 'google-github-actions/setup-gcloud@v2'
with:
install_components: 'beta'
- name: Ensure Branch R2 Bucket
if: env.ENV_TYPE == 'preview'
run: |
set -euo pipefail
: "${R2_BUCKET_NAME:?R2_BUCKET_NAME must be configured for preview branch deployments.}"
: "${CLOUDFLARE_ACCOUNT_ID:?Set secret CLOUDFLARE_ACCOUNT_ID.}"
: "${CLOUDFLARE_R2_PROVISIONER:?Set secret CLOUDFLARE_R2_PROVISIONER.}"
: "${RUNTIME_SERVICE_ACCOUNT:?Set environment variable GCP_BRANCH_PREVIEW_RUNTIME_SERVICE_ACCOUNT in the branch-preview GitHub environment.}"
mask_secret() {
local value="${1:-}"
if [ -n "$value" ]; then
echo "::add-mask::$value"
fi
}
if [ "${MONGODB_SECRET_NAME:-}" = "MONGODB_URI" ]; then
echo "::error::Branch previews must use BRANCH_PREVIEW_MONGODB_URI, not production MONGODB_URI."
exit 1
fi
if [ "$SEEDING_SOURCE_R2_BUCKET_NAME" = "$R2_BUCKET_NAME" ]; then
echo "::error::Branch previews must seed from a source R2 bucket that differs from their per-branch target bucket."
exit 1
fi
if [ "$SEEDING_SOURCE_R2_BUCKET_NAME" = "modtale-binaries" ]; then
echo "::warning::Branch preview is seeding from modtale-binaries. Set GCP_BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME and matching source secret-name vars to use a sanitized preview source bucket."
fi
source_r2_access_key="$(gcloud secrets versions access latest --project "$PROJECT_ID" --secret="$SEEDING_SOURCE_R2_ACCESS_KEY_SECRET_NAME")"
source_r2_secret_key="$(gcloud secrets versions access latest --project "$PROJECT_ID" --secret="$SEEDING_SOURCE_R2_SECRET_KEY_SECRET_NAME")"
source_r2_endpoint="$(gcloud secrets versions access latest --project "$PROJECT_ID" --secret="$SEEDING_SOURCE_R2_ENDPOINT_SECRET_NAME")"
source_r2_endpoint="$(printf '%s' "$source_r2_endpoint" | sed -E 's#(https?://[^/]+).*#\1#')"
mask_secret "$source_r2_access_key"
mask_secret "$source_r2_secret_key"
if ! command -v aws >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y awscli
fi
AWS_ACCESS_KEY_ID="$source_r2_access_key" \
AWS_SECRET_ACCESS_KEY="$source_r2_secret_key" \
AWS_DEFAULT_REGION=auto \
aws s3api head-bucket \
--endpoint-url "$source_r2_endpoint" \
--bucket "$SEEDING_SOURCE_R2_BUCKET_NAME" >/dev/null
existing_token_id="$(
gcloud secrets versions access latest \
--project "$PROJECT_ID" \
--secret="$R2_TOKEN_ID_SECRET_NAME" 2>/dev/null || true
)"
mask_secret "$existing_token_id"
latest_secret_version() {
local name="$1"
gcloud secrets versions list "$name" \
--project "$PROJECT_ID" \
--filter="state=ENABLED" \
--sort-by="~createTime" \
--limit=1 \
--format="value(name)"
}
ensure_secret_accessor() {
local name="$1"
gcloud secrets add-iam-policy-binding "$name" \
--project "$PROJECT_ID" \
--member="serviceAccount:$RUNTIME_SERVICE_ACCOUNT" \
--role="roles/secretmanager.secretAccessor" \
--quiet >/dev/null
}
write_secret() {
local name="$1"
local value="$2"
if gcloud secrets describe "$name" --project "$PROJECT_ID" >/dev/null 2>&1; then
printf '%s' "$value" | gcloud secrets versions add "$name" \
--project "$PROJECT_ID" \
--data-file=- >/dev/null
else
printf '%s' "$value" | gcloud secrets create "$name" \
--project "$PROJECT_ID" \
--replication-policy=automatic \
--data-file=- >/dev/null
fi
ensure_secret_accessor "$name"
latest_secret_version "$name"
}
existing_r2_access_key="$(
gcloud secrets versions access latest \
--project "$PROJECT_ID" \
--secret="$R2_ACCESS_KEY_SECRET_NAME" 2>/dev/null || true
)"
existing_r2_secret_key="$(
gcloud secrets versions access latest \
--project "$PROJECT_ID" \
--secret="$R2_SECRET_KEY_SECRET_NAME" 2>/dev/null || true
)"
existing_r2_endpoint="$(
gcloud secrets versions access latest \
--project "$PROJECT_ID" \
--secret="$R2_ENDPOINT_SECRET_NAME" 2>/dev/null || true
)"
existing_r2_endpoint="$(printf '%s' "$existing_r2_endpoint" | sed -E 's#(https?://[^/]+).*#\1#')"
mask_secret "$existing_r2_access_key"
mask_secret "$existing_r2_secret_key"
runtime_credentials_valid=false
if [ -n "$existing_r2_access_key" ] && \
[ -n "$existing_r2_secret_key" ] && \
[ -n "$existing_r2_endpoint" ]; then
check_file="$(mktemp)"
check_key=".modtale-preview/credential-check-${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-0}"
printf 'ok' > "$check_file"
if AWS_ACCESS_KEY_ID="$existing_r2_access_key" \
AWS_SECRET_ACCESS_KEY="$existing_r2_secret_key" \
AWS_DEFAULT_REGION=auto \
aws s3api put-object \
--endpoint-url "$existing_r2_endpoint" \
--bucket "$R2_BUCKET_NAME" \
--key "$check_key" \
--body "$check_file" >/dev/null && \
AWS_ACCESS_KEY_ID="$existing_r2_access_key" \
AWS_SECRET_ACCESS_KEY="$existing_r2_secret_key" \
AWS_DEFAULT_REGION=auto \
aws s3api head-object \
--endpoint-url "$existing_r2_endpoint" \
--bucket "$R2_BUCKET_NAME" \
--key "$check_key" >/dev/null; then
runtime_credentials_valid=true
fi
AWS_ACCESS_KEY_ID="$existing_r2_access_key" \
AWS_SECRET_ACCESS_KEY="$existing_r2_secret_key" \
AWS_DEFAULT_REGION=auto \
aws s3api delete-object \
--endpoint-url "$existing_r2_endpoint" \
--bucket "$R2_BUCKET_NAME" \
--key "$check_key" >/dev/null 2>&1 || true
rm -f "$check_file"
fi
if [ "$runtime_credentials_valid" = "true" ]; then
echo "Reusing existing branch preview R2 runtime credentials."
ensure_secret_accessor "$R2_ACCESS_KEY_SECRET_NAME"
ensure_secret_accessor "$R2_SECRET_KEY_SECRET_NAME"
ensure_secret_accessor "$R2_ENDPOINT_SECRET_NAME"
if gcloud secrets describe "$R2_TOKEN_ID_SECRET_NAME" --project "$PROJECT_ID" >/dev/null 2>&1; then
ensure_secret_accessor "$R2_TOKEN_ID_SECRET_NAME"
R2_TOKEN_ID_SECRET_VERSION="$(latest_secret_version "$R2_TOKEN_ID_SECRET_NAME")"
else
R2_TOKEN_ID_SECRET_VERSION=""
fi
R2_ACCESS_KEY_SECRET_VERSION="$(latest_secret_version "$R2_ACCESS_KEY_SECRET_NAME")"
R2_SECRET_KEY_SECRET_VERSION="$(latest_secret_version "$R2_SECRET_KEY_SECRET_NAME")"
R2_ENDPOINT_SECRET_VERSION="$(latest_secret_version "$R2_ENDPOINT_SECRET_NAME")"
R2_RUNTIME_CREDENTIALS_REPLACED=false
else
echo "Existing branch preview R2 runtime credentials are missing or invalid; provisioning a replacement."
: "${CLOUDFLARE_API_TOKEN_PROVISIONER:?Set secret CLOUDFLARE_API_TOKEN_PROVISIONER.}"
export EXISTING_R2_RUNTIME_TOKEN_ID="$existing_token_id"
runtime_env_file="$(mktemp)"
trap 'rm -f "$runtime_env_file"' EXIT
export R2_RUNTIME_ENV_FILE="$runtime_env_file"
node .github/scripts/cloudflare-r2-preview.mjs provision
set -a
. "$runtime_env_file"
set +a
R2_ACCESS_KEY_SECRET_VERSION="$(write_secret "$R2_ACCESS_KEY_SECRET_NAME" "$R2_RUNTIME_ACCESS_KEY")"
R2_SECRET_KEY_SECRET_VERSION="$(write_secret "$R2_SECRET_KEY_SECRET_NAME" "$R2_RUNTIME_SECRET_KEY")"
R2_ENDPOINT_SECRET_VERSION="$(write_secret "$R2_ENDPOINT_SECRET_NAME" "$R2_RUNTIME_ENDPOINT")"
R2_TOKEN_ID_SECRET_VERSION="$(write_secret "$R2_TOKEN_ID_SECRET_NAME" "$R2_RUNTIME_TOKEN_ID")"
R2_RUNTIME_CREDENTIALS_REPLACED=true
fi
echo "R2_ACCESS_KEY_SECRET_VERSION=$R2_ACCESS_KEY_SECRET_VERSION" >> "$GITHUB_ENV"
echo "R2_SECRET_KEY_SECRET_VERSION=$R2_SECRET_KEY_SECRET_VERSION" >> "$GITHUB_ENV"
echo "R2_ENDPOINT_SECRET_VERSION=$R2_ENDPOINT_SECRET_VERSION" >> "$GITHUB_ENV"
echo "R2_TOKEN_ID_SECRET_VERSION=$R2_TOKEN_ID_SECRET_VERSION" >> "$GITHUB_ENV"
echo "R2_RUNTIME_CREDENTIALS_REPLACED=$R2_RUNTIME_CREDENTIALS_REPLACED" >> "$GITHUB_ENV"
- name: Copy missing preview fixtures on the GitHub runner
if: env.ENV_TYPE == 'preview'
run: python3 .github/scripts/sync-preview-r2.py
- name: Check Existing Services
id: check_services
run: |
# Check backend service
if gcloud run services describe ${{ env.BACKEND_SERVICE }} --region ${{ env.REGION }} >/dev/null 2>&1; then
echo "BACKEND_EXISTS=true" >> $GITHUB_ENV
B_URL=$(gcloud run services describe ${{ env.BACKEND_SERVICE }} --region ${{ env.REGION }} --format="value(status.url)")
echo "EXISTING_BACKEND_URL=$B_URL" >> $GITHUB_ENV
echo "Backend service already exists: $B_URL"
else
echo "BACKEND_EXISTS=false" >> $GITHUB_ENV
echo "Backend service does not exist."
fi
# Check frontend service
if gcloud run services describe ${{ env.FRONTEND_SERVICE }} --region ${{ env.REGION }} >/dev/null 2>&1; then
echo "FRONTEND_EXISTS=true" >> $GITHUB_ENV
F_URL=$(gcloud run services describe ${{ env.FRONTEND_SERVICE }} --region ${{ env.REGION }} --format="value(status.url)")
echo "EXISTING_FRONTEND_URL=$F_URL" >> $GITHUB_ENV
echo "Frontend service already exists: $F_URL"
else
echo "FRONTEND_EXISTS=false" >> $GITHUB_ENV
echo "Frontend service does not exist."
fi
- name: Build and Deploy Backend
id: deploy_backend
if: steps.filter.outputs.backend == 'true' || env.BACKEND_EXISTS == 'false' || env.R2_RUNTIME_CREDENTIALS_REPLACED == 'true'
run: |
cd backend
: "${BUILD_SERVICE_ACCOUNT:?Set GitHub environment variable GCP_BUILD_SERVICE_ACCOUNT.}"
: "${RUNTIME_SERVICE_ACCOUNT:?Set the GitHub environment runtime service account variable for this environment.}"
WARDEN_URL_VALUE=""
if [ "${{ env.ENV_TYPE }}" = "prod" ] || [ "${{ env.ENV_TYPE }}" = "dev" ]; then
WARDEN_URL_VALUE="$(
gcloud secrets versions access latest \
--project "$PROJECT_ID" \
--secret WARDEN_URL
)"
if [ -z "$WARDEN_URL_VALUE" ]; then
echo "::error::Secret WARDEN_URL is empty or unavailable."
exit 1
fi
echo "::add-mask::$WARDEN_URL_VALUE"
fi
# Rotating preview credentials needs a new Cloud Run revision, but the
# application image is unchanged. Avoid paying to compile it again.
if [ "${{ steps.filter.outputs.backend }}" = "true" ] || [ "$BACKEND_EXISTS" = "false" ]; then
bash ../.github/scripts/build-container.sh backend "$TAG"
else
echo "Reusing the existing backend image for a configuration-only rollout."
fi
ARGS=(
"--image" "gcr.io/$PROJECT_ID/modtale-backend:${{ env.TAG }}"
"--region" "$REGION"
"--service-account" "$RUNTIME_SERVICE_ACCOUNT"
"--allow-unauthenticated"
"--use-http2"
"--memory" "1Gi"
"--concurrency" "80"
"--update-env-vars" "MONGODB_DATABASE_NAME=${{ env.DB_NAME }}"
"--update-env-vars" "APP_SEEDING_ENABLED=${{ env.SEEDING_ENABLED }}"
"--update-env-vars" "APP_SEEDING_MODE=${{ env.SEEDING_MODE }}"
"--update-env-vars" "APP_SEEDING_RESET=${{ env.SEEDING_RESET }}"
"--update-env-vars" "APP_SEEDING_SOURCE_DB=${{ env.SEEDING_SOURCE_DB }}"
"--update-env-vars" "APP_SEEDING_SOURCE_R2_BUCKET_NAME=${{ env.SEEDING_SOURCE_R2_BUCKET_NAME }}"
"--update-env-vars" "WARDEN_ENABLED=${{ env.WARDEN_ENABLED }}"
"--update-env-vars" "OAUTH_ENABLED=${{ env.OAUTH_ENABLED }}"
"--update-env-vars" "FRONTEND_URL=${FRONTEND_DOMAIN:-placeholder}"
"--update-env-vars" "BACKEND_URL=${BACKEND_DOMAIN:-placeholder}"
"--update-env-vars" "R2_BUCKET_NAME=${{ env.R2_BUCKET_NAME }}"
"--update-env-vars" "R2_PUBLIC_DOMAIN=${{ env.R2_PUBLIC_DOMAIN }}"
)
R2_ACCESS_KEY_SECRET_VERSION="${R2_ACCESS_KEY_SECRET_VERSION:-latest}"
R2_SECRET_KEY_SECRET_VERSION="${R2_SECRET_KEY_SECRET_VERSION:-latest}"
R2_ENDPOINT_SECRET_VERSION="${R2_ENDPOINT_SECRET_VERSION:-latest}"
ARGS+=("--update-secrets" "MONGODB_URI=${{ env.MONGODB_SECRET_NAME }}:latest")
ARGS+=("--update-secrets" "R2_ACCESS_KEY=${{ env.R2_ACCESS_KEY_SECRET_NAME }}:$R2_ACCESS_KEY_SECRET_VERSION")
ARGS+=("--update-secrets" "R2_SECRET_KEY=${{ env.R2_SECRET_KEY_SECRET_NAME }}:$R2_SECRET_KEY_SECRET_VERSION")
ARGS+=("--update-secrets" "R2_ENDPOINT=${{ env.R2_ENDPOINT_SECRET_NAME }}:$R2_ENDPOINT_SECRET_VERSION")
if [ "${{ env.ENV_TYPE }}" = "prod" ]; then
# Keep one production instance warm to prevent user-facing cold starts.
ARGS+=("--cpu" "1")
ARGS+=("--min-instances" "1")
ARGS+=("--max-instances" "10")
ARGS+=("--cpu-throttling")
ARGS+=("--update-env-vars" "WARDEN_URL=$WARDEN_URL_VALUE")
ARGS+=("--update-secrets" "SMTP_HOST=SMTP_HOST:latest")
ARGS+=("--update-secrets" "SMTP_USERNAME=SMTP_USERNAME:latest")
ARGS+=("--update-secrets" "SMTP_PASSWORD=SMTP_PASSWORD:latest")
ARGS+=("--update-secrets" "PRE_AUTH_SECRET=PRE_AUTH_SECRET:latest")
ARGS+=("--update-secrets" "GITHUB_CLIENT_ID=GITHUB_CLIENT_ID:latest")
ARGS+=("--update-secrets" "GITHUB_CLIENT_SECRET=GITHUB_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID:latest")
ARGS+=("--update-secrets" "GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "GITLAB_CLIENT_ID=GITLAB_CLIENT_ID:latest")
ARGS+=("--update-secrets" "GITLAB_CLIENT_SECRET=GITLAB_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "DISCORD_CLIENT_ID=DISCORD_CLIENT_ID:latest")
ARGS+=("--update-secrets" "DISCORD_CLIENT_SECRET=DISCORD_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "TWITTER_CLIENT_ID=TWITTER_CLIENT_ID:latest")
ARGS+=("--update-secrets" "TWITTER_CLIENT_SECRET=TWITTER_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "BLUESKY_CLIENT_ID=BLUESKY_CLIENT_ID:latest")
ARGS+=("--update-secrets" "WARDEN_API_KEY=${{ env.WARDEN_SECRET_NAME }}:latest")
ARGS+=("--update-secrets" "WEBHOOK_URL=WEBHOOK_URL:latest")
ARGS+=("--update-secrets" "HYTALEMODDING_KEY=HYTALEMODDING_KEY:latest")
ARGS+=("--update-secrets" "WIKI_API_KEY=WIKI_API_KEY:latest")
ARGS+=("--update-secrets" "DISCORD_WEBHOOK_URL=DISCORD_WEBHOOK_URL:latest")
ARGS+=("--update-secrets" "ADMIN_DISCORD_WEBHOOK_URL=ADMIN_DISCORD_WEBHOOK_URL:latest")
# Status monitoring has been retired.
ARGS+=("--remove-secrets" "STATUS_DISCORD_WEBHOOK_URL")
elif [ "${{ env.ENV_TYPE }}" = "dev" ]; then
ARGS+=("--cpu" "1")
ARGS+=("--min-instances" "0")
ARGS+=("--max-instances" "3")
ARGS+=("--cpu-throttling")
ARGS+=("--update-env-vars" "WARDEN_URL=$WARDEN_URL_VALUE")
ARGS+=("--update-secrets" "SMTP_HOST=SMTP_HOST:latest")
ARGS+=("--update-secrets" "SMTP_USERNAME=SMTP_USERNAME:latest")
ARGS+=("--update-secrets" "SMTP_PASSWORD=SMTP_PASSWORD:latest")
ARGS+=("--update-secrets" "PRE_AUTH_SECRET=PRE_AUTH_SECRET:latest")
ARGS+=("--update-secrets" "GITHUB_CLIENT_ID=GITHUB_CLIENT_ID:latest")
ARGS+=("--update-secrets" "GITHUB_CLIENT_SECRET=GITHUB_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID:latest")
ARGS+=("--update-secrets" "GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "GITLAB_CLIENT_ID=GITLAB_CLIENT_ID:latest")
ARGS+=("--update-secrets" "GITLAB_CLIENT_SECRET=GITLAB_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "DISCORD_CLIENT_ID=DISCORD_CLIENT_ID:latest")
ARGS+=("--update-secrets" "DISCORD_CLIENT_SECRET=DISCORD_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "TWITTER_CLIENT_ID=TWITTER_CLIENT_ID:latest")
ARGS+=("--update-secrets" "TWITTER_CLIENT_SECRET=TWITTER_CLIENT_SECRET:latest")
ARGS+=("--update-secrets" "BLUESKY_CLIENT_ID=BLUESKY_CLIENT_ID:latest")
ARGS+=("--update-secrets" "WARDEN_API_KEY=${{ env.WARDEN_SECRET_NAME }}:latest")
ARGS+=("--update-secrets" "WIKI_API_KEY=WIKI_API_KEY:latest")
ARGS+=("--remove-secrets" "STATUS_DISCORD_WEBHOOK_URL")
else
: "${RUNTIME_SERVICE_ACCOUNT:?Set environment variable GCP_BRANCH_PREVIEW_RUNTIME_SERVICE_ACCOUNT in the branch-preview GitHub environment.}"
# Preview services are usually idle. Request-based CPU billing avoids
# charging for CPU between requests without changing request capacity.
ARGS+=("--cpu" "1")
ARGS+=("--min-instances" "0")
ARGS+=("--max-instances" "2")
ARGS+=("--cpu-throttling")
ARGS+=("--startup-probe" "httpGet.path=/actuator/health/readiness,httpGet.port=8080,periodSeconds=10,timeoutSeconds=10,failureThreshold=60")
ARGS+=("--update-env-vars" "LOGGING_STRUCTURED_FORMAT_CONSOLE=logstash")
ARGS+=("--update-env-vars" "LOGGING_STRUCTURED_JSON_RENAME_LEVEL=severity")
ARGS+=("--update-env-vars" "LOGGING_STRUCTURED_JSON_ADD_SERVICE_NAME=${{ env.BACKEND_SERVICE }}")
ARGS+=("--update-env-vars" "LOGGING_STRUCTURED_JSON_ADD_COMPONENT=backend")
ARGS+=("--update-env-vars" "LOGGING_STRUCTURED_JSON_ADD_ENVIRONMENT=branch-preview")
ARGS+=("--update-env-vars" "LOGGING_STRUCTURED_JSON_ADD_BRANCH=${{ env.BRANCH_SLUG }}")
ARGS+=("--labels" "app=modtale,environment=branch-preview,component=backend,branch=${{ env.BRANCH_SLUG }}")
ARGS+=("--update-secrets" "APP_SEEDING_SOURCE_R2_ACCESS_KEY=${{ env.SEEDING_SOURCE_R2_ACCESS_KEY_SECRET_NAME }}:latest")
ARGS+=("--update-secrets" "APP_SEEDING_SOURCE_R2_SECRET_KEY=${{ env.SEEDING_SOURCE_R2_SECRET_KEY_SECRET_NAME }}:latest")
ARGS+=("--update-secrets" "APP_SEEDING_SOURCE_R2_ENDPOINT=${{ env.SEEDING_SOURCE_R2_ENDPOINT_SECRET_NAME }}:latest")
ARGS+=("--remove-env-vars" "SMTP_HOST,SMTP_PORT,SMTP_USERNAME,SMTP_PASSWORD,PRE_AUTH_SECRET,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GITLAB_CLIENT_ID,GITLAB_CLIENT_SECRET,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,TWITTER_CLIENT_ID,TWITTER_CLIENT_SECRET,BLUESKY_CLIENT_ID,BLUESKY_CLIENT_SECRET,WARDEN_URL,WARDEN_API_KEY,WIKI_API_KEY,WEBHOOK_URL,HYTALEMODDING_KEY,DISCORD_WEBHOOK_URL,ADMIN_DISCORD_WEBHOOK_URL")
ARGS+=("--remove-secrets" "STATUS_DISCORD_WEBHOOK_URL")
fi
gcloud run deploy ${{ env.BACKEND_SERVICE }} "${ARGS[@]}" --format="value(status.url)" > backend_url.txt
if [ "${{ env.ENV_TYPE }}" = "prod" ]; then
BACKEND_REVISION=$(gcloud run services describe "$BACKEND_SERVICE" --region "$REGION" --format="value(status.latestReadyRevisionName)")
echo "revision=$BACKEND_REVISION" >> "$GITHUB_OUTPUT"
fi
- name: Determine API URL
run: |
if [ -f backend/backend_url.txt ]; then
BACKEND_SERVICE_URL=$(cat backend/backend_url.txt)
else
BACKEND_SERVICE_URL=$EXISTING_BACKEND_URL
fi
: "${BACKEND_SERVICE_URL:?Backend service URL was not resolved.}"
if [ "$ENV_TYPE" = prod ]; then
echo "SSR_API_URL=$BACKEND_DOMAIN/api/v1" >> "$GITHUB_ENV"
else
echo "SSR_API_URL=$BACKEND_SERVICE_URL/api/v1" >> "$GITHUB_ENV"
fi
if [ -n "${BACKEND_DOMAIN:-}" ]; then
echo "API_URL=$BACKEND_DOMAIN/api/v1" >> $GITHUB_ENV
else
echo "API_URL=$BACKEND_SERVICE_URL/api/v1" >> $GITHUB_ENV
fi
- name: Build and Deploy Frontend
id: deploy_frontend
if: steps.filter.outputs.frontend == 'true' || env.FRONTEND_EXISTS == 'false'
run: |
cd frontend
: "${BUILD_SERVICE_ACCOUNT:?Set GitHub environment variable GCP_BUILD_SERVICE_ACCOUNT.}"
: "${RUNTIME_SERVICE_ACCOUNT:?Set the GitHub environment runtime service account variable for this environment.}"
bash ../.github/scripts/build-container.sh frontend "$TAG" \
--build-arg "PUBLIC_API_URL=$API_URL" --build-arg "SSR_API_URL=$SSR_API_URL"
FRONTEND_ARGS=(
"--image" "gcr.io/$PROJECT_ID/modtale-frontend:$TAG"
"--region" "$REGION"
"--service-account" "$RUNTIME_SERVICE_ACCOUNT"
"--allow-unauthenticated"
"--cpu" "1"
"--memory" "256Mi"
"--concurrency" "80"
"--cpu-throttling"
"--set-env-vars" "PUBLIC_API_URL=$API_URL"
)
if [ "${{ env.ENV_TYPE }}" = "preview" ]; then
FRONTEND_ARGS+=("--min-instances" "0")
FRONTEND_ARGS+=("--max-instances" "2")
FRONTEND_ARGS+=("--set-env-vars" "LOG_SERVICE_NAME=${{ env.FRONTEND_SERVICE }}")
FRONTEND_ARGS+=("--set-env-vars" "LOG_COMPONENT=frontend")
FRONTEND_ARGS+=("--set-env-vars" "LOG_ENVIRONMENT=branch-preview")
FRONTEND_ARGS+=("--set-env-vars" "LOG_BRANCH=${{ env.BRANCH_SLUG }}")
FRONTEND_ARGS+=("--labels" "app=modtale,environment=branch-preview,component=frontend,branch=${{ env.BRANCH_SLUG }}")
elif [ "${{ env.ENV_TYPE }}" = "dev" ]; then
FRONTEND_ARGS+=("--min-instances" "0")
FRONTEND_ARGS+=("--max-instances" "3")
else
FRONTEND_ARGS+=("--min-instances" "1")
FRONTEND_ARGS+=("--max-instances" "3")
fi
gcloud run deploy "$FRONTEND_SERVICE" "${FRONTEND_ARGS[@]}" --format="value(status.url)" > frontend_url.txt
if [ "${{ env.ENV_TYPE }}" = "prod" ]; then
FRONTEND_REVISION=$(gcloud run services describe "$FRONTEND_SERVICE" --region "$REGION" --format="value(status.latestReadyRevisionName)")
echo "revision=$FRONTEND_REVISION" >> "$GITHUB_OUTPUT"
fi
- name: Notify Admin Bot of Frontend Production Deploy
if: env.ENV_TYPE == 'prod' && steps.deploy_frontend.outputs.revision != ''
env:
ADMIN_DISCORD_WEBHOOK_URL: ${{ secrets.ADMIN_DISCORD_WEBHOOK_URL }}
COMPONENT_NAME: Frontend
SERVICE_NAME: ${{ env.FRONTEND_SERVICE }}
SERVICE_URL: ${{ env.FRONTEND_DOMAIN }}
DEPLOYED_REVISION: ${{ steps.deploy_frontend.outputs.revision }}
run: bash .github/scripts/notify-admin-deploy.sh
- name: Determine Frontend URL
run: |
if [ "${{ env.ENV_TYPE }}" = "preview" ]; then
if [ -f frontend/frontend_url.txt ]; then
echo "FINAL_FRONTEND_URL=$(cat frontend/frontend_url.txt)" >> $GITHUB_ENV
else
echo "FINAL_FRONTEND_URL=${{ env.EXISTING_FRONTEND_URL }}" >> $GITHUB_ENV
fi
else
echo "FINAL_FRONTEND_URL=${{ env.FRONTEND_DOMAIN }}" >> $GITHUB_ENV
fi
- name: Update Backend CORS and Self-Awareness
id: update_backend_self_awareness
# Only preview URLs are unknown during the initial backend deploy. Production
# and develop use their configured domains and avoid a second paid rollout.
if: env.ENV_TYPE == 'preview' && (steps.filter.outputs.backend == 'true' || env.BACKEND_EXISTS == 'false' || steps.filter.outputs.frontend == 'true' || env.FRONTEND_EXISTS == 'false')
run: |
BEFORE_REVISION=$(gcloud run services describe "${{ env.BACKEND_SERVICE }}" --region "$REGION" --format="value(status.latestReadyRevisionName)" 2>/dev/null || true)
if [ -f backend/backend_url.txt ]; then
B_URL=$(cat backend/backend_url.txt)
else
B_URL=${EXISTING_BACKEND_URL:-}
fi
if [ -n "${BACKEND_DOMAIN:-}" ]; then
PUBLIC_BACKEND_URL=$BACKEND_DOMAIN
else
PUBLIC_BACKEND_URL=$B_URL
fi
gcloud run services update ${{ env.BACKEND_SERVICE }} \
--region $REGION \
--update-env-vars "FRONTEND_URL=$FINAL_FRONTEND_URL,BACKEND_URL=$PUBLIC_BACKEND_URL"
if [ "${{ env.ENV_TYPE }}" = "prod" ]; then
BACKEND_REVISION=$(gcloud run services describe "$BACKEND_SERVICE" --region "$REGION" --format="value(status.latestReadyRevisionName)")
if [ "$BACKEND_REVISION" != "$BEFORE_REVISION" ]; then
echo "revision=$BACKEND_REVISION" >> "$GITHUB_OUTPUT"
fi
fi
- name: Notify Admin Bot of Backend Production Deploy
if: env.ENV_TYPE == 'prod' && (steps.update_backend_self_awareness.outputs.revision != '' || steps.deploy_backend.outputs.revision != '')
env:
ADMIN_DISCORD_WEBHOOK_URL: ${{ secrets.ADMIN_DISCORD_WEBHOOK_URL }}
COMPONENT_NAME: Backend
SERVICE_NAME: ${{ env.BACKEND_SERVICE }}
SERVICE_URL: ${{ env.BACKEND_DOMAIN }}
DEPLOYED_REVISION: ${{ steps.update_backend_self_awareness.outputs.revision || steps.deploy_backend.outputs.revision }}
run: bash .github/scripts/notify-admin-deploy.sh
- name: Retain two preview R2 credential versions
if: env.ENV_TYPE == 'preview' && env.R2_RUNTIME_CREDENTIALS_REPLACED == 'true'
run: |
set -euo pipefail
for secret_name in \
"$R2_ACCESS_KEY_SECRET_NAME" \
"$R2_SECRET_KEY_SECRET_NAME" \
"$R2_ENDPOINT_SECRET_NAME" \
"$R2_TOKEN_ID_SECRET_NAME"; do
# Keep the current version plus one rollback version. Delayed
# destruction preserves a seven-day recovery window without letting
# rotated preview credentials accumulate indefinitely.
gcloud secrets update "$secret_name" \
--project "$PROJECT_ID" \
--version-destroy-ttl="604800s" \
--quiet
mapfile -t old_versions < <(
gcloud secrets versions list "$secret_name" \
--project "$PROJECT_ID" \
--filter='state=ENABLED' \
--format='value(name.basename())' \
| sort -rn \
| tail -n +3
)
for version in "${old_versions[@]}"; do
gcloud secrets versions destroy "$version" \
--secret "$secret_name" \
--project "$PROJECT_ID" \
--quiet
done
done
- name: Generate Deployment Summary
if: always()
run: |
PUBLIC_API_URL="${API_URL:-}"
# Define status emojis and labels
if [ "${{ steps.filter.outputs.backend }}" = "true" ] || [ "${{ env.BACKEND_EXISTS }}" = "false" ] || [ "${{ env.R2_RUNTIME_CREDENTIALS_REPLACED }}" = "true" ]; then
if [ "${{ env.R2_RUNTIME_CREDENTIALS_REPLACED }}" = "true" ] && [ "${{ steps.filter.outputs.backend }}" != "true" ] && [ "${{ env.BACKEND_EXISTS }}" != "false" ]; then
BACKEND_STATUS="Deployed (R2 Credentials Replaced)"
elif [ "${{ steps.filter.outcome }}" = "success" ]; then
BACKEND_STATUS="Deployed (Fresh Build)"
else
BACKEND_STATUS="Deployment Failed"
fi
if [ -f backend/backend_url.txt ]; then
B_URL=$(cat backend/backend_url.txt)
else
B_URL="${{ env.EXISTING_BACKEND_URL }}"
fi
else
BACKEND_STATUS="✨ Cached (No Changes / Reused)"
B_URL="${{ env.EXISTING_BACKEND_URL }}"
fi
if [ "${{ steps.filter.outputs.frontend }}" = "true" ] || [ "${{ env.FRONTEND_EXISTS }}" = "false" ]; then
if [ "${{ steps.filter.outcome }}" = "success" ]; then
FRONTEND_STATUS="Deployed (Fresh Build)"
else
FRONTEND_STATUS="Deployment Failed"
fi
if [ -f frontend/frontend_url.txt ]; then
F_URL=$(cat frontend/frontend_url.txt)
else
F_URL="${{ env.EXISTING_FRONTEND_URL }}"
fi
else
FRONTEND_STATUS="✨ Cached (No Changes / Reused)"
F_URL="${{ env.EXISTING_FRONTEND_URL }}"
fi
# Output to GitHub Step Summary
echo "## Modtale Deployment Dashboard" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Environment Context" >> $GITHUB_STEP_SUMMARY
echo "- **Triggered By:** \`${{ github.actor }}\` via \`${{ github.event_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Environment:** \`${{ env.ENV_TYPE }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Component | Status | Target Service | Service URL |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|---|---|" >> $GITHUB_STEP_SUMMARY
if [ -n "$PUBLIC_API_URL" ]; then
echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($PUBLIC_API_URL) |" >> $GITHUB_STEP_SUMMARY
elif [ -n "$B_URL" ]; then
echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($B_URL/api/v1) |" >> $GITHUB_STEP_SUMMARY
else
echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY
fi
if [ -n "$F_URL" ]; then
echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | [App URL]($F_URL) |" >> $GITHUB_STEP_SUMMARY
else
echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY
prepare-preview-source:
concurrency:
group: modtale-template-reader
cancel-in-progress: false
if: github.repository == 'Modtale/modtale' && github.event_name == 'workflow_dispatch' && inputs.prepare_preview_source
runs-on: ubuntu-latest
environment: branch-preview
permissions:
contents: read
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_R2_PROVISIONER: ${{ secrets.CLOUDFLARE_R2_PROVISIONER }}
CLOUDFLARE_API_TOKEN_PROVISIONER: ${{ secrets.CLOUDFLARE_API_TOKEN_PROVISIONER }}
CLOUDFLARE_API_TOKEN_OWNER: ${{ vars.CLOUDFLARE_API_TOKEN_OWNER }}
R2_BUCKET_NAME: modtale-preview-template
R2_TOKEN_NAME: modtale-preview-template-reader
RUNTIME_SERVICE_ACCOUNT: ${{ vars.GCP_BRANCH_PREVIEW_RUNTIME_SERVICE_ACCOUNT }}
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
credentials_json: '${{ secrets.GCP_CREDENTIALS }}'
- uses: google-github-actions/setup-gcloud@v2
- name: Provision isolated template reader
run: |
set -euo pipefail
: "${RUNTIME_SERVICE_ACCOUNT:?Preview runtime service account required}"
if gcloud secrets describe R2_TEMPLATE_READ_ACCESS_KEY --project "$PROJECT_ID" >/dev/null 2>&1; then
echo "Template reader is already provisioned."
exit 0
fi
runtime_env_file=$(mktemp)
trap 'rm -f "$runtime_env_file"' EXIT
export R2_RUNTIME_ENV_FILE="$runtime_env_file"
node .github/scripts/cloudflare-r2-preview.mjs provision-read-only
set -a
. "$runtime_env_file"
set +a
for entry in ACCESS_KEY SECRET_KEY ENDPOINT; do
variable="R2_RUNTIME_$entry"
printf '%s' "${!variable}" | gcloud secrets create "R2_TEMPLATE_READ_$entry" \
--project "$PROJECT_ID" --replication-policy=automatic --data-file=- --quiet
gcloud secrets add-iam-policy-binding "R2_TEMPLATE_READ_$entry" \
--project "$PROJECT_ID" --member="serviceAccount:$RUNTIME_SERVICE_ACCOUNT" \
--role=roles/secretmanager.secretAccessor --quiet >/dev/null
done
find-orphan-previews:
if: github.repository == 'Modtale/modtale' && (github.event_name == 'delete' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
environment: branch-preview
permissions:
contents: read
outputs:
branches: ${{ steps.inventory.outputs.branches }}
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
credentials_json: '${{ secrets.GCP_CREDENTIALS }}'
- uses: google-github-actions/setup-gcloud@v2
- id: inventory
env:
GH_TOKEN: ${{ github.token }}
EXPLICIT_CLEANUP_BRANCHES: ${{ inputs.cleanup_branches || '[]' }}
run: |
set -euo pipefail
branches=$(python3 .github/scripts/find-orphan-previews.py)
echo "branches=$branches" >> "$GITHUB_OUTPUT"
cleanup-branch-preview:
concurrency:
group: modtale-preview-cleanup-${{ matrix.branch }}
cancel-in-progress: false
name: Clean Up Deleted Branch Preview (${{ matrix.branch }})
needs: find-orphan-previews
if: needs.find-orphan-previews.outputs.branches != '[]'
strategy:
fail-fast: false
max-parallel: 3
matrix:
branch: ${{ fromJSON(needs.find-orphan-previews.outputs.branches) }}
runs-on: ubuntu-latest
environment: branch-preview
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_R2_PROVISIONER: ${{ secrets.CLOUDFLARE_R2_PROVISIONER }}
CLOUDFLARE_API_TOKEN_PROVISIONER: ${{ secrets.CLOUDFLARE_API_TOKEN_PROVISIONER }}
CLOUDFLARE_API_TOKEN_OWNER: ${{ vars.CLOUDFLARE_API_TOKEN_OWNER }}
CLOUDFLARE_R2_JURISDICTION: ${{ vars.CLOUDFLARE_R2_JURISDICTION }}
permissions:
contents: read
id-token: write
steps:
- name: Prepare cleanup names
env:
DELETED_BRANCH: ${{ matrix.branch }}
run: |
set -euo pipefail
branch_name="$DELETED_BRANCH"
slug="$(printf '%s' "$branch_name" | sed 's/[^a-zA-Z0-9-]/-/g' | tr '[:upper:]' '[:lower:]' | sed 's/--*/-/g' | cut -c 1-20 | sed 's/^-//;s/-$//')"
if [ -z "$slug" ]; then
deleted_sha="${{ github.event.before }}"
slug="${deleted_sha:0:7}"
fi
if [ -z "$slug" ] || [ "$slug" = "main" ] || [ "$slug" = "develop" ]; then
echo "::error::Refusing to clean up protected or empty branch slug '$slug'."
exit 1
fi
echo "BRANCH_SLUG=$slug" >> "$GITHUB_ENV"
echo "BACKEND_SERVICE=modtale-backend-$slug" >> "$GITHUB_ENV"
echo "FRONTEND_SERVICE=modtale-frontend-$slug" >> "$GITHUB_ENV"
echo "DB_NAME=modtale-$slug" >> "$GITHUB_ENV"
echo "MONGODB_SECRET_NAME=BRANCH_PREVIEW_MONGODB_URI" >> "$GITHUB_ENV"
echo "R2_BUCKET_NAME=modtale-branch-$slug" >> "$GITHUB_ENV"
echo "R2_ACCESS_KEY_SECRET_NAME=branch-preview-$slug-r2-access-key" >> "$GITHUB_ENV"
echo "R2_SECRET_KEY_SECRET_NAME=branch-preview-$slug-r2-secret-key" >> "$GITHUB_ENV"
echo "R2_ENDPOINT_SECRET_NAME=branch-preview-$slug-r2-endpoint" >> "$GITHUB_ENV"
echo "R2_TOKEN_ID_SECRET_NAME=branch-preview-$slug-r2-token-id" >> "$GITHUB_ENV"
echo "IMAGE_TAG=$slug" >> "$GITHUB_ENV"
- name: Check out cleanup scripts
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- id: auth
uses: google-github-actions/auth@v2
with:
credentials_json: '${{ secrets.GCP_CREDENTIALS }}'
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
- name: Verify no live branch owns this preview
env:
GH_TOKEN: ${{ github.token }}
run: python3 .github/scripts/assert-preview-orphan.py "$BRANCH_SLUG"
- name: Delete branch Cloud Run services
run: |
gcloud run services delete "$BACKEND_SERVICE" \
--project "$PROJECT_ID" \
--region "$REGION" \
--quiet || true
gcloud run services delete "$FRONTEND_SERVICE" \
--project "$PROJECT_ID" \
--region "$REGION" \
--quiet || true
- name: Remove branch image tags
run: |
gcloud container images untag "gcr.io/$PROJECT_ID/modtale-backend:$IMAGE_TAG" --quiet || true
gcloud container images untag "gcr.io/$PROJECT_ID/modtale-frontend:$IMAGE_TAG" --quiet || true
- name: Drop branch Mongo database
run: |
set -euo pipefail
case "$DB_NAME" in
modtale-*)
;;
*)
echo "::error::Refusing to drop non-preview Mongo database '$DB_NAME'."
exit 1
;;
esac
mongodb_uri="$(gcloud secrets versions access latest --project "$PROJECT_ID" --secret="$MONGODB_SECRET_NAME")"
echo "::add-mask::$mongodb_uri"
npm install --silent --prefix "$RUNNER_TEMP/mongo-cleanup" mongodb@6
MONGODB_URI="$mongodb_uri" \
NODE_PATH="$RUNNER_TEMP/mongo-cleanup/node_modules" \
node .github/scripts/drop-preview-db.cjs
- name: Delete branch R2 bucket
run: |
set -euo pipefail
mask_secret() {
local value="${1:-}"
if [ -n "$value" ]; then
echo "::add-mask::$value"
fi
}
case "$R2_BUCKET_NAME" in
modtale-branch-*)
;;
*)
echo "::error::Refusing to delete non-branch R2 bucket '$R2_BUCKET_NAME'."
exit 1
;;
esac
r2_access_key="$(gcloud secrets versions access latest --project "$PROJECT_ID" --secret="$R2_ACCESS_KEY_SECRET_NAME" 2>/dev/null || true)"
r2_secret_key="$(gcloud secrets versions access latest --project "$PROJECT_ID" --secret="$R2_SECRET_KEY_SECRET_NAME" 2>/dev/null || true)"
r2_endpoint="$(gcloud secrets versions access latest --project "$PROJECT_ID" --secret="$R2_ENDPOINT_SECRET_NAME" 2>/dev/null || true)"
r2_endpoint="$(printf '%s' "$r2_endpoint" | sed -E 's#(https?://[^/]+).*#\1#')"
mask_secret "$r2_access_key"
mask_secret "$r2_secret_key"
if [ -z "$r2_access_key" ] || [ -z "$r2_secret_key" ] || [ -z "$r2_endpoint" ]; then
echo "Per-branch R2 runtime secrets are missing; skipping object empty step."
elif ! command -v aws >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y awscli
fi
if [ -n "$r2_access_key" ] && \
[ -n "$r2_secret_key" ] && \
[ -n "$r2_endpoint" ] && \
AWS_ACCESS_KEY_ID="$r2_access_key" \
AWS_SECRET_ACCESS_KEY="$r2_secret_key" \
AWS_DEFAULT_REGION=auto \
aws s3api head-bucket \
--endpoint-url "$r2_endpoint" \
--bucket "$R2_BUCKET_NAME" >/dev/null 2>&1; then
AWS_ACCESS_KEY_ID="$r2_access_key" \
AWS_SECRET_ACCESS_KEY="$r2_secret_key" \
AWS_DEFAULT_REGION=auto \
aws s3 rm \
--endpoint-url "$r2_endpoint" \
"s3://$R2_BUCKET_NAME" \
--recursive
else
echo "R2 bucket '$R2_BUCKET_NAME' does not exist; nothing to delete."
fi
runtime_token_id="$(
gcloud secrets versions access latest \
--project "$PROJECT_ID" \
--secret="$R2_TOKEN_ID_SECRET_NAME" 2>/dev/null || true
)"
if [ -z "$runtime_token_id" ]; then
runtime_token_id="$(
gcloud secrets versions access latest \
--project "$PROJECT_ID" \
--secret="$R2_ACCESS_KEY_SECRET_NAME" 2>/dev/null || true
)"
fi
mask_secret "$runtime_token_id"
export R2_RUNTIME_TOKEN_ID="$runtime_token_id"
: "${CLOUDFLARE_ACCOUNT_ID:?Set secret CLOUDFLARE_ACCOUNT_ID.}"
: "${CLOUDFLARE_R2_PROVISIONER:?Set secret CLOUDFLARE_R2_PROVISIONER.}"
: "${CLOUDFLARE_API_TOKEN_PROVISIONER:?Set secret CLOUDFLARE_API_TOKEN_PROVISIONER.}"
node .github/scripts/cloudflare-r2-preview.mjs cleanup
for secret_name in \
"$R2_ACCESS_KEY_SECRET_NAME" \
"$R2_SECRET_KEY_SECRET_NAME" \
"$R2_ENDPOINT_SECRET_NAME" \
"$R2_TOKEN_ID_SECRET_NAME"; do
gcloud secrets delete "$secret_name" \
--project "$PROJECT_ID" \
--quiet || true
done