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 (admin@test.com 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/cli@5.30.1 - 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-" — 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--" 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 each app service from this branch if: ${{ inputs.action == 'deploy' }} # `--ci` (not `--detach`) blocks until each service's deploy/healthcheck succeeds, # so the URL printed later is actually live. Trade-off: builds run sequentially. # # `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: | for svc in $APP_SERVICES; do echo "::group::railway up $svc" log_file=$(mktemp) railway up --service "$svc" --ci 2>&1 | tee "$log_file" railway_status=${PIPESTATUS[0]} if [ "$railway_status" -ne 0 ]; then if grep -q "Failed to stream build logs:" "$log_file"; then deploy_id=$(grep -oP '(?<=[?&]id=)[0-9a-fA-F-]+' "$log_file" | head -n1) if [ -z "$deploy_id" ]; then echo "::error::railway up failed for $svc and no deployment id could be recovered from its output — cannot verify actual status." rm -f "$log_file" exit 1 fi echo "railway up reported failure for $svc due to the known log-streaming glitch; checking status of deployment $deploy_id..." status="" for attempt in $(seq 1 60); do status=$(railway deployment list --service "$svc" --json 2>/dev/null | jq -r --arg id "$deploy_id" '.[] | select(.id == $id) | .status // empty') case "$status" in SUCCESS) echo "Deployment $deploy_id for $svc actually succeeded (status: SUCCESS) — the CLI error was just a log-streaming glitch. Continuing." break ;; FAILED|CRASHED|REMOVED) echo "::error::$svc deployment $deploy_id did not succeed (status: $status)." rm -f "$log_file" exit 1 ;; esac if [ "$attempt" -eq 60 ]; then echo "::error::$svc deployment $deploy_id never reached a terminal status after 60 attempts (last seen: ${status:-unknown})." rm -f "$log_file" exit 1 fi echo "Deployment $deploy_id for $svc not finished yet (status: ${status:-unknown}, attempt $attempt/60) — retrying in 10s..." sleep 10 done else echo "::error::railway up failed for $svc for a reason other than the known log-streaming glitch — not polling stale deployment status." rm -f "$log_file" exit 1 fi fi rm -f "$log_file" echo "::endgroup::" done - 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"