1515 description : ' ECR image tag to deploy (e.g. r33)'
1616 required : true
1717 type : string
18+ migration_type :
19+ description : ' Choose when to run database migrations: before updating the service (pre-deploy) or after (post-deploy).'
20+ required : true
21+ type : choice
22+ default : ' none'
23+ options :
24+ - none
25+ - pre-deploy
26+ - post-deploy
1827
1928env :
2029 AWS_REGION : us-east-1
@@ -23,13 +32,20 @@ permissions:
2332 id-token : write
2433 contents : read
2534
35+ # One active deployment per environment at a time — queue rather than cancel
36+ # so in-progress deploys finish before the next one starts.
37+ concurrency :
38+ group : deploy-api-${{ inputs.environment }}
39+ cancel-in-progress : false
40+
2641jobs :
27- deploy :
28- name : Deploy ${{ inputs.image_tag || github.sha }} to ${{ inputs.environment || 'test' }}
29- # sandbox uses prod CodeBuild runner (same account)
42+ prepare :
43+ name : Prepare ${{ inputs.image_tag }} for ${{ inputs.environment || 'test' }}
44+ # sandbox shares the prod CodeBuild runner and ECR repo
3045 runs-on :
3146 - codebuild-bb-${{ (inputs.environment || 'test') == 'test' && 'test' || 'prod' }}-web-server-${{ github.run_id }}-${{ github.run_attempt }}
32- environment : ${{ inputs.environment || 'test' }}
47+ outputs :
48+ task_definition_arn : ${{ steps.register-task.outputs.task_definition_arn }}
3349
3450 steps :
3551 - uses : actions/checkout@v4
5975 id : verify-image
6076 run : |
6177 ENV="${{ inputs.environment || 'test' }}"
62- # sandbox and prod share the same ECR repo (bb-prod-api)
6378 BUCKET_ENV="${ENV}"
6479 if [[ "$ENV" == "sandbox" ]]; then
6580 BUCKET_ENV="prod"
@@ -79,20 +94,19 @@ jobs:
7994 echo "Image verified: ${REPO_NAME}:${IMAGE_TAG}"
8095
8196 - name : Get current task definition
82- id : current-task
8397 run : |
8498 ENV="${{ inputs.environment || 'test' }}"
8599 FAMILY="bb-${ENV}-api-family"
86100
87101 echo "Fetching current task definition for ${FAMILY}..."
88102 TASK_DEF=$(aws ecs describe-task-definition --task-definition "$FAMILY" --query 'taskDefinition')
89103
90- # Update the image in the container definition
104+ CONTAINER_NAME="bb-${ENV}-api"
91105 UPDATED=$(echo "$TASK_DEF" | jq \
92106 --arg IMAGE "${{ steps.verify-image.outputs.image }}" \
93- '.containerDefinitions[0].image = $IMAGE')
107+ --arg CONTAINER "$CONTAINER_NAME" \
108+ '(.containerDefinitions[] | select(.name == $CONTAINER) | .image) = $IMAGE')
94109
95- # Strip fields that cannot be included in register-task-definition
96110 CLEANED=$(echo "$UPDATED" | jq 'del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)')
97111
98112 echo "$CLEANED" > /tmp/task-definition.json
@@ -109,17 +123,89 @@ jobs:
109123 echo "task_definition_arn=$NEW_TASK_DEF" >> "$GITHUB_OUTPUT"
110124 echo "Registered: $NEW_TASK_DEF"
111125
126+ # Run migrations before the service update.
127+ # First, we check for pending migrations. Then, we wait for a team member to approve the deployment.
128+ # Finally, we run the migrations. If any of these steps fail, the deployment stops to prevent issues.
129+
130+ pre-showmigrations :
131+ name : Pre-deploy showmigrations on ${{ inputs.environment || 'test' }}
132+ needs : prepare
133+ if : inputs.migration_type == 'pre-deploy'
134+ uses : ./.github/workflows/run-manage-command.yml
135+ with :
136+ environment : ${{ inputs.environment || 'test' }}
137+ command : showmigrations
138+ task_definition_arn : ${{ needs.prepare.outputs.task_definition_arn }}
139+ secrets : inherit
140+
141+ approve-pre-migration :
142+ name : Approve pre-deploy migration on ${{ inputs.environment || 'test' }}
143+ needs : pre-showmigrations
144+ if : inputs.migration_type == 'pre-deploy'
145+ runs-on : ubuntu-latest
146+ timeout-minutes : 60
147+ environment : ${{ inputs.environment || 'test' }}
148+ steps :
149+ - name : Confirm approval
150+ run : echo "Approved by ${{ github.actor }} — running migrate --noinput against the new image."
151+
152+ pre-migrate :
153+ name : Pre-deploy migrate --noinput on ${{ inputs.environment || 'test' }}
154+ needs : [prepare, approve-pre-migration]
155+ if : inputs.migration_type == 'pre-deploy'
156+ uses : ./.github/workflows/run-manage-command.yml
157+ with :
158+ environment : ${{ inputs.environment || 'test' }}
159+ command : migrate --noinput
160+ task_definition_arn : ${{ needs.prepare.outputs.task_definition_arn }}
161+ secrets : inherit
162+
163+ update-service :
164+ name : Deploy ${{ inputs.image_tag || github.sha }} to ${{ inputs.environment || 'test' }}
165+ needs : [prepare, pre-showmigrations, approve-pre-migration, pre-migrate]
166+ if : ${{ !failure() && !cancelled() }}
167+ runs-on :
168+ - codebuild-bb-${{ (inputs.environment || 'test') == 'test' && 'test' || 'prod' }}-web-server-${{ github.run_id }}-${{ github.run_attempt }}
169+ # Gate the deploy for 'none' and 'post-deploy' — in both cases the deploy happens
170+ # before any migration approval, so it needs its own gate.
171+ # Only 'pre-deploy' skips this gate: its approval already ran upstream (approve-pre-migration),
172+ # and the deploy auto-follows the same image right after the migration.
173+ environment : ${{ inputs.migration_type != 'pre-deploy' && (inputs.environment || 'test') || '' }}
174+
175+ steps :
176+ - uses : actions/checkout@v4
177+
178+ - name : Setup AWS credentials
179+ uses : ./.github/actions/setup-tofu
180+ with :
181+ environment : ${{ inputs.environment || 'test' }}
182+ non-prod-account : ${{ secrets.NON_PROD_ACCOUNT }}
183+ prod-account : ${{ secrets.PROD_ACCOUNT }}
184+ test-role-id : ${{ secrets.TEST_AWS_ROLE_ID_MASK }}
185+ prod-role-id : ${{ secrets.PROD_AWS_ROLE_ID_MASK }}
186+
112187 - name : Update ECS service
113188 run : |
114189 ENV="${{ inputs.environment || 'test' }}"
115190 CLUSTER="bb-${ENV}-cluster"
116191 SERVICE="bb-${ENV}-api-service"
192+ TASK_DEF="${{ needs.prepare.outputs.task_definition_arn }}"
193+
194+ if [[ -z "$TASK_DEF" ]]; then
195+ echo "Warning: TASK_DEF from prepare job is empty. Querying the latest task definition for family bb-${ENV}-api-family..."
196+ TASK_DEF=$(aws ecs describe-task-definition --task-definition "bb-${ENV}-api-family" --query 'taskDefinition.taskDefinitionArn' --output text)
197+ fi
117198
118- echo "Updating ${SERVICE} on ${CLUSTER}..."
199+ if [[ -z "$TASK_DEF" ]]; then
200+ echo "::error::Could not resolve task definition ARN."
201+ exit 1
202+ fi
203+
204+ echo "Updating ${SERVICE} on ${CLUSTER} with task definition ${TASK_DEF}..."
119205 aws ecs update-service \
120206 --cluster "$CLUSTER" \
121207 --service "$SERVICE" \
122- --task-definition "${{ steps.register-task.outputs.task_definition_arn }} " \
208+ --task-definition "$TASK_DEF " \
123209 --force-new-deployment \
124210 --query 'service.serviceName' \
125211 --output text
@@ -139,3 +225,38 @@ jobs:
139225 }
140226
141227 echo "Deployment complete and stable."
228+
229+ # Run migrations after the service update.
230+ # The new code is already running, so we run migrations on the live database.
231+ # We still check for pending migrations, wait for approval, and then run them.
232+
233+ post-showmigrations :
234+ name : Post-deploy showmigrations on ${{ inputs.environment || 'test' }}
235+ needs : update-service
236+ if : inputs.migration_type == 'post-deploy'
237+ uses : ./.github/workflows/run-manage-command.yml
238+ with :
239+ environment : ${{ inputs.environment || 'test' }}
240+ command : showmigrations
241+ secrets : inherit
242+
243+ approve-post-migration :
244+ name : Approve post-deploy migration on ${{ inputs.environment || 'test' }}
245+ needs : post-showmigrations
246+ if : inputs.migration_type == 'post-deploy'
247+ runs-on : ubuntu-latest
248+ timeout-minutes : 60
249+ environment : ${{ inputs.environment || 'test' }}
250+ steps :
251+ - name : Confirm approval
252+ run : echo "Approved by ${{ github.actor }} — running migrate --noinput against the live service."
253+
254+ post-migrate :
255+ name : Post-deploy migrate --noinput on ${{ inputs.environment || 'test' }}
256+ needs : approve-post-migration
257+ if : inputs.migration_type == 'post-deploy'
258+ uses : ./.github/workflows/run-manage-command.yml
259+ with :
260+ environment : ${{ inputs.environment || 'test' }}
261+ command : migrate --noinput
262+ secrets : inherit
0 commit comments