Skip to content

Commit e783855

Browse files
authored
Add preview deployment workflow for Azure Container Apps (#326)
1 parent aa01372 commit e783855

1 file changed

Lines changed: 322 additions & 0 deletions

File tree

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
name: Preview Deploy
2+
3+
on:
4+
pull_request:
5+
types: [labeled, unlabeled, synchronize]
6+
workflow_dispatch:
7+
inputs:
8+
branch:
9+
description: 'Branch name to deploy or clean up (PR not required)'
10+
required: true
11+
type: string
12+
action:
13+
description: 'Action to perform'
14+
type: choice
15+
options:
16+
- deploy
17+
- cleanup
18+
default: deploy
19+
20+
jobs:
21+
build-and-push:
22+
runs-on: ubuntu-latest
23+
if: |
24+
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'deploy') ||
25+
(github.event_name == 'pull_request' && github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'deploy')) ||
26+
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy')
27+
28+
permissions:
29+
contents: read
30+
packages: write
31+
32+
outputs:
33+
deploy_id: ${{ steps.resolve.outputs.deploy_id }}
34+
pr_number: ${{ steps.resolve.outputs.pr_number }}
35+
36+
steps:
37+
- name: Resolve deploy target
38+
id: resolve
39+
env:
40+
GH_TOKEN: ${{ github.token }}
41+
run: |
42+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
43+
BRANCH="${{ github.event.inputs.branch }}"
44+
PR_NUMBER=$(gh pr list --head "$BRANCH" --repo "${{ github.repository }}" --json number -q '.[0].number' 2>/dev/null || true)
45+
if [ -n "$PR_NUMBER" ]; then
46+
HEAD_SHA=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json headRefOid -q .headRefOid)
47+
DEPLOY_ID="pr-${PR_NUMBER}"
48+
echo "pr_number=${PR_NUMBER}" >> $GITHUB_OUTPUT
49+
else
50+
HEAD_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" -q .object.sha) \
51+
|| { echo "Branch '$BRANCH' not found"; exit 1; }
52+
SAFE_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | tr '/_.' '-' | tr -cd 'a-z0-9-' | cut -c1-20 | sed 's/-*$//')
53+
DEPLOY_ID="branch-${SAFE_BRANCH}"
54+
fi
55+
echo "deploy_id=${DEPLOY_ID}" >> $GITHUB_OUTPUT
56+
echo "head_sha=${HEAD_SHA}" >> $GITHUB_OUTPUT
57+
else
58+
echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
59+
echo "head_sha=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
60+
echo "deploy_id=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
61+
fi
62+
63+
- name: Checkout
64+
uses: actions/checkout@v4
65+
with:
66+
ref: ${{ steps.resolve.outputs.head_sha }}
67+
68+
- name: Login to GitHub Container Registry
69+
uses: docker/login-action@v3
70+
with:
71+
registry: ghcr.io
72+
username: ${{ github.actor }}
73+
password: ${{ secrets.GITHUB_TOKEN }}
74+
75+
- name: Build and push app image
76+
uses: docker/build-push-action@v5
77+
with:
78+
context: .
79+
file: Dockerfile
80+
push: true
81+
tags: ghcr.io/${{ github.repository }}-preview:preview-${{ steps.resolve.outputs.deploy_id }}
82+
83+
- name: Build and push sync image
84+
uses: docker/build-push-action@v5
85+
with:
86+
context: .
87+
file: Dockerfile.sync
88+
push: true
89+
tags: ghcr.io/${{ github.repository }}-sync-preview:preview-${{ steps.resolve.outputs.deploy_id }}
90+
91+
deploy:
92+
runs-on: ubuntu-latest
93+
needs: build-and-push
94+
if: |
95+
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'deploy') ||
96+
(github.event_name == 'pull_request' && github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'deploy')) ||
97+
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy')
98+
99+
permissions:
100+
id-token: write
101+
contents: read
102+
packages: write
103+
pull-requests: write
104+
deployments: write
105+
106+
environment:
107+
name: preview
108+
url: ${{ steps.app-url.outputs.url }}
109+
110+
env:
111+
DEPLOY_ID: ${{ needs.build-and-push.outputs.deploy_id }}
112+
PR_NUMBER: ${{ needs.build-and-push.outputs.pr_number }}
113+
PREVIEW_ALLOWED_IPS: ${{ secrets.PREVIEW_ALLOWED_IPS }}
114+
115+
steps:
116+
- name: Azure Login
117+
uses: azure/login@v2
118+
with:
119+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
120+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
121+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
122+
123+
- name: Create ACA Environment
124+
run: |
125+
az containerapp env create \
126+
--name cmv-preview-${{ env.DEPLOY_ID }} \
127+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
128+
--location ${{ secrets.AZURE_LOCATION }} \
129+
|| true
130+
131+
- name: Generate PostgreSQL password
132+
id: pgpass
133+
run: |
134+
PGPASSWORD=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32)
135+
echo "::add-mask::$PGPASSWORD"
136+
echo "pgpassword=$PGPASSWORD" >> $GITHUB_OUTPUT
137+
138+
- name: Deploy PostgreSQL container
139+
run: |
140+
az containerapp create \
141+
--name cmv-db \
142+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
143+
--environment cmv-preview-${{ env.DEPLOY_ID }} \
144+
--image postgres:15-alpine \
145+
--min-replicas 1 --max-replicas 1 \
146+
--ingress internal --transport tcp --target-port 5432 \
147+
--env-vars \
148+
POSTGRES_DB=copilot_metrics \
149+
POSTGRES_USER=metrics_user \
150+
POSTGRES_PASSWORD=${{ steps.pgpass.outputs.pgpassword }}
151+
152+
- name: Build DATABASE_URL
153+
id: dburl
154+
run: |
155+
DATABASE_URL="postgresql://metrics_user:${{ steps.pgpass.outputs.pgpassword }}@cmv-db:5432/copilot_metrics"
156+
echo "::add-mask::$DATABASE_URL"
157+
echo "database_url=${DATABASE_URL}" >> $GITHUB_OUTPUT
158+
159+
- name: Deploy app container
160+
run: |
161+
az containerapp create \
162+
--name cmv-app \
163+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
164+
--environment cmv-preview-${{ env.DEPLOY_ID }} \
165+
--image ghcr.io/${{ github.repository }}-preview:preview-${{ env.DEPLOY_ID }} \
166+
--ingress external --target-port 3000 \
167+
--min-replicas 1 --max-replicas 1 \
168+
--env-vars \
169+
NUXT_SESSION_PASSWORD=${{ secrets.NUXT_SESSION_PASSWORD }} \
170+
NUXT_GITHUB_TOKEN=${{ secrets.PREVIEW_GITHUB_TOKEN }} \
171+
DATABASE_URL=${{ steps.dburl.outputs.database_url }} \
172+
ENABLE_HISTORICAL_MODE=true \
173+
SYNC_ENABLED=false \
174+
NITRO_PORT=3000 \
175+
NUXT_PUBLIC_IS_DATA_MOCKED=false \
176+
NUXT_PUBLIC_SCOPE=${{ secrets.PREVIEW_SCOPE || 'organization' }} \
177+
NUXT_PUBLIC_GITHUB_ORG=${{ secrets.PREVIEW_GITHUB_ORG }}
178+
179+
- name: Restrict app ingress to allowed IPs
180+
if: ${{ env.PREVIEW_ALLOWED_IPS != '' }}
181+
run: |
182+
az containerapp ingress access-restriction set \
183+
--name cmv-app \
184+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
185+
--rule-name allow-preview-ips \
186+
--ip-address "${{ secrets.PREVIEW_ALLOWED_IPS }}" \
187+
--action Allow
188+
189+
- name: Deploy sync job
190+
run: |
191+
az containerapp job create \
192+
--name cmv-sync \
193+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
194+
--environment cmv-preview-${{ env.DEPLOY_ID }} \
195+
--image ghcr.io/${{ github.repository }}-sync-preview:preview-${{ env.DEPLOY_ID }} \
196+
--trigger-type Schedule \
197+
--cron-expression "0 2 * * *" \
198+
--replica-timeout 1800 \
199+
--replica-retry-limit 1 \
200+
--env-vars \
201+
NUXT_GITHUB_TOKEN=${{ secrets.PREVIEW_GITHUB_TOKEN }} \
202+
DATABASE_URL=${{ steps.dburl.outputs.database_url }} \
203+
ENABLE_HISTORICAL_MODE=true \
204+
NUXT_PUBLIC_IS_DATA_MOCKED=false \
205+
NUXT_PUBLIC_SCOPE=${{ secrets.PREVIEW_SCOPE || 'organization' }} \
206+
NUXT_PUBLIC_GITHUB_ORG=${{ secrets.PREVIEW_GITHUB_ORG }}
207+
208+
- name: Get app URL
209+
id: app-url
210+
run: |
211+
APP_FQDN=$(az containerapp show \
212+
--name cmv-app \
213+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
214+
--query "properties.configuration.ingress.fqdn" \
215+
-o tsv)
216+
echo "url=https://${APP_FQDN}" >> $GITHUB_OUTPUT
217+
218+
- name: Comment on PR with preview URL
219+
if: ${{ env.PR_NUMBER != '' }}
220+
uses: actions/github-script@v7
221+
with:
222+
script: |
223+
const prNumber = parseInt('${{ env.PR_NUMBER }}', 10);
224+
const url = '${{ steps.app-url.outputs.url }}';
225+
const body = `## 🚀 Preview Deployment Ready\n\n` +
226+
`Preview environment for PR #${prNumber} has been deployed.\n\n` +
227+
`**Preview URL:** ${url}\n\n` +
228+
`> This preview will be cleaned up when the \`deploy\` label is removed.`;
229+
github.rest.issues.createComment({
230+
owner: context.repo.owner,
231+
repo: context.repo.repo,
232+
issue_number: prNumber,
233+
body
234+
});
235+
236+
cleanup:
237+
runs-on: ubuntu-latest
238+
if: |
239+
(github.event_name == 'pull_request' && github.event.action == 'unlabeled' && github.event.label.name == 'deploy') ||
240+
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'cleanup')
241+
242+
permissions:
243+
id-token: write
244+
contents: read
245+
pull-requests: write
246+
deployments: write
247+
248+
env:
249+
DEPLOY_ID: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || '' }}
250+
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
251+
252+
steps:
253+
- name: Resolve deploy ID (workflow_dispatch only)
254+
if: github.event_name == 'workflow_dispatch'
255+
env:
256+
GH_TOKEN: ${{ github.token }}
257+
run: |
258+
BRANCH="${{ github.event.inputs.branch }}"
259+
PR_NUMBER=$(gh pr list --head "$BRANCH" --repo "${{ github.repository }}" --json number -q '.[0].number' 2>/dev/null || true)
260+
if [ -n "$PR_NUMBER" ]; then
261+
echo "DEPLOY_ID=pr-${PR_NUMBER}" >> $GITHUB_ENV
262+
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
263+
else
264+
SAFE_BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | tr '/_.' '-' | tr -cd 'a-z0-9-' | cut -c1-20 | sed 's/-*$//')
265+
echo "DEPLOY_ID=branch-${SAFE_BRANCH}" >> $GITHUB_ENV
266+
fi
267+
268+
- name: Azure Login
269+
uses: azure/login@v2
270+
with:
271+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
272+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
273+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
274+
275+
- name: Delete app container
276+
run: |
277+
az containerapp delete \
278+
--name cmv-app \
279+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
280+
--yes \
281+
|| true
282+
283+
- name: Delete sync job
284+
run: |
285+
az containerapp job delete \
286+
--name cmv-sync \
287+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
288+
--yes \
289+
|| true
290+
291+
- name: Delete PostgreSQL container
292+
run: |
293+
az containerapp delete \
294+
--name cmv-db \
295+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
296+
--yes \
297+
|| true
298+
299+
- name: Delete ACA Environment
300+
run: |
301+
az containerapp env delete \
302+
--name cmv-preview-${{ env.DEPLOY_ID }} \
303+
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
304+
--yes \
305+
|| true
306+
307+
- name: Comment on PR about cleanup
308+
if: ${{ env.PR_NUMBER != '' }}
309+
uses: actions/github-script@v7
310+
with:
311+
script: |
312+
const prNumber = parseInt('${{ env.PR_NUMBER }}', 10);
313+
const body = `## 🧹 Preview Environment Cleaned Up\n\n` +
314+
`The preview environment for PR #${prNumber} has been removed.`;
315+
github.rest.issues.createComment({
316+
owner: context.repo.owner,
317+
repo: context.repo.repo,
318+
issue_number: prNumber,
319+
body
320+
});
321+
322+

0 commit comments

Comments
 (0)