Skip to content

Commit 126cc14

Browse files
committed
ci(dev): deploy Trigger tasks independently of app images
1 parent bdc3598 commit 126cc14

3 files changed

Lines changed: 205 additions & 15 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,13 @@ jobs:
226226
MSHIP_PLAN_MODE_DEFAULT=true
227227
max-cache-size-mb: ${{ matrix.cache_mb }}
228228

229-
# Build and upload tasks alongside tests and images. The unpromoted version
230-
# cannot serve new runs; promote-images waits for it and successful migrations.
229+
# Staging/production coordinate task releases with app traffic cutover.
230+
# Dev tasks deploy independently in deploy-trigger-dev.yml.
231231
prepare-trigger:
232232
name: Prepare Trigger.dev
233233
if: >-
234234
github.event_name == 'push' &&
235-
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
235+
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging')
236236
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
237237
timeout-minutes: 15
238238
outputs:
@@ -268,7 +268,6 @@ jobs:
268268
case "$GITHUB_REF" in
269269
refs/heads/main) TRIGGER_ENV=prod; TRIGGER_BRANCH='' ;;
270270
refs/heads/staging) TRIGGER_ENV=staging; TRIGGER_BRANCH='' ;;
271-
refs/heads/dev) TRIGGER_ENV=preview; TRIGGER_BRANCH=dev-sim ;;
272271
*) echo "ERROR: unsupported Trigger release ref: $GITHUB_REF" >&2; exit 1 ;;
273272
esac
274273
echo "environment=$TRIGGER_ENV" >> "$GITHUB_OUTPUT"
@@ -429,8 +428,9 @@ jobs:
429428
tags: ${{ steps.meta.outputs.tags }}
430429
max-cache-size-mb: ${{ matrix.cache_mb }}
431430

432-
# Promote the sha-tagged ECR images once tests, migrations, and the Trigger
433-
# upload pass. Pushing the ECR latest/staging tag is what triggers
431+
# Promote the sha-tagged ECR images once their build and migrations pass.
432+
# Staging/production also require the Trigger upload; dev never waits for it.
433+
# Pushing the ECR latest/staging/dev tag is what triggers
434434
# CodePipeline, so this seconds-long manifest retag is the deploy gate —
435435
# the image builds themselves run in parallel with the tests. A single job
436436
# (not a matrix) so all four sha manifests are verified before any tag
@@ -441,9 +441,9 @@ jobs:
441441
# Explicit results: see migrate's comment.
442442
if: >-
443443
!cancelled() && github.event_name == 'push' &&
444-
needs.prepare-trigger.result == 'success' &&
445444
(
446445
((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') &&
446+
needs.prepare-trigger.result == 'success' &&
447447
needs.migrate.result == 'success' &&
448448
needs.build-amd64.result == 'success') ||
449449
(github.ref == 'refs/heads/dev' &&
@@ -549,7 +549,7 @@ jobs:
549549
fi
550550
done
551551
552-
# Promote the parked Trigger.dev version after observing the ECS
552+
# Staging/production: promote the parked Trigger.dev version after observing the ECS
553553
# traffic cutover (CodeDeploy AllowTraffic on every target). The image retag
554554
# triggers the ECS pipeline; this job correlates it via the digest + retag epoch
555555
# (rejecting a stale execution reusing the digest) and promotes at cutover.
@@ -563,14 +563,13 @@ jobs:
563563
if: >-
564564
!cancelled() &&
565565
github.event_name == 'push' &&
566-
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') &&
566+
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') &&
567567
needs.promote-images.result == 'success' &&
568568
needs.prepare-trigger.result == 'success' &&
569569
needs.promote-images.outputs.promoted == 'true'
570570
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
571-
# Leave setup/promotion headroom above the cutover poll (dev: 20 min;
572-
# staging/prod: 70 min, including a deploy queued behind a long bake).
573-
timeout-minutes: ${{ github.ref == 'refs/heads/dev' && 40 || 90 }}
571+
# Leave setup/promotion headroom above the 70-minute cutover poll.
572+
timeout-minutes: 90
574573
permissions:
575574
contents: read
576575
id-token: write
@@ -602,14 +601,13 @@ jobs:
602601
with:
603602
role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
604603
aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }}
605-
# Match each environment's session budget; both outlast their polls.
606-
role-duration-seconds: ${{ github.ref == 'refs/heads/dev' && 2400 || 5400 }}
604+
role-duration-seconds: 5400
607605

608606
# An unchanged tag may belong to a failed or still-running earlier deploy.
609607
# Verify its latest cutover rather than treating tag equality as success.
610608
- name: Wait for ECS traffic cutover
611609
env:
612-
OVERALL_TIMEOUT: ${{ github.ref == 'refs/heads/dev' && 1200 || 4200 }}
610+
OVERALL_TIMEOUT: 4200
613611
APP_IMAGE_CHANGED: ${{ needs.promote-images.outputs.app_image_changed }}
614612
DIGEST: ${{ needs.promote-images.outputs.app_image_digest }}
615613
PIPELINE: sim-${{ github.ref == 'refs/heads/main' && 'production' || github.ref == 'refs/heads/dev' && 'dev' || 'staging' }}-us-east-1-app-deployment
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: Deploy Dev Tasks
2+
3+
# Independent of app CI: task packaging and deployment must never delay dev images.
4+
on:
5+
push:
6+
branches: [dev]
7+
8+
permissions:
9+
contents: read
10+
11+
# Serialize promotions without cancelling an external deployment in flight.
12+
# Pending pushes coalesce to the newest run while the current run finishes.
13+
concurrency:
14+
group: deploy-trigger-dev
15+
cancel-in-progress: false
16+
17+
jobs:
18+
deploy:
19+
name: Deploy Trigger.dev preview
20+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
21+
timeout-minutes: 30
22+
steps:
23+
- name: Checkout code
24+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
25+
26+
- name: Setup Bun
27+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
28+
with:
29+
bun-version: 1.4.1
30+
31+
- name: Cache Bun dependencies
32+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
33+
with:
34+
path: |
35+
~/.bun/install/cache
36+
node_modules
37+
**/node_modules
38+
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
39+
restore-keys: |
40+
${{ runner.os }}-bun-
41+
42+
- name: Install dependencies
43+
run: bun install --frozen-lockfile --ignore-scripts
44+
45+
- name: Upload preview version
46+
id: deploy
47+
working-directory: ./apps/sim
48+
env:
49+
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
50+
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
51+
run: |
52+
set -euo pipefail
53+
: "${TRIGGER_ACCESS_TOKEN:?TRIGGER_ACCESS_TOKEN must be configured}"
54+
: "${TRIGGER_PROJECT_ID:?TRIGGER_PROJECT_ID must be configured}"
55+
bunx trigger.dev@4.5.12 deploy --env preview --branch dev-sim --skip-promotion
56+
57+
- name: Promote current dev preview
58+
working-directory: ./apps/sim
59+
env:
60+
GH_TOKEN: ${{ github.token }}
61+
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
62+
TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }}
63+
VERSION: ${{ steps.deploy.outputs.deploymentVersion }}
64+
run: |
65+
set -euo pipefail
66+
if ! [[ "$VERSION" =~ ^[0-9]{8}\.[0-9]+$ ]]; then
67+
echo "ERROR: Trigger.dev did not report a valid deploymentVersion output" >&2
68+
exit 1
69+
fi
70+
CURRENT_SHA=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/dev" --jq '.object.sha')
71+
if [ "$CURRENT_SHA" != "$GITHUB_SHA" ]; then
72+
echo "::notice::Skipping superseded dev task version $VERSION"
73+
exit 0
74+
fi
75+
bunx trigger.dev@4.5.12 promote "$VERSION" --env preview --branch dev-sim
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { readFileSync } from 'node:fs'
3+
import { runInNewContext } from 'node:vm'
4+
import { describe, expect, it } from 'vitest'
5+
6+
const ci = readFileSync(new URL('../.github/workflows/ci.yml', import.meta.url), 'utf8')
7+
const tasks = readFileSync(
8+
new URL('../.github/workflows/deploy-trigger-dev.yml', import.meta.url),
9+
'utf8'
10+
)
11+
12+
/** Evaluate the checked-in job condition for concrete GitHub job outcomes. */
13+
function eligible(job: string, branch: string, results: Record<string, string> = {}) {
14+
const body = ci.split(`\n ${job}:\n`)[1]?.split(/^ {2}[\w-]+:/m)[0]
15+
const expression = body?.match(/ {4}if: >-\n((?: {6}.*\n)+)/)?.[1]
16+
if (!expression) throw new Error(`Missing job condition: ${job}`)
17+
const needs = Object.fromEntries(
18+
Object.entries({
19+
migrate: 'success',
20+
'build-amd64': 'success',
21+
'migrate-dev': 'success',
22+
'build-dev': 'success',
23+
'prepare-trigger': 'success',
24+
'promote-images': 'success',
25+
...results,
26+
}).map(([name, result]) => [name, { result, outputs: { promoted: 'true' } }])
27+
)
28+
return runInNewContext(expression.replace(/needs\.([\w-]+)/g, 'needs["$1"]'), {
29+
github: { event_name: 'push', ref: `refs/heads/${branch}` },
30+
needs,
31+
cancelled: () => false,
32+
})
33+
}
34+
35+
const promotion = tasks.split(' - name: Promote current dev preview')[1]
36+
const shell = promotion?.match(/ {8}run: \|\n((?: {10}.*\n)+)/)?.[1]
37+
if (!shell) throw new Error('Missing dev task promotion step')
38+
const script = shell.replace(/^ {10}/gm, '')
39+
40+
/** Run the real promotion shell with fake GitHub and Trigger commands. */
41+
function promote(env: Record<string, string> = {}) {
42+
return spawnSync(
43+
'bash',
44+
[
45+
'-e',
46+
'-c',
47+
`gh() { printf '%s\\n' "$HEAD_SHA"; return "$GH_EXIT"; }
48+
bunx() { printf 'PROMOTE %s\\n' "$*"; return "$TRIGGER_EXIT"; }
49+
${script}`,
50+
],
51+
{
52+
encoding: 'utf8',
53+
env: {
54+
PATH: process.env.PATH,
55+
GITHUB_REPOSITORY: 'example/sim',
56+
GITHUB_SHA: 'current',
57+
HEAD_SHA: 'current',
58+
VERSION: '20260922.1',
59+
GH_EXIT: '0',
60+
TRIGGER_EXIT: '0',
61+
...env,
62+
},
63+
}
64+
)
65+
}
66+
67+
describe('dev deployment independence', () => {
68+
it('promotes dev images with the skipped Trigger jobs and no cutover waiter', () => {
69+
expect(eligible('prepare-trigger', 'dev')).toBe(false)
70+
expect(eligible('promote-trigger', 'dev')).toBe(false)
71+
expect(eligible('promote-images', 'dev', { 'prepare-trigger': 'skipped' })).toBe(true)
72+
})
73+
74+
it.each(['migrate-dev', 'build-dev'])('still requires successful %s', (job) => {
75+
expect(eligible('promote-images', 'dev', { [job]: 'failure' })).toBe(false)
76+
})
77+
78+
it.each(['staging', 'main'])('preserves the coordinated release gates for %s', (branch) => {
79+
expect(eligible('prepare-trigger', branch)).toBe(true)
80+
expect(eligible('promote-trigger', branch)).toBe(true)
81+
expect(eligible('promote-images', branch)).toBe(true)
82+
for (const job of ['prepare-trigger', 'migrate', 'build-amd64']) {
83+
expect(eligible('promote-images', branch, { [job]: 'failure' })).toBe(false)
84+
}
85+
expect(eligible('promote-trigger', branch, { 'promote-images': 'failure' })).toBe(false)
86+
})
87+
88+
it('promotes the current task version only to the dev preview', () => {
89+
const result = promote()
90+
expect(result.status).toBe(0)
91+
expect(result.stdout).toContain(
92+
'PROMOTE trigger.dev@4.5.12 promote 20260922.1 --env preview --branch dev-sim'
93+
)
94+
})
95+
96+
it('does not roll tasks back when an older push is retried', () => {
97+
const result = promote({ HEAD_SHA: 'newer' })
98+
expect(result.status).toBe(0)
99+
expect(result.stdout).not.toContain('PROMOTE ')
100+
})
101+
102+
it('fails closed if the current branch cannot be read', () => {
103+
const result = promote({ GH_EXIT: '42' })
104+
expect(result.status).toBe(42)
105+
expect(result.stdout).not.toContain('PROMOTE ')
106+
})
107+
108+
it('rejects an invalid task version', () => {
109+
const result = promote({ VERSION: '' })
110+
expect(result.status).toBe(1)
111+
expect(result.stdout).not.toContain('PROMOTE ')
112+
})
113+
114+
it('reports task promotion failures in the independent workflow', () => {
115+
expect(promote({ TRIGGER_EXIT: '43' }).status).toBe(43)
116+
})
117+
})

0 commit comments

Comments
 (0)