320 lines
16 KiB
YAML
320 lines
16 KiB
YAML
name: Railway Preview Environment
|
|
|
|
# Manually spin up (or tear down) a short-lived Railway preview environment for a
|
|
# branch/PR, without waiting for a merge. Railway builds each app service FROM SOURCE
|
|
# (docker/Dockerfile.*) so the preview runs the branch's exact code — this is the
|
|
# controlled counterpart to Railway's native PR environments.
|
|
#
|
|
# Prerequisites (one-time):
|
|
# - A Railway project (e.g. "classroomio-staging") whose `staging` environment is
|
|
# fully configured (Postgres, Redis, cio-minio, cio-api, cio-dashboard, cio-jobs).
|
|
# - Repo/Org secret RAILWAY_API_TOKEN -> a Railway ACCOUNT or TEAM token (NOT a
|
|
# project token; env-scoped project tokens can't create environments).
|
|
# - Repo/Org secret RAILWAY_PROJECT_ID -> the target project's id.
|
|
# - Repo/Org variable RAILWAY_WORKSPACE -> the Railway workspace the project lives in
|
|
# (default: "cio projects"); required by `railway link` in non-interactive mode.
|
|
# - Repo/Org variable RAILWAY_BASE_ENV -> the env to duplicate from (default: staging).
|
|
#
|
|
# The DB needs no explicit migration step here: the cio-api image self-migrates on boot
|
|
# (docker/entrypoint-api.sh -> `pnpm --filter @cio/db db:setup`, which runs drizzle
|
|
# migrations + essential seed). The optional `seed` input adds the full demo dataset.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
repo:
|
|
description: "Repo to deploy from (leave blank for classroomio/classroomio; set to e.g. Arun5768/classroomio for a fork)"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
branch:
|
|
description: "Branch (or PR head ref) to deploy into the preview env"
|
|
required: true
|
|
type: string
|
|
action:
|
|
description: "deploy = create/update the preview; destroy = delete it"
|
|
required: true
|
|
type: choice
|
|
default: deploy
|
|
options:
|
|
- deploy
|
|
- destroy
|
|
seed:
|
|
description: "Seed the full demo dataset ([email protected] etc.) after deploy"
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
|
|
# One run per branch at a time; a newer run supersedes an in-flight one.
|
|
concurrency:
|
|
group: railway-preview-${{ inputs.branch }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }}
|
|
RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}
|
|
RAILWAY_WORKSPACE: ${{ vars.RAILWAY_WORKSPACE || 'cio projects' }}
|
|
BASE_ENV: ${{ vars.RAILWAY_BASE_ENV || 'staging' }}
|
|
# The three app services that build from source. Postgres/Redis/cio-minio come along
|
|
# via environment duplication and need no `railway up`.
|
|
APP_SERVICES: "cio-api cio-dashboard cio-jobs"
|
|
|
|
jobs:
|
|
preview:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout target branch
|
|
uses: actions/checkout@v4
|
|
with:
|
|
repository: ${{ inputs.repo != '' && inputs.repo || github.repository }}
|
|
ref: ${{ inputs.branch }}
|
|
|
|
- name: Install Railway CLI
|
|
run: npm install -g @railway/[email protected]
|
|
|
|
- name: Derive a safe environment name
|
|
id: env
|
|
# BRANCH is bound via env (not interpolated into the script) so an attacker-supplied
|
|
# value can't be executed as shell. "feat/foo_bar" -> "pr-feat-foo-bar-<hash>" — the
|
|
# slug alone is lossy (e.g. "foo/bar" and "foo_bar" both slug to "foo-bar"), so a
|
|
# hash of the original branch string disambiguates collisions. Slug is capped at 18
|
|
# chars so "pr-<slug>-<hash>" stays under Railway's ~32-char environment name limit
|
|
# even for long auto-generated branch names — the hash (not the slug) is what
|
|
# actually guarantees uniqueness, so truncating the slug is safe.
|
|
env:
|
|
BRANCH: ${{ inputs.branch }}
|
|
run: |
|
|
slug=$(echo "$BRANCH" | tr '[:upper:]/_.' '[:lower:]---' | sed 's/[^a-z0-9-]//g' | sed 's/--*/-/g; s/^-//; s/-$//' | cut -c1-18 | sed 's/-$//')
|
|
hash=$(echo -n "$BRANCH" | sha256sum | cut -c1-8)
|
|
echo "name=pr-${slug}-${hash}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Link to base project
|
|
# `railway environment new`/`delete` take no --project/--workspace/--environment
|
|
# flags — only `railway link` does, so it must run first to establish that
|
|
# context for every other command in this job.
|
|
run: |
|
|
railway link --project "$RAILWAY_PROJECT_ID" --workspace "$RAILWAY_WORKSPACE" --environment "$BASE_ENV"
|
|
|
|
# ---------------------------------------------------------------- destroy
|
|
- name: Destroy preview environment
|
|
if: ${{ inputs.action == 'destroy' }}
|
|
env:
|
|
ENV_NAME: ${{ steps.env.outputs.name }}
|
|
run: |
|
|
railway environment delete "$ENV_NAME" --yes
|
|
echo "Deleted environment $ENV_NAME." >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# ---------------------------------------------------------------- deploy
|
|
- name: Create preview environment (duplicate base)
|
|
if: ${{ inputs.action == 'deploy' }}
|
|
# `--duplicate` copies service config and variables but not public domains (see
|
|
# domain-generation steps below). Only "already exists" is swallowed here — any
|
|
# other failure must surface, not silently continue into a "Link" step that can't
|
|
# find an environment that was never created.
|
|
env:
|
|
ENV_NAME: ${{ steps.env.outputs.name }}
|
|
run: |
|
|
if ! output=$(railway environment new "$ENV_NAME" --duplicate "$BASE_ENV" 2>&1); then
|
|
echo "$output"
|
|
if echo "$output" | grep -qi "already exists"; then
|
|
echo "Environment may already exist; continuing."
|
|
else
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "$output"
|
|
fi
|
|
|
|
- name: Link project + preview environment
|
|
if: ${{ inputs.action == 'deploy' }}
|
|
# A brand-new environment (first run for a branch) can take a moment to become
|
|
# visible to `railway link` right after `railway environment new` returns — same
|
|
# duplication-propagation lag as the cio-minio ServiceInstance race below. Retry
|
|
# instead of failing the run.
|
|
env:
|
|
ENV_NAME: ${{ steps.env.outputs.name }}
|
|
run: |
|
|
for attempt in $(seq 1 10); do
|
|
if output=$(railway link --project "$RAILWAY_PROJECT_ID" --workspace "$RAILWAY_WORKSPACE" --environment "$ENV_NAME" 2>&1); then
|
|
echo "$output"
|
|
break
|
|
fi
|
|
echo "$output"
|
|
if [ "$attempt" -eq 10 ]; then
|
|
echo "::error::Environment $ENV_NAME never became linkable after 10 attempts."
|
|
exit 1
|
|
fi
|
|
echo "Environment not ready yet (attempt $attempt/10) — retrying in 5s..."
|
|
sleep 5
|
|
done
|
|
|
|
- name: Pin the Dockerfile per service
|
|
if: ${{ inputs.action == 'deploy' }}
|
|
# `railway up` uploads local source but does NOT carry the dashboard's "Dockerfile
|
|
# Path" build setting, so without this Railway falls back to Railpack and builds
|
|
# the whole monorepo as a generic Node app. (Same gotcha documented in the setup guide.)
|
|
run: |
|
|
railway variables --service cio-api --set "RAILWAY_DOCKERFILE_PATH=docker/Dockerfile.api"
|
|
railway variables --service cio-dashboard --set "RAILWAY_DOCKERFILE_PATH=docker/Dockerfile.dashboard"
|
|
railway variables --service cio-jobs --set "RAILWAY_DOCKERFILE_PATH=docker/Dockerfile.jobs"
|
|
|
|
- name: Seed MinIO buckets (fresh volume starts empty)
|
|
if: ${{ inputs.action == 'deploy' }}
|
|
# Railway has no minio-init sidecar and a duplicated env's MinIO volume is empty,
|
|
# so create the buckets the app expects. Root creds carry over via duplication,
|
|
# but the public domain doesn't, so generate one here (same as "Report the preview
|
|
# URL" does for cio-dashboard). `--ignore-existing` keeps re-deploys safe.
|
|
run: |
|
|
# A freshly duplicated environment's cio-minio ServiceInstance (volume-backed, like
|
|
# Postgres) can take a few seconds to materialize after `railway environment new
|
|
# --duplicate` returns, so `railway domain`/`variables` can fail with "ServiceInstance
|
|
# not found" immediately after duplication. Retry instead of failing the run.
|
|
MINIO_HOST=""
|
|
for attempt in $(seq 1 10); do
|
|
# --port 9000: the minio/minio image exposes both 9000 (S3 API) and 9001
|
|
# (console); pin it so domain generation doesn't land on the wrong one.
|
|
if output=$(railway domain --service cio-minio --port 9000 2>&1); then
|
|
echo "$output"
|
|
else
|
|
echo "$output"
|
|
if ! echo "$output" | grep -qi "already exists"; then
|
|
if [ "$attempt" -eq 10 ]; then
|
|
echo "::error::cio-minio ServiceInstance never became available after 10 attempts."
|
|
exit 1
|
|
fi
|
|
echo "cio-minio not ready yet (attempt $attempt/10) — retrying in 5s..."
|
|
sleep 5
|
|
continue
|
|
fi
|
|
fi
|
|
# `cut -d= -f2-` (not `awk -F= '{print $2}'`) so a value containing '=' — common in
|
|
# base64/auto-generated secrets like MINIO_ROOT_PASSWORD — isn't silently truncated.
|
|
MINIO_HOST=$(railway variables --service cio-minio --kv | grep '^RAILWAY_PUBLIC_DOMAIN=' | cut -d= -f2-)
|
|
if [ -n "$MINIO_HOST" ]; then
|
|
break
|
|
fi
|
|
if [ "$attempt" -eq 10 ]; then
|
|
echo "::error::cio-minio has no public domain after 10 attempts — 'railway domain' may have failed."
|
|
exit 1
|
|
fi
|
|
echo "cio-minio has no public domain yet (attempt $attempt/10) — retrying in 5s..."
|
|
sleep 5
|
|
done
|
|
MINIO_USER=$(railway variables --service cio-minio --kv | grep '^MINIO_ROOT_USER=' | cut -d= -f2-)
|
|
MINIO_PASS=$(railway variables --service cio-minio --kv | grep '^MINIO_ROOT_PASSWORD=' | cut -d= -f2-)
|
|
# Verify mc against MinIO's published SHA256 before executing it.
|
|
curl -sSL https://dl.min.io/client/mc/release/linux-amd64/mc -o mc
|
|
curl -sSL https://dl.min.io/client/mc/release/linux-amd64/mc.sha256sum -o mc.sha256sum
|
|
echo "$(cut -d' ' -f1 mc.sha256sum) mc" | sha256sum -c -
|
|
chmod +x mc
|
|
# --path on forces path-style bucket addressing (host/bucket/key), same as the
|
|
# app's own OBJECT_STORAGE_FORCE_PATH_STYLE=true — this Railway domain doesn't
|
|
# route wildcard bucket subdomains, so virtual-host style (mc's "auto" default)
|
|
# fails bucket creation with a misleading "does not exist" error.
|
|
./mc alias set cio "https://${MINIO_HOST}" "$MINIO_USER" "$MINIO_PASS" --path on
|
|
# A freshly duplicated cio-minio's HTTP listener accepts connections before it
|
|
# finishes formatting its storage backend ("Formatting 1st pool..." in its boot
|
|
# logs), so bucket creation can transiently fail right after boot. Retry instead
|
|
# of failing the run on what's usually just a not-ready-yet response.
|
|
for attempt in $(seq 1 10); do
|
|
if output=$(./mc mb cio/videos cio/documents cio/media --ignore-existing 2>&1); then
|
|
echo "$output"
|
|
break
|
|
fi
|
|
echo "$output"
|
|
if [ "$attempt" -eq 10 ]; then
|
|
echo "::error::cio-minio bucket creation failed after 10 attempts."
|
|
exit 1
|
|
fi
|
|
echo "Bucket creation failed (attempt $attempt/10) — cio-minio may still be initializing; retrying in 5s..."
|
|
sleep 5
|
|
done
|
|
# Same boot-race as above can hit the very next call too, even right after mb
|
|
# just succeeded — cio-minio's storage layer can take a moment past that point
|
|
# to fully settle.
|
|
for attempt in $(seq 1 10); do
|
|
if output=$(./mc anonymous set download cio/media 2>&1); then
|
|
echo "$output"
|
|
break
|
|
fi
|
|
echo "$output"
|
|
if [ "$attempt" -eq 10 ]; then
|
|
echo "::error::cio-minio anonymous-download policy failed after 10 attempts."
|
|
exit 1
|
|
fi
|
|
echo "Anonymous policy set failed (attempt $attempt/10) — retrying in 5s..."
|
|
sleep 5
|
|
done
|
|
|
|
- name: Deploy app services from this branch in parallel
|
|
if: ${{ inputs.action == 'deploy' }}
|
|
# `--ci` (not `--detach`) keeps each process attached until its deployment
|
|
# succeeds or fails. Run the independent app builds concurrently, then wait for
|
|
# every service so the URL printed later is actually live.
|
|
#
|
|
# `railway up --ci` can exit 1 on a log-streaming glitch (railwayapp/cli#696) even
|
|
# though the deploy succeeded. Only on that specific error do we poll the real
|
|
# deployment status instead of trusting the exit code — any other failure means no
|
|
# new deployment exists yet, so it fails immediately rather than risking a match
|
|
# against a stale, unrelated deployment.
|
|
run: bash .github/scripts/railway-preview-deploy.sh
|
|
|
|
- name: Setup pnpm
|
|
if: ${{ inputs.action == 'deploy' && inputs.seed }}
|
|
uses: pnpm/action-setup@v4
|
|
|
|
- name: Setup Node.js
|
|
if: ${{ inputs.action == 'deploy' && inputs.seed }}
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
cache: 'pnpm'
|
|
|
|
- name: Install dependencies
|
|
if: ${{ inputs.action == 'deploy' && inputs.seed }}
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Build workspace dependencies
|
|
if: ${{ inputs.action == 'deploy' && inputs.seed }}
|
|
# @cio/db imports compiled output (e.g. @cio/utils/dist/...) from its workspace
|
|
# deps, not source — they must be built before the seed script can import them.
|
|
run: pnpm --filter @cio/db^... build
|
|
|
|
- name: Seed the full demo dataset (optional)
|
|
if: ${{ inputs.action == 'deploy' && inputs.seed }}
|
|
# cio-api only carries the private DATABASE_URL; `railway run` executes on this
|
|
# GitHub runner, so seeding needs Postgres's own DATABASE_PUBLIC_URL instead
|
|
# (railway.internal isn't reachable from here).
|
|
run: |
|
|
PUBLIC_DB_URL=$(railway variables --service Postgres --kv | grep '^DATABASE_PUBLIC_URL=' | cut -d= -f2-)
|
|
if [ -z "$PUBLIC_DB_URL" ]; then
|
|
echo "::warning::Postgres has no DATABASE_PUBLIC_URL in this environment — skipping full seed; only essential seed ran on api boot."
|
|
else
|
|
railway run --service cio-api -- env DATABASE_URL="$PUBLIC_DB_URL" pnpm --filter @cio/db db:setup:seed || \
|
|
echo "Demo seed failed (likely DB not reachable from runner) — migrations + essential seed still ran on api boot."
|
|
fi
|
|
|
|
- name: Report the preview URL
|
|
if: ${{ inputs.action == 'deploy' }}
|
|
# BRANCH / ENV_NAME bound via env (never interpolated into the script).
|
|
env:
|
|
BRANCH: ${{ inputs.branch }}
|
|
ENV_NAME: ${{ steps.env.outputs.name }}
|
|
run: |
|
|
railway domain --service cio-dashboard || true
|
|
URL=$(railway variables --service cio-dashboard --kv | grep '^RAILWAY_PUBLIC_DOMAIN=' | cut -d= -f2-)
|
|
if [ -z "$URL" ]; then
|
|
echo "::error::cio-dashboard has no public domain — 'railway domain' may have failed."
|
|
exit 1
|
|
fi
|
|
{
|
|
echo "### Railway preview ready :rocket:"
|
|
echo ""
|
|
echo "- Environment: \`$ENV_NAME\` (branch \`$BRANCH\`)"
|
|
echo "- Dashboard: https://${URL}"
|
|
echo ""
|
|
echo "> All services passed their Railway deploy/healthcheck before this link was printed, so it should be live now."
|
|
echo ""
|
|
echo "Tear down with this workflow using **action = destroy** and the same branch."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|