Skip to content

Commit a283048

Browse files
committed
ci: harden release promotion flow
1 parent 3e3cb78 commit a283048

3 files changed

Lines changed: 353 additions & 41 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 250 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,191 @@ on:
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

2125
concurrency:
22-
group: deploy-production
26+
group: deploy-${{ github.event_name == 'release' && 'production' || inputs.target_environment || 'staging' }}
2327
cancel-in-progress: false
2428

2529
permissions:
2630
contents: read
2731

2832
jobs:
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}"

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,9 @@ The default public hosting target for this project is **Railway**.
202202

203203
Deployment automation is implemented through [`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml) and supports:
204204

205-
- published GitHub releases
206-
- manual dispatch with a custom ref
205+
- published GitHub releases for production promotion
206+
- manual dispatch for intentional non-production deployments
207+
- exact-ref verification before any deployment
207208
- smoke validation for `/health`, `/ready`, and `/docs.json`
208209

209210
Deployment setup material:
@@ -214,6 +215,14 @@ Deployment setup material:
214215

215216
The public demo deployment is live at [`https://auth-api-production-a97b.up.railway.app`](https://auth-api-production-a97b.up.railway.app), with public docs available at [`/docs`](https://auth-api-production-a97b.up.railway.app/docs) and [`/docs.json`](https://auth-api-production-a97b.up.railway.app/docs.json).
216217

218+
Operational model:
219+
220+
- CI in [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) protects `main` with `quality` and `integration`
221+
- production deploys happen only from published GitHub releases
222+
- manual `Deploy` runs are reserved for staging/homolog-style environments and reject `production`
223+
- the deploy workflow re-verifies the exact ref being promoted with lint, typecheck, build, coverage, and integration tests before `railway up`
224+
- production smoke validation is mandatory and fails clearly if `RAILWAY_PUBLIC_URL` is missing
225+
217226
## Observability
218227

219228
Enable metrics locally with `METRICS_ENABLED=true` and expose:

0 commit comments

Comments
 (0)