Repository navigation
678 lines (612 loc) · 27.3 KB
/
Copy pathdatabase-migration.yml
File metadata and controls
678 lines (612 loc) · 27.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
# Run Database Migrations
#
# This workflow manages database schema migrations across all cloud providers.
# It can run migrations up (apply) or down (rollback) with safety checks.
#
# Required GitHub Secrets:
# - DB_PASSWORD_AWS: Database password for AWS Aurora
# - DB_PASSWORD_GCP: Database password for GCP Cloud SQL
# - DB_PASSWORD_AZURE: Database password for Azure Flexible Server
# - (AWS auth via OIDC: vars.AWS_ROLE_TO_ASSUME)
# - (GCP auth via WIF: vars.GCP_WORKLOAD_IDENTITY_PROVIDER + vars.GCP_SERVICE_ACCOUNT)
# - AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID (OIDC federation)
#
# Required GitHub Variables:
# - DB_HOST_AWS_DEV: Aurora endpoint (dev)
# - DB_HOST_AWS_STAGING: Aurora endpoint (staging)
# - DB_HOST_AWS_PROD: Aurora endpoint (prod)
# - (Similar for GCP and Azure)
#
# Triggered by:
# - Manual workflow dispatch
# - Before deployment workflows (optional)
name: Database Migration
# Least privilege by default: only the jobs that actually authenticate to a
# cloud provider get `id-token: write`, and every one of those jobs is bound to
# a deployment environment (`aws-db-<env>`, `gcp-db-<env>`, `azure-db-<env>`).
# `validate` and `summary` never authenticate, so they stay on the `contents:
# read` floor below.
permissions:
contents: read
on:
workflow_dispatch:
inputs:
cloud:
description: 'Cloud provider'
required: true
type: choice
options: [aws, gcp, azure, all]
environment:
description: 'Environment'
required: true
type: choice
options: [dev, staging, prod]
direction:
description: 'Migration direction'
required: true
type: choice
options: [up, down]
default: up
steps:
description: 'Number of migrations to apply/rollback (0 = all; for direction=down an explicit positive value is required)'
required: false
type: number
default: 0
confirm:
description: 'Type "rollback-prod" to confirm a down migration on prod (ignored otherwise)'
required: false
type: string
default: ''
compute_platform:
description: 'AWS compute platform whose database to migrate (terraform/environments/aws has two independent RDS instances, one per namespace; ignored for gcp/azure)'
required: false
type: choice
options: [lambda, fargate]
default: lambda
workflow_call:
inputs:
cloud:
required: true
type: string
environment:
required: true
type: string
direction:
required: false
type: string
default: up
compute_platform:
required: false
type: string
default: lambda
env:
MIGRATIONS_PATH: internal/database/postgres/migrations
# Must satisfy `required_version = ">= 1.10.0"` declared by every
# terraform/environments/*/main.tf. Keep in sync with ci.yml/pre-commit.yml.
TF_VERSION: '1.10.5'
jobs:
# Validate migration request
validate:
name: Validate Migration Request
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
is_safe: ${{ steps.check.outputs.is_safe }}
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Safety checks
id: check
env:
CLOUD: ${{ inputs.cloud }}
ENVIRONMENT: ${{ inputs.environment }}
DIRECTION: ${{ inputs.direction }}
STEPS: ${{ inputs.steps }}
CONFIRM: ${{ inputs.confirm }}
COMPUTE_PLATFORM: ${{ inputs.compute_platform }}
run: |
set -uo pipefail
IS_SAFE=true
# `workflow_call` never declares a `steps` input, so on that path this
# renders empty. Treat that the same as the explicit default of "0"
# (apply/rollback everything) rather than rejecting it outright.
STEPS="${STEPS:-0}"
# Both triggers default `compute_platform` to "lambda", but
# workflow_call's is a plain `type: string` with no `choice`
# enforcement, so re-validate it here like every other input.
COMPUTE_PLATFORM="${COMPUTE_PLATFORM:-lambda}"
# workflow_dispatch's cloud/environment/direction are `type: choice`,
# enforced server-side before the run starts. workflow_call's are plain
# `type: string` with no such enforcement, so re-validate them here
# against the same allowlist regardless of which trigger fired.
case "$CLOUD" in
aws|gcp|azure|all) ;;
*)
echo "❌ Invalid cloud provider: '$CLOUD' (expected aws, gcp, azure, or all)"
IS_SAFE=false
;;
esac
case "$ENVIRONMENT" in
dev|staging|prod) ;;
*)
echo "❌ Invalid environment: '$ENVIRONMENT' (expected dev, staging, or prod)"
IS_SAFE=false
;;
esac
case "$DIRECTION" in
up|down) ;;
*)
echo "❌ Invalid direction: '$DIRECTION' (expected up or down)"
IS_SAFE=false
;;
esac
case "$COMPUTE_PLATFORM" in
lambda|fargate) ;;
*)
echo "❌ Invalid compute_platform: '$COMPUTE_PLATFORM' (expected lambda or fargate)"
IS_SAFE=false
;;
esac
# Check if migrations directory exists
if [ ! -d "$MIGRATIONS_PATH" ]; then
echo "❌ Migrations directory not found: $MIGRATIONS_PATH"
IS_SAFE=false
fi
# `steps` must be a non-negative integer regardless of direction: 0
# means "everything" for direction=up, and direction=down requires an
# explicit positive value (checked separately below). Reject anything
# else before it can reach a migrate invocation in either direction.
if ! [[ "$STEPS" =~ ^(0|[1-9][0-9]*)$ ]]; then
echo "❌ 'steps' must be a non-negative integer (got '$STEPS')."
IS_SAFE=false
fi
# Down migrations must specify an explicit positive step count.
# steps=0 (the default) would run 'migrate down -all' and drop the
# entire schema, so it is rejected here for direction=down.
if [[ "$DIRECTION" == "down" ]]; then
if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then
echo "❌ direction=down requires an explicit positive 'steps' value (got '$STEPS')."
echo "Rolling back ALL migrations at once is not supported by this workflow."
IS_SAFE=false
fi
# Production down migrations additionally require typed confirmation.
if [[ "$ENVIRONMENT" == "prod" ]]; then
echo "⚠️ WARNING: Attempting to rollback migrations on PRODUCTION"
echo "This operation is destructive and may cause data loss!"
if [[ "$CONFIRM" != "rollback-prod" ]]; then
echo "❌ Production rollback requires typing 'rollback-prod' in the 'confirm' input."
IS_SAFE=false
fi
fi
fi
# Check migration files
if [ -d "$MIGRATIONS_PATH" ]; then
UP_COUNT=$(find "$MIGRATIONS_PATH" -maxdepth 1 -type f -name '*.up.sql' 2>/dev/null | wc -l)
DOWN_COUNT=$(find "$MIGRATIONS_PATH" -maxdepth 1 -type f -name '*.down.sql' 2>/dev/null | wc -l)
echo "Migration files found:"
echo " Up migrations: $UP_COUNT"
echo " Down migrations: $DOWN_COUNT"
if [ "$UP_COUNT" -eq 0 ]; then
echo "❌ No migration files found"
IS_SAFE=false
fi
fi
echo "is_safe=$IS_SAFE" >> "$GITHUB_OUTPUT"
if [[ "$IS_SAFE" != "true" ]]; then
echo "Validation failed; refusing to run migrations."
exit 1
fi
- name: Display migration plan
env:
CLOUD: ${{ inputs.cloud }}
ENVIRONMENT: ${{ inputs.environment }}
DIRECTION: ${{ inputs.direction }}
STEPS: ${{ inputs.steps || 'all' }}
run: |
set -uo pipefail
{
echo "## Database Migration Plan"
echo ""
echo "**Cloud:** $CLOUD"
echo "**Environment:** $ENVIRONMENT"
echo "**Direction:** $DIRECTION"
echo "**Steps:** $STEPS"
echo ""
if [[ "$ENVIRONMENT" == "prod" ]] && [[ "$DIRECTION" == "down" ]]; then
echo "⚠️ **WARNING:** This will rollback migrations on PRODUCTION!"
fi
} >> "$GITHUB_STEP_SUMMARY"
# Run AWS migrations
migrate-aws:
name: Migrate AWS Database
runs-on: ubuntu-latest
needs: validate
if: |
needs.validate.outputs.is_safe == 'true' &&
(inputs.cloud == 'aws' || inputs.cloud == 'all')
# Serializes migrations against one database: two dispatches for the same
# cloud and environment must not run `migrate` concurrently against the same
# schema_migrations table (#1593). Deliberately NOT the `aws-tfstate-*` group
# deploy-aws-lambda.yml and rollback.yml use: this job only runs `terraform
# init` + `terraform output -raw` to read the DB endpoint, takes no state
# lock, and the resource needing protection is the database, not the state
# file. Different clouds, and the same cloud in different environments, are
# different databases and stay concurrent.
#
# `inputs.environment` is a required `choice` on `workflow_dispatch`, so that
# path is constrained to dev|staging|prod server-side. On the `workflow_call`
# path it is a plain `type: string`; `required: true` makes the caller pass
# the key but GitHub does not enforce that the value is non-empty, and an
# empty suffix would collapse every environment into one group. The fallback
# below keeps such a call in its own group instead. `validate` rejects an
# environment outside the allowlist before this job runs, so the sentinel
# should be unreachable in practice; it is here so the group can never be
# silently ambiguous if that ordering ever changes.
#
# `cancel-in-progress: false` because cancelling mid-`migrate` leaves
# schema_migrations dirty, which then needs the CUDLY_FORCE_MIGRATION_VERSION
# recovery path in internal/database/postgres/migrations/migrate.go. Two
# consequences, both accepted as better than concurrent writers to one
# database:
# - GitHub keeps one pending entry per group, so a queued migration can be
# evicted by a later dispatch. Unlike rollback.yml, the `summary` job
# below does not exit 1 on a non-success result, so an evicted migration
# leaves the run green; read the per-cloud results it prints.
# - if the `environment:` binding below carries required reviewers, it is
# undocumented whether a job parked awaiting approval holds its group.
concurrency:
group: db-migrate-aws-${{ inputs.environment || 'unset' }}
cancel-in-progress: false
permissions:
id-token: write
contents: read
environment:
name: aws-db-${{ inputs.environment }}
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Install golang-migrate
# No @version suffix: installed as a package of this module, so the
# migrate version and its dependency set come from go.mod, the same way
# the Dockerfile and `make install-tools` build it. -tags 'pgx5' keeps
# lib/pq out of the binary that talks to the production database.
# See issue #1849.
run: go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
with:
role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME }}
aws-region: ${{ vars.AWS_REGION || 'us-east-1' }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Get database endpoint from Terraform
id: get-endpoint
env:
TF_BACKEND: ${{ secrets.TF_BACKEND_AWS }}
ENVIRONMENT: ${{ inputs.environment }}
COMPUTE_PLATFORM: ${{ inputs.compute_platform || 'lambda' }}
run: |
set -euo pipefail
cd terraform/environments/aws
# terraform/environments/aws is one root applied into two independent
# state objects, each with its own RDS instance (module.database has
# no `count` on compute_platform): github-<env>/ for Lambda deploys,
# github-fargate-<env>/ for Fargate. Picking the wrong one migrates a
# database nobody is serving traffic from while the live one stays on
# the old schema, so the namespace is an explicit operator input
# (`inputs.compute_platform`), never guessed. `validate` already
# rejects anything outside lambda|fargate before this job runs.
case "$COMPUTE_PLATFORM" in
lambda) STATE_KEY="github-${ENVIRONMENT}/terraform.tfstate" ;;
fargate) STATE_KEY="github-fargate-${ENVIRONMENT}/terraform.tfstate" ;;
*)
echo "::error::Unknown compute_platform: '$COMPUTE_PLATFORM'"
exit 1
;;
esac
printf '%s\nkey = "%s"\n' "$TF_BACKEND" "$STATE_KEY" > /tmp/backend.tfbackend
terraform init -backend-config=/tmp/backend.tfbackend
# No `2>/dev/null || echo ""` here: letting terraform's own error
# reach the log is what tells an operator WHICH cause they hit
# (output renamed, empty state for this environment/platform, wrong
# key, insufficient IAM on the state bucket) instead of the single
# undifferentiated "Failed to get database endpoint" below.
DB_ENDPOINT=$(terraform output -raw database_proxy_endpoint)
if [ -z "$DB_ENDPOINT" ]; then
echo "::error::terraform output returned an empty database_proxy_endpoint for compute_platform=$COMPUTE_PLATFORM, environment=$ENVIRONMENT"
exit 1
fi
echo "endpoint=$DB_ENDPOINT" >> "$GITHUB_OUTPUT"
- name: Run migrations
env:
# migrate reads the password from PGPASSWORD (pgx), so it never goes
# into the URL and needs no percent-encoding or derived-value mask.
PGPASSWORD: ${{ secrets.DB_PASSWORD_AWS }}
DB_ENDPOINT: ${{ steps.get-endpoint.outputs.endpoint }}
DIRECTION: ${{ inputs.direction }}
STEPS: ${{ inputs.steps }}
run: |
set -uo pipefail
# pgx5://, not postgresql://: migrate is installed with -tags 'pgx5'
# above and golang-migrate dispatches on the URL scheme (issue #1849).
DB_URL="pgx5://cudly@${DB_ENDPOINT}:5432/cudly?sslmode=require"
# `validate` already rejects a malformed `steps`/`direction` before this
# job is ever reached, but this job sits behind an environment gate and
# is the thing that actually runs `migrate`, so the checks are repeated
# here against the re-parsed shell variable as defense in depth.
STEPS="${STEPS:-0}"
if [[ "$DIRECTION" == "up" ]]; then
if ! [[ "$STEPS" =~ ^(0|[1-9][0-9]*)$ ]]; then
echo "❌ 'steps' must be a non-negative integer (got '$STEPS')."
exit 1
fi
if [[ "$STEPS" == "0" ]]; then
echo "Applying all pending migrations..."
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up
else
echo "Applying $STEPS migration(s)..."
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up "$STEPS"
fi
else
if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then
echo "❌ Refusing to roll back without an explicit positive 'steps' value."
exit 1
fi
echo "Rolling back $STEPS migration(s)..."
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" down "$STEPS"
fi
- name: Get migration version
env:
# migrate reads the password from PGPASSWORD (pgx), so it never goes
# into the URL and needs no percent-encoding or derived-value mask.
PGPASSWORD: ${{ secrets.DB_PASSWORD_AWS }}
DB_ENDPOINT: ${{ steps.get-endpoint.outputs.endpoint }}
run: |
set -uo pipefail
# pgx5://, not postgresql://: migrate is installed with -tags 'pgx5'
# above and golang-migrate dispatches on the URL scheme (issue #1849).
DB_URL="pgx5://cudly@${DB_ENDPOINT}:5432/cudly?sslmode=require"
VERSION=$(migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" version 2>&1 || echo "unknown")
echo "Current migration version: $VERSION"
echo "MIGRATION_VERSION=$VERSION" >> "$GITHUB_ENV"
# Run GCP migrations
migrate-gcp:
name: Migrate GCP Database
runs-on: ubuntu-latest
needs: validate
if: |
needs.validate.outputs.is_safe == 'true' &&
(inputs.cloud == 'gcp' || inputs.cloud == 'all')
# Serializes migrations against the GCP database for one environment
# (#1593). A different database from `migrate-aws`'s, so a different group:
# `cloud=all` still migrates the three clouds concurrently. The group-key,
# empty-suffix and `cancel-in-progress: false` reasoning documented on
# `migrate-aws` applies here identically.
concurrency:
group: db-migrate-gcp-${{ inputs.environment || 'unset' }}
cancel-in-progress: false
permissions:
id-token: write
contents: read
environment:
name: gcp-db-${{ inputs.environment }}
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Install golang-migrate
# No @version suffix: installed as a package of this module, so the
# migrate version and its dependency set come from go.mod, the same way
# the Dockerfile and `make install-tools` build it. -tags 'pgx5' keeps
# lib/pq out of the binary that talks to the production database.
# See issue #1849.
run: go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
- name: Setup Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Get database endpoint from Terraform
id: get-endpoint
env:
TF_BACKEND: ${{ secrets.TF_BACKEND_GCP }}
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -euo pipefail
printf '%s\nprefix = "github-%s"\n' "$TF_BACKEND" "$ENVIRONMENT" > /tmp/backend.tfbackend
cd terraform/environments/gcp
terraform init -backend-config=/tmp/backend.tfbackend
# No `2>/dev/null || echo ""`: let terraform's own error reach the
# log instead of collapsing every distinct cause into one message.
DB_ENDPOINT=$(terraform output -raw database_private_ip)
if [ -z "$DB_ENDPOINT" ]; then
echo "::error::terraform output returned an empty database_private_ip for environment=$ENVIRONMENT"
exit 1
fi
echo "endpoint=$DB_ENDPOINT" >> "$GITHUB_OUTPUT"
- name: Run migrations
env:
# migrate reads the password from PGPASSWORD (pgx), so it never goes
# into the URL and needs no percent-encoding or derived-value mask.
PGPASSWORD: ${{ secrets.DB_PASSWORD_GCP }}
DB_ENDPOINT: ${{ steps.get-endpoint.outputs.endpoint }}
DIRECTION: ${{ inputs.direction }}
STEPS: ${{ inputs.steps }}
run: |
set -uo pipefail
# pgx5://, not postgresql://: migrate is installed with -tags 'pgx5'
# above and golang-migrate dispatches on the URL scheme (issue #1849).
DB_URL="pgx5://cudly@${DB_ENDPOINT}:5432/cudly?sslmode=require"
STEPS="${STEPS:-0}"
if [[ "$DIRECTION" == "up" ]]; then
if ! [[ "$STEPS" =~ ^(0|[1-9][0-9]*)$ ]]; then
echo "❌ 'steps' must be a non-negative integer (got '$STEPS')."
exit 1
fi
if [[ "$STEPS" == "0" ]]; then
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up
else
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up "$STEPS"
fi
else
if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then
echo "❌ Refusing to roll back without an explicit positive 'steps' value."
exit 1
fi
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" down "$STEPS"
fi
# Run Azure migrations
migrate-azure:
name: Migrate Azure Database
runs-on: ubuntu-latest
needs: validate
if: |
needs.validate.outputs.is_safe == 'true' &&
(inputs.cloud == 'azure' || inputs.cloud == 'all')
# Serializes migrations against the Azure database for one environment
# (#1593). A different database from `migrate-aws`'s and `migrate-gcp`'s, so
# a different group: `cloud=all` still migrates the three clouds
# concurrently. The group-key, empty-suffix and `cancel-in-progress: false`
# reasoning documented on `migrate-aws` applies here identically.
concurrency:
group: db-migrate-azure-${{ inputs.environment || 'unset' }}
cancel-in-progress: false
permissions:
id-token: write
contents: read
environment:
name: azure-db-${{ inputs.environment }}
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Install golang-migrate
# No @version suffix: installed as a package of this module, so the
# migrate version and its dependency set come from go.mod, the same way
# the Dockerfile and `make install-tools` build it. -tags 'pgx5' keeps
# lib/pq out of the binary that talks to the production database.
# See issue #1849.
run: go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate
- name: Azure Login
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Get database endpoint from Terraform
id: get-endpoint
env:
TF_BACKEND: ${{ secrets.TF_BACKEND_AZURE }}
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -euo pipefail
printf '%s\nkey = "github-%s.terraform.tfstate"\n' "$TF_BACKEND" "$ENVIRONMENT" > /tmp/backend.tfbackend
cd terraform/environments/azure
terraform init -backend-config=/tmp/backend.tfbackend
# No `2>/dev/null || echo ""`: let terraform's own error reach the
# log instead of collapsing every distinct cause into one message.
DB_ENDPOINT=$(terraform output -raw database_server_fqdn)
if [ -z "$DB_ENDPOINT" ]; then
echo "::error::terraform output returned an empty database_server_fqdn for environment=$ENVIRONMENT"
exit 1
fi
echo "endpoint=$DB_ENDPOINT" >> "$GITHUB_OUTPUT"
- name: Run migrations
env:
# migrate reads the password from PGPASSWORD (pgx), so it never goes
# into the URL and needs no percent-encoding or derived-value mask.
PGPASSWORD: ${{ secrets.DB_PASSWORD_AZURE }}
DB_ENDPOINT: ${{ steps.get-endpoint.outputs.endpoint }}
DIRECTION: ${{ inputs.direction }}
STEPS: ${{ inputs.steps }}
run: |
set -uo pipefail
# pgx5://, not postgresql://: migrate is installed with -tags 'pgx5'
# above and golang-migrate dispatches on the URL scheme (issue #1849).
DB_URL="pgx5://cudly@${DB_ENDPOINT}:5432/cudly?sslmode=require"
STEPS="${STEPS:-0}"
if [[ "$DIRECTION" == "up" ]]; then
if ! [[ "$STEPS" =~ ^(0|[1-9][0-9]*)$ ]]; then
echo "❌ 'steps' must be a non-negative integer (got '$STEPS')."
exit 1
fi
if [[ "$STEPS" == "0" ]]; then
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up
else
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" up "$STEPS"
fi
else
if ! [[ "$STEPS" =~ ^[1-9][0-9]*$ ]]; then
echo "❌ Refusing to roll back without an explicit positive 'steps' value."
exit 1
fi
migrate -path "$MIGRATIONS_PATH" -database "$DB_URL" down "$STEPS"
fi
# Summary
summary:
name: Migration Summary
runs-on: ubuntu-latest
permissions:
contents: read
needs: [validate, migrate-aws, migrate-gcp, migrate-azure]
if: always()
steps:
- name: Post summary
env:
CLOUD: ${{ inputs.cloud }}
ENVIRONMENT: ${{ inputs.environment }}
DIRECTION: ${{ inputs.direction }}
RESULT_AWS: ${{ needs.migrate-aws.result }}
RESULT_GCP: ${{ needs.migrate-gcp.result }}
RESULT_AZURE: ${{ needs.migrate-azure.result }}
run: |
set -uo pipefail
{
echo "## Database Migration Results"
echo ""
echo "**Cloud:** $CLOUD"
echo "**Environment:** $ENVIRONMENT"
echo "**Direction:** $DIRECTION"
echo ""
echo "### Results"
if [[ "$CLOUD" == "aws" ]] || [[ "$CLOUD" == "all" ]]; then
echo "- AWS: $RESULT_AWS"
fi
if [[ "$CLOUD" == "gcp" ]] || [[ "$CLOUD" == "all" ]]; then
echo "- GCP: $RESULT_GCP"
fi
if [[ "$CLOUD" == "azure" ]] || [[ "$CLOUD" == "all" ]]; then
echo "- Azure: $RESULT_AZURE"
fi
} >> "$GITHUB_STEP_SUMMARY"