-
Notifications
You must be signed in to change notification settings - Fork 0
207 lines (193 loc) · 10.1 KB
/
Copy pathdeploy.yml
File metadata and controls
207 lines (193 loc) · 10.1 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
# =============================================================================
# hasna self-hosted deploy pipeline — drop this file in .github/workflows/deploy.yml
#
# The ONLY per-repo edit is the `APP` env var below. Everything else is derived
# from AWS at run time via the /hasna/deploy/<APP> SSM manifest published by the
# hasna-app Terraform module (modules/deploy-oidc-role).
#
# What it does:
# 1. Assumes the per-repo least-priv OIDC role (no long-lived keys).
# 2. Builds a NATIVE arm64 image on a GitHub arm64 runner (no QEMU emulation).
# 3. Reuses an existing immutable source tag or builds and pushes it to ECR.
# 4. Runs the one-shot DB migration task and fails hard on non-zero exit.
# 5. Registers a new web task-def revision and updates the ECS service.
# 6. Waits for the service to reach steady state and asserts the deployment
# circuit breaker reported COMPLETED (rollback => job fails).
#
# Prereqs (created by the hasna-app module): ECR repo, ECS cluster/service,
# web + migration task-def families, task/execution roles, and the SSM manifest.
# The role name is <APP>-prod-gha-deploy; its trust is pinned to
# repo:hasna/<APP>:environment:production, so this job MUST run in the
# `production` GitHub Environment (set below).
# =============================================================================
name: deploy
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch: {}
# Serialize production deploys; never cancel an in-flight one mid-rollout.
concurrency:
group: deploy-production
cancel-in-progress: false
permissions:
contents: read
id-token: write # required to mint the GitHub OIDC token
env:
# >>> THE ONLY LINE EACH REPO CHANGES <<<
APP: conversations
# Locked platform defaults.
AWS_REGION: us-east-1
AWS_ACCOUNT_ID: "789877399345"
jobs:
deploy:
name: build + migrate + deploy
runs-on: ubuntu-24.04-arm # native arm64 runner — image is built without QEMU
environment: production # MUST match the OIDC subject repo:hasna/<APP>:environment:production
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
persist-credentials: false
# The v* namespace is not covered by this repository's protected npm/**
# tag ruleset, and workflow_dispatch can select a non-default ref. Bind
# every deploy to reviewed main history before the job can request an AWS
# identity or perform any production mutation.
- name: Require the deploy commit on protected main
run: |
set -euo pipefail
if ! git merge-base --is-ancestor "${GITHUB_SHA}" "refs/remotes/origin/main"; then
echo "::error::deploy commit ${GITHUB_SHA} is not contained in protected main"
exit 1
fi
echo "deploy commit ${GITHUB_SHA} is contained in protected main"
- name: Configure AWS credentials (GitHub OIDC)
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.APP }}-prod-gha-deploy
aws-region: ${{ env.AWS_REGION }}
role-session-name: gha-deploy-${{ env.APP }}-${{ github.run_id }}
- name: Load deploy manifest
id: m
run: |
set -euo pipefail
M="$(aws ssm get-parameter --name "/hasna/deploy/${APP}" --query Parameter.Value --output text)"
get() { jq -er ".$1" <<<"$M"; }
{
echo "cluster=$(get cluster)"
echo "service=$(get service)"
echo "web_family=$(get web_task_family)"
echo "web_container=$(get web_container)"
echo "mig_family=$(get migration_task_family)"
echo "mig_container=$(get migration_container)"
echo "ecr_url=$(get ecr_repository_url)"
echo "assign_public_ip=$(get assign_public_ip)"
echo "subnets=$(jq -er '.subnets | join(",")' <<<"$M")"
echo "sgs=$(jq -er '.security_groups | join(",")' <<<"$M")"
} >> "$GITHUB_OUTPUT"
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Resolve or build native arm64 image
id: build
env:
ECR_URL: ${{ steps.m.outputs.ecr_url }}
run: |
set -euo pipefail
bash scripts/ci/resolve-ecr-image.sh
- name: Run one-shot migration task
env:
IMAGE: ${{ steps.build.outputs.image }}
CLUSTER: ${{ steps.m.outputs.cluster }}
MIG_FAMILY: ${{ steps.m.outputs.mig_family }}
MIG_CONTAINER: ${{ steps.m.outputs.mig_container }}
SUBNETS: ${{ steps.m.outputs.subnets }}
SGS: ${{ steps.m.outputs.sgs }}
ASSIGN: ${{ steps.m.outputs.assign_public_ip }}
run: |
set -euo pipefail
# Register a migration revision pinned to the new image.
NEW_TD="$(aws ecs describe-task-definition --task-definition "$MIG_FAMILY" \
--query taskDefinition | jq --arg img "$IMAGE" --arg c "$MIG_CONTAINER" '
.containerDefinitions |= map(if .name==$c then .image=$img else . end)
| del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy,.deregisteredAt)')"
MIG_ARN="$(aws ecs register-task-definition --cli-input-json "$NEW_TD" \
--query taskDefinition.taskDefinitionArn --output text)"
echo "Running migration task def: $MIG_ARN"
TASK_ARN="$(aws ecs run-task --cluster "$CLUSTER" --task-definition "$MIG_ARN" \
--launch-type FARGATE --count 1 \
--started-by "gha-migrate-${GITHUB_RUN_ID}" \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SGS],assignPublicIp=$ASSIGN}" \
--query 'tasks[0].taskArn' --output text)"
if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then
echo "::error::migration task failed to start"; exit 1
fi
echo "Waiting for migration task to stop: $TASK_ARN"
aws ecs wait tasks-stopped --cluster "$CLUSTER" --tasks "$TASK_ARN"
DESC="$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN")"
EXIT="$(jq -r ".tasks[0].containers[] | select(.name==\"$MIG_CONTAINER\") | .exitCode // \"null\"" <<<"$DESC")"
REASON="$(jq -r '.tasks[0].stoppedReason // ""' <<<"$DESC")"
echo "migration exitCode=$EXIT stoppedReason=$REASON"
if [ "$EXIT" != "0" ]; then
echo "::error::migration task did not exit 0 (exit=$EXIT, reason=$REASON)"; exit 1
fi
- name: Deploy service (new revision) and wait for stable
env:
IMAGE: ${{ steps.build.outputs.image }}
CLUSTER: ${{ steps.m.outputs.cluster }}
SERVICE: ${{ steps.m.outputs.service }}
WEB_FAMILY: ${{ steps.m.outputs.web_family }}
WEB_CONTAINER: ${{ steps.m.outputs.web_container }}
run: |
set -euo pipefail
NEW_TD="$(aws ecs describe-task-definition --task-definition "$WEB_FAMILY" \
--query taskDefinition | jq --arg img "$IMAGE" --arg c "$WEB_CONTAINER" '
.containerDefinitions |= map(if .name==$c then .image=$img else . end)
| del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy,.deregisteredAt)')"
WEB_ARN="$(aws ecs register-task-definition --cli-input-json "$NEW_TD" \
--query taskDefinition.taskDefinitionArn --output text)"
echo "Updating $SERVICE -> $WEB_ARN"
aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" \
--task-definition "$WEB_ARN" >/dev/null
echo "Waiting for service to reach steady state..."
aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE"
# Deployment circuit breaker: a rolled-back deploy is a FAILED deploy.
# After a rollback the PRIMARY deployment can still report COMPLETED (the
# *rollback* completed) while running the OLD task def, so assert BOTH the
# rollout state AND that the live PRIMARY task def is the one we deployed.
# ECS can briefly return IN_PROGRESS here after services-stable succeeds,
# so poll only that state within a bounded verification window.
MAX_ROLLOUT_POLLS="${ECS_ROLLOUT_MAX_ATTEMPTS:-30}"
ROLLOUT_POLL_SECONDS="${ECS_ROLLOUT_POLL_SECONDS:-5}"
RS=""
LIVE_TD=""
for ((i=1; i<=MAX_ROLLOUT_POLLS; i++)); do
SVC="$(aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE")"
RS="$(jq -r '[.services[0].deployments[]? | select(.status=="PRIMARY")][0].rolloutState // ""' <<<"$SVC")"
LIVE_TD="$(jq -r '[.services[0].deployments[]? | select(.status=="PRIMARY")][0].taskDefinition // ""' <<<"$SVC")"
echo "[rollout $i/$MAX_ROLLOUT_POLLS] primary rolloutState=$RS liveTaskDef=$LIVE_TD deployed=$WEB_ARN"
case "$RS" in
COMPLETED)
if [ "$LIVE_TD" != "$WEB_ARN" ]; then
echo "::error::live task def ($LIVE_TD) != deployed ($WEB_ARN) — deployment was rolled back"; exit 1
fi
break
;;
FAILED)
echo "::error::deployment rolloutState=FAILED — circuit-breaker rollback"; exit 1
;;
IN_PROGRESS)
;;
*)
echo "::error::deployment returned unexpected rolloutState=$RS"; exit 1
;;
esac
if [ "$i" -eq "$MAX_ROLLOUT_POLLS" ]; then
echo "::error::deployment rollout timed out (rolloutState=$RS liveTaskDef=$LIVE_TD deployed=$WEB_ARN)"; exit 1
fi
sleep "$ROLLOUT_POLL_SECONDS"
done
echo "Deploy of ${APP} @ ${GITHUB_SHA} succeeded."