66 workflow_dispatch :
77 inputs :
88 ref :
9- description : Git ref to deploy
9+ description : Git ref to verify and deploy to a non-production environment
1010 required : false
1111 default : main
12+ target_environment :
13+ description : GitHub Environment name for a non-production deploy, for example staging or homolog
14+ required : true
15+ default : staging
1216 railway_environment :
13- description : Railway environment name or ID
17+ description : Optional Railway environment override; defaults to target_environment
1418 required : false
15- default : production
19+ default : " "
1620 app_base_url :
1721 description : Public HTTPS base URL used for smoke validation
1822 required : false
1923 default : " "
2024
2125concurrency :
22- group : deploy-production
26+ group : deploy-${{ github.event_name == 'release' && ' production' || inputs.target_environment || 'staging' }}
2327 cancel-in-progress : false
2428
2529permissions :
2630 contents : read
2731
2832jobs :
33+ resolve-context :
34+ name : resolve deployment context
35+ runs-on : ubuntu-latest
36+ outputs :
37+ ref : ${{ steps.resolve.outputs.ref }}
38+ github_environment : ${{ steps.resolve.outputs.github_environment }}
39+ railway_environment : ${{ steps.resolve.outputs.railway_environment }}
40+ requested_base_url : ${{ steps.resolve.outputs.requested_base_url }}
41+ smoke_required : ${{ steps.resolve.outputs.smoke_required }}
42+
43+ steps :
44+ - name : Resolve trigger-specific deployment context
45+ id : resolve
46+ shell : bash
47+ env :
48+ DISPATCH_REF : ${{ inputs.ref }}
49+ DISPATCH_TARGET_ENVIRONMENT : ${{ inputs.target_environment }}
50+ DISPATCH_RAILWAY_ENVIRONMENT : ${{ inputs.railway_environment }}
51+ DISPATCH_APP_BASE_URL : ${{ inputs.app_base_url }}
52+ run : |
53+ set -euo pipefail
54+
55+ if [ "${GITHUB_EVENT_NAME}" = "release" ]; then
56+ ref="${{ github.event.release.tag_name }}"
57+ github_environment="production"
58+ railway_environment="production"
59+ smoke_required="true"
60+ requested_base_url=""
61+ else
62+ if [ -z "${DISPATCH_TARGET_ENVIRONMENT:-}" ]; then
63+ echo "::error::workflow_dispatch requires target_environment for a non-production deployment."
64+ exit 1
65+ fi
66+
67+ if [ "${DISPATCH_TARGET_ENVIRONMENT}" = "production" ]; then
68+ echo "::error::Manual production deployments are intentionally blocked. Publish a GitHub release to promote to production."
69+ exit 1
70+ fi
71+
72+ ref="${DISPATCH_REF:-main}"
73+ github_environment="${DISPATCH_TARGET_ENVIRONMENT}"
74+ railway_environment="${DISPATCH_RAILWAY_ENVIRONMENT:-${DISPATCH_TARGET_ENVIRONMENT}}"
75+ smoke_required="false"
76+ requested_base_url="${DISPATCH_APP_BASE_URL:-}"
77+ fi
78+
79+ if [ -z "${ref}" ]; then
80+ echo "::error::Unable to resolve a git ref to deploy."
81+ exit 1
82+ fi
83+
84+ {
85+ echo "ref=${ref}"
86+ echo "github_environment=${github_environment}"
87+ echo "railway_environment=${railway_environment}"
88+ echo "requested_base_url=${requested_base_url}"
89+ echo "smoke_required=${smoke_required}"
90+ } >> "${GITHUB_OUTPUT}"
91+
92+ verify-promotion-candidate :
93+ name : verify promotion candidate
94+ needs : resolve-context
95+ runs-on : ubuntu-latest
96+ timeout-minutes : 25
97+ env :
98+ DATABASE_URL : postgresql://auth_user:auth_password@localhost:5432/auth_api
99+ NODE_ENV : test
100+ ACCESS_TOKEN_SECRET : test-access-secret-with-at-least-thirty-two-characters
101+ REFRESH_TOKEN_SECRET : test-refresh-secret-with-at-least-thirty-two-characters
102+ JWT_ISSUER : auth-api-test
103+ JWT_AUDIENCE : auth-api-test-clients
104+ REDIS_URL : redis://localhost:6379
105+ RATE_LIMIT_WINDOW_MS : 60000
106+ RATE_LIMIT_MAX_REQUESTS : 100
107+ DOCS_ENABLED : true
108+ BCRYPT_ROUNDS : 8
109+
110+ services :
111+ postgres :
112+ image : postgres:16
113+ env :
114+ POSTGRES_USER : auth_user
115+ POSTGRES_PASSWORD : auth_password
116+ POSTGRES_DB : auth_api
117+ ports :
118+ - 5432:5432
119+ options : >-
120+ --health-cmd="pg_isready -U auth_user -d auth_api"
121+ --health-interval=5s
122+ --health-timeout=5s
123+ --health-retries=5
124+
125+ redis :
126+ image : redis:7
127+ ports :
128+ - 6379:6379
129+ options : >-
130+ --health-cmd="redis-cli ping"
131+ --health-interval=5s
132+ --health-timeout=5s
133+ --health-retries=10
134+
135+ steps :
136+ - name : Checkout source
137+ uses : actions/checkout@v6
138+ with :
139+ ref : ${{ needs.resolve-context.outputs.ref }}
140+
141+ - name : Use Node.js 20
142+ uses : actions/setup-node@v6
143+ with :
144+ node-version : 20
145+ cache : npm
146+
147+ - name : Install dependencies
148+ run : npm ci
149+
150+ - name : Generate Prisma Client
151+ run : npm run prisma:generate
152+
153+ - name : Run lint
154+ run : npm run lint
155+
156+ - name : Run typecheck
157+ run : npm run typecheck
158+
159+ - name : Run build
160+ run : npm run build
161+
162+ - name : Run tests with coverage
163+ run : npm run test:coverage
164+
165+ - name : Apply Prisma migrations
166+ run : npm run prisma:migrate:deploy
167+
168+ - name : Run integration tests
169+ run : npm run test:integration
170+
29171 deploy :
30172 name : deploy
173+ needs :
174+ - resolve-context
175+ - verify-promotion-candidate
31176 runs-on : ubuntu-latest
32177 timeout-minutes : 30
33178 environment :
34- name : production
179+ name : ${{ needs.resolve-context.outputs.github_environment }}
180+ outputs :
181+ app_base_url : ${{ steps.validate-config.outputs.app_base_url }}
182+ railway_environment : ${{ steps.validate-config.outputs.railway_environment }}
35183 env :
36184 RAILWAY_TOKEN : ${{ secrets.RAILWAY_TOKEN }}
37185 RAILWAY_PROJECT_ID : ${{ secrets.RAILWAY_PROJECT_ID }}
38186 RAILWAY_SERVICE : ${{ secrets.RAILWAY_SERVICE }}
39- RAILWAY_ENVIRONMENT : ${{ github.event_name == 'workflow_dispatch' && inputs.railway_environment || secrets.RAILWAY_ENVIRONMENT }}
40- APP_BASE_URL : ${{ github.event_name == 'workflow_dispatch' && inputs.app_base_url || secrets.RAILWAY_PUBLIC_URL }}
187+ RAILWAY_ENVIRONMENT : ${{ secrets.RAILWAY_ENVIRONMENT || needs.resolve-context.outputs.railway_environment }}
188+ APP_BASE_URL : ${{ needs.resolve-context.outputs.requested_base_url != '' && needs.resolve-context.outputs.requested_base_url || secrets.RAILWAY_PUBLIC_URL }}
189+ SMOKE_REQUIRED : ${{ needs.resolve-context.outputs.smoke_required }}
41190
42191 steps :
43192 - name : Validate deployment configuration
193+ id : validate-config
44194 shell : bash
45195 run : |
46196 set -euo pipefail
@@ -53,19 +203,22 @@ jobs:
53203
54204 for key in "${required_secrets[@]}"; do
55205 if [ -z "${!key:-}" ]; then
56- echo "::error::Missing required repository secret: ${key}"
206+ echo "::error::Missing required environment secret: ${key}"
57207 exit 1
58208 fi
59209 done
60210
61211 if [ -z "${RAILWAY_ENVIRONMENT:-}" ]; then
62- echo "RAILWAY_ENVIRONMENT=production" >> "${GITHUB_ENV}"
212+ echo "::error::Missing Railway environment. Set RAILWAY_ENVIRONMENT on the selected GitHub Environment or provide a workflow_dispatch override."
213+ exit 1
63214 fi
64215
65- if [ -z "${APP_BASE_URL:-}" ]; then
66- echo "SMOKE_ENABLED=false" >> "${GITHUB_ENV}"
67- echo "::warning::RAILWAY_PUBLIC_URL is not configured. Deployment will run, but smoke validation will be skipped."
68- else
216+ if [ "${SMOKE_REQUIRED}" = "true" ] && [ -z "${APP_BASE_URL:-}" ]; then
217+ echo "::error::Production deployments require RAILWAY_PUBLIC_URL to be configured on the production GitHub Environment."
218+ exit 1
219+ fi
220+
221+ if [ -n "${APP_BASE_URL:-}" ]; then
69222 case "${APP_BASE_URL}" in
70223 https://*)
71224 ;;
@@ -74,19 +227,22 @@ jobs:
74227 exit 1
75228 ;;
76229 esac
77- echo "SMOKE_ENABLED=true" >> "${GITHUB_ENV}"
78230 fi
79231
232+ {
233+ echo "app_base_url=${APP_BASE_URL:-}"
234+ echo "railway_environment=${RAILWAY_ENVIRONMENT}"
235+ } >> "${GITHUB_OUTPUT}"
236+
80237 - name : Checkout source
81238 uses : actions/checkout@v6
82239 with :
83- ref : ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.release.tag_name }}
240+ ref : ${{ needs.resolve-context.outputs.ref }}
84241
85242 - name : Use Node.js 20
86243 uses : actions/setup-node@v6
87244 with :
88245 node-version : 20
89- cache : npm
90246
91247 - name : Install Railway CLI
92248 run : npm install --global @railway/cli@latest
@@ -96,25 +252,99 @@ jobs:
96252 CI : " "
97253 run : railway up --project "$RAILWAY_PROJECT_ID" --service "$RAILWAY_SERVICE" --environment "${RAILWAY_ENVIRONMENT:-production}"
98254
255+ validate-deployment :
256+ name : validate deployment
257+ needs :
258+ - resolve-context
259+ - deploy
260+ runs-on : ubuntu-latest
261+ timeout-minutes : 15
262+ env :
263+ DEPLOY_REF : ${{ needs.resolve-context.outputs.ref }}
264+ GITHUB_ENVIRONMENT_NAME : ${{ needs.resolve-context.outputs.github_environment }}
265+ RAILWAY_ENVIRONMENT : ${{ needs.deploy.outputs.railway_environment }}
266+ APP_BASE_URL : ${{ needs.deploy.outputs.app_base_url }}
267+ SMOKE_REQUIRED : ${{ needs.resolve-context.outputs.smoke_required }}
268+
269+ steps :
270+ - name : Checkout source
271+ uses : actions/checkout@v6
272+ with :
273+ ref : ${{ needs.resolve-context.outputs.ref }}
274+
275+ - name : Use Node.js 20
276+ uses : actions/setup-node@v6
277+ with :
278+ node-version : 20
279+
280+ - name : Validate smoke configuration
281+ id : smoke-config
282+ shell : bash
283+ run : |
284+ set -euo pipefail
285+
286+ if [ -z "${APP_BASE_URL:-}" ]; then
287+ if [ "${SMOKE_REQUIRED}" = "true" ]; then
288+ echo "::error::Production deployments require a public HTTPS base URL for smoke validation."
289+ exit 1
290+ fi
291+
292+ echo "smoke_enabled=false" >> "${GITHUB_OUTPUT}"
293+ exit 0
294+ fi
295+
296+ case "${APP_BASE_URL}" in
297+ https://*)
298+ ;;
299+ *)
300+ echo "::error::Smoke validation requires a full HTTPS base URL, for example https://auth-api-staging.up.railway.app"
301+ exit 1
302+ ;;
303+ esac
304+
305+ echo "smoke_enabled=true" >> "${GITHUB_OUTPUT}"
306+
99307 - name : Run smoke validation
100- if : env.SMOKE_ENABLED == 'true'
308+ id : smoke
309+ if : steps.smoke-config.outputs.smoke_enabled == 'true'
101310 run : ./scripts/smoke-production.sh "$APP_BASE_URL"
102311
103312 - name : Write deployment summary
313+ if : always()
104314 shell : bash
315+ env :
316+ SMOKE_CONFIG_OUTCOME : ${{ steps.smoke-config.outcome }}
317+ SMOKE_OUTCOME : ${{ steps.smoke.outcome }}
318+ SMOKE_ENABLED : ${{ steps.smoke-config.outputs.smoke_enabled }}
105319 run : |
106320 {
107321 echo "## Deployment summary"
108322 echo
109323 echo "- Trigger: ${GITHUB_EVENT_NAME}"
110- echo "- Ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.release.tag_name }}"
111- echo "- Railway environment: ${RAILWAY_ENVIRONMENT:-production}"
324+ echo "- Ref: ${DEPLOY_REF}"
325+ echo "- GitHub environment: ${GITHUB_ENVIRONMENT_NAME}"
326+ echo "- Railway environment: ${RAILWAY_ENVIRONMENT}"
327+ echo "- Verification gate: passed"
112328 if [ -n "${APP_BASE_URL:-}" ]; then
113329 echo "- Base URL: ${APP_BASE_URL}"
114330 echo "- Health: ${APP_BASE_URL%/}/health"
115331 echo "- Readiness: ${APP_BASE_URL%/}/ready"
116332 echo "- OpenAPI: ${APP_BASE_URL%/}/docs.json"
117333 else
118- echo "- Base URL: not configured"
334+ echo "- Base URL: not configured for this environment"
335+ fi
336+
337+ if [ "${SMOKE_ENABLED:-false}" = "true" ]; then
338+ if [ "${SMOKE_OUTCOME:-}" = "success" ]; then
339+ echo "- Smoke validation: passed"
340+ else
341+ echo "- Smoke validation: failed"
342+ fi
343+ else
344+ if [ "${SMOKE_CONFIG_OUTCOME:-}" = "failure" ]; then
345+ echo "- Smoke validation: blocked by invalid configuration"
346+ else
347+ echo "- Smoke validation: skipped (allowed only for non-production without a public URL)"
348+ fi
119349 fi
120350 } >> "${GITHUB_STEP_SUMMARY}"
0 commit comments