Files
Abdulmuizz HamzatandRotimi Best 73f39da54a feat(workflows): add Railway preview environment and teardown workflows (#766)
* feat(workflows): add Railway preview environment and teardown workflows

* fix(workflows): enhance security by binding branch names to env variables in Railway workflows

* fix(workflows): ensure preview URLs are live by blocking on service healthchecks during deployment

* temp: auto-run railway-preview.yml on push for demo recording

workflow_dispatch requires the file on the default branch before "Run
workflow" is available; add a scoped push trigger + input fallbacks so
this branch can produce a real run to record. Revert before merge.

* fix(workflows): pass --workspace to railway link for non-interactive CI

Railway CLI now requires --workspace when running `railway link`
non-interactively; without it the preview workflow fails at the link
step. Defaults to the "cio projects" workspace the staging project
lives in, overridable via a RAILWAY_WORKSPACE repo variable.

* fix(workflows): read RAILWAY_PROJECT_ID from secrets, not vars

It's configured as a repository secret (has been for weeks), but the
workflow read it via the vars context, which is a separate namespace
and always resolved empty -> railway link failed with Project "" not
found. Read it via secrets to match how it's actually stored.

* fix(workflows): pass --workspace to environment new/delete, stop masking failures

railway environment new needed --workspace in non-interactive mode too
(same as railway link), but its failure was unconditionally swallowed
by `|| echo`, so the workflow proceeded to "Link" as if the preview
environment existed when it never got created. Add --workspace to
both environment new and environment delete, and only swallow the
"already exists" case -- any other failure now fails the run where it
actually happened instead of surfacing as a confusing error two steps
later.

* fix(workflows): stop passing --project/--workspace to railway environment new/delete

Verified against the actual CLI (railway environment new --help / delete
--help): those subcommands take NO --project/--workspace/--environment
flags at all, and were silently rejecting them, which is why environment
creation kept failing and getting masked by the fallback echo. Only
`railway link` accepts --project/--workspace/--environment, so link to
the base project first, then create/delete environments by name, then
re-link to switch into the new one for deploy.

* fix(workflows): force path-style addressing for mc against Railway's MinIO domain

mc alias set defaults to --path auto, which can pick virtual-host-style
bucket addressing (bucket.host/key). Railway's edge only routes the
exact configured domain, not wildcard bucket subdomains, so mb then
fails with a misleading "specified bucket does not exist" even though
the alias/auth check (a GET) succeeds. Force --path on, matching the
app's own OBJECT_STORAGE_FORCE_PATH_STYLE=true for this same endpoint.

* chore: retrigger preview workflow after fixing cio-minio start command

staging's cio-minio now has the correct Custom Start Command, and the
stale pr-chore-staging-setup preview env (duplicated before that fix)
has been deleted. Empty commit to trigger a fresh deploy run.

* fix(workflows): generate cio-minio's public domain in the preview env

Confirmed staging's cio-minio has its own domain, but environment
duplication doesn't carry it to the new preview environment, so
RAILWAY_PUBLIC_DOMAIN came back empty there and produced an invalid
"https://" alias URL. Generate one in CI, same as the existing
cio-dashboard domain step, pinned to port 9000 since the minio/minio
image exposes both the S3 API (9000) and console (9001).

* chore(workflows): revert temp push-trigger testing scaffold, trim comments

Remove the push trigger and || fallback expressions added solely to
get a run before this file exists on main (workflow_dispatch requires
that for the "Run workflow" UI). Back to workflow_dispatch-only, with
comments trimmed from a debugging narrative down to the durable facts
future readers need. All real fixes found during testing (link
--workspace, secrets vs vars, environment new/delete flag handling,
mc --path on, cio-minio domain generation) are unchanged.

* fix(workflows): fix teardown link/secrets bugs, harden domain reads

railway-preview-teardown.yml had the same class of bugs already found
and fixed in railway-preview.yml during testing:
- RAILWAY_PROJECT_ID read from vars.* while the deploy workflow (and
  the actual GitHub config) uses secrets.* — always resolved empty.
- environment delete was called without linking first, and passed an
  unsupported --project flag; the resulting CLI error was swallowed
  by `|| echo`, so teardown silently no-opped on every run and every
  preview environment leaked indefinitely.

Also hardens railway-preview.yml against a repeat of the cio-minio
domain bug: two other "generate domain, then read it back" spots
(cio-minio bucket seeding, cio-dashboard's final URL report) had the
same unguarded `|| true` swallow with no check that the resulting
host is non-empty. Both now fail loudly instead of silently
proceeding — the dashboard one matters most since it's the final
step and would otherwise report success with a broken link.

Resolves the now-verified SPIKE comment on environment duplication,
and trims a few comments that restated code instead of explaining
non-obvious behavior.

* fix(workflows): address CodeRabbit findings on preview lifecycle

- Unify concurrency groups between deploy and teardown (was
  railway-preview-<branch> vs railway-preview-teardown-<branch>, so
  they never coordinated). A PR-close teardown and a manual deploy
  for the same branch could otherwise race, letting a deploy recreate
  an environment right after teardown deleted it.
- Pin the Railway CLI to 5.30.1 (the version verified against all
  session) in both workflows, instead of an unpinned `npm install -g`
  that could silently pick up different undocumented CLI behavior
  later, same class of bug this session hit repeatedly.
- Append a short hash of the original branch string to the derived
  environment name in both workflows. The slug alone is lossy (/, _,
  and . all collapse to the same separator), so e.g. "foo/bar" and
  "foo_bar" previously collided onto the same environment name.

* fix(workflows): scope teardown job to same-repo PRs only

The header comment already documented that this only applies to
same-repo branches (secrets are withheld from fork PR runs), but
nothing enforced it -- the job ran on every closed PR including
forks, where the empty RAILWAY_API_TOKEN/RAILWAY_PROJECT_ID made
railway link fail with an auth error and show a red run for a PR
that never had a preview environment to clean up.

* docs: migrate RAILWAY_PREVIEW.md to PREVIEW_ENV.md

Replace docker/docs/RAILWAY_PREVIEW.md with PREVIEW_ENV.md at the repo
root, restructured per PR review feedback around the two things a
reader actually needs: how to deploy to staging, and how to deploy a
PR to a preview environment. Drops the one-time Railway project setup
section (not needed on this page, per review).

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
2026-08-01 21:07:31 +01:00

74 lines
3.7 KiB
Markdown

# Railway Staging & PR Preview Environments
ClassroomIO's production SaaS runs on Render + Cloudflare — **not** Railway. This
setup adds a **separate, internal Railway project** that provides a long-lived **staging**
environment plus **short-lived preview environments** for testing a branch/PR of the full
stack (api, dashboard, jobs, Postgres, Redis, MinIO) before it merges to `main`.
Real users are never routed here; it's a team-only sandbox.
## How to deploy to staging
Push or merge your change to the branch connected to the staging environment (staging). Railway
picks it up automatically and redeploys — no manual step required.
## How to deploy a PR to a preview environment
Two mechanisms, both enabled:
| | Native PR environments | Manual workflow (`railway-preview.yml`) |
|---|---|---|
| Trigger | Automatic on every opened PR | You run it from the **Actions** tab |
| Teardown | Automatic on PR merge/close | `action = destroy`, or auto via `railway-preview-teardown.yml` on PR close |
| Control | Whole base env is duplicated | Same, but you choose when and can re-seed the DB |
| Requires | App services connected to the GitHub repo (Dockerfile builder) | A Railway account/team token in CI |
> A service that deploys from a **fixed Docker Hub image tag can never get a PR preview** —
> an image tag has no branch. That's why the Railway app services build **from the repo**
> via `docker/Dockerfile.*`, not from `docker-compose.images.yaml`.
### Using the manual workflow
> 🎥 Demo video: TODO
**Actions → "Railway Preview Environment" → Run workflow:**
- `branch` — the branch/PR head to deploy.
- `action` — `deploy` (create/update) or `destroy` (delete).
- `seed` — also load the full demo dataset (`admin@test.com` etc.); migrations + essential
seed always run on api boot regardless.
The run prints the preview dashboard URL in its job **summary**. The env is named
`pr-<branch-slug>`.
The deploy step runs `railway up --ci` (no `--detach`), which streams build logs and **blocks until
each service passes its deploy/healthcheck** before the next one starts — so the printed URL is live
the moment the run finishes, and a failed healthcheck fails the run rather than reporting a broken
preview.
## Database behavior
- Each environment has its **own isolated Postgres** — previews start fresh.
- `cio-api` **self-migrates on boot** (`docker/entrypoint-api.sh` → `pnpm --filter @cio/db
db:setup` → `drizzle-kit migrate` + essential seed). No manual migrate step needed.
- **The branch must include a committed Drizzle migration** (`pnpm --filter @cio/db
db:generate`) — the boot path runs committed migrations only, not schema `push`.
## Verify before relying on it (spike, do on a throwaway env)
Railway's docs are thin on a couple of points the workflow depends on — confirm these once:
1. **`railway environment new --duplicate <env>` copies service config *and* variables.**
If your CLI version lacks `--duplicate`, use the GraphQL
`environmentCreate(sourceEnvironmentId)` mutation instead.
2. **A duplicated env's Postgres starts empty** (so boot migration builds a clean schema).
3. **The demo-seed step's DB reachability.** `railway run` executes on the GitHub runner, so
it needs a DB URL reachable from CI (Railway's public TCP proxy `DATABASE_PUBLIC_URL`),
not the private `railway.internal` one. If unreachable, drop the seed step and rely on the
boot-time essential seed. These caveats are marked `# SPIKE:` in `railway-preview.yml`.
## Cost
Every environment (staging + each preview) runs the full 6 services incl. a MinIO volume, so
previews are not free while alive. Destroy them promptly — the teardown workflow handles PR
close automatically; use `action = destroy` for previews whose PR is still open.