Skip to content

Commit 90a05d9

Browse files
TheodoreSpeakswaleedlatif1Sg312icecrasher321
authored
feat(ci): promote Trigger.dev tasks in lockstep with the ECS traffic cutover (#5725)
* v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li <theo@sim.ai> * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha * feat(ci): promote Trigger.dev tasks in lockstep with the ECS traffic cutover * fix(ci): harden Trigger.dev cutover gate — reject stale executions, verify all ECS targets * fix(ci): widen promote-trigger job timeout above the poll budget * fix(ci): hold AWS session for the full poll and skip wait when the app image is unchanged * feat(ci): extend lockstep Trigger.dev promotion to dev (preview branch) * fix(ci): don't block dev task promotion on a non-app build-dev leg failure * chore(ci): use one Trigger.dev PAT for all envs (drop DEV_TRIGGER_ACCESS_TOKEN) * fix(ci): reliable digest reads for no-op detection, robust version parse, pre-push dev epoch * fix(ci): give dev promote-trigger a 20-min margin over its poll budget * fix(ci): require promote-images + deploy-trigger success explicitly for promote-trigger * fix(ci): verify Trigger promotion against the deployed image and cutover * fix(ci): bind Trigger promotion to the latest app tag move * test(ci): reject exhausted deployment response fixtures * test(ci): require cutover checks for every ECS target --------- Co-authored-by: Waleed <walif6@gmail.com> Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
1 parent 664ca82 commit 90a05d9

6 files changed

Lines changed: 782 additions & 11 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
# Read one ECR tag. Only ImageNotFound is optional; AWS and response errors fail.
3+
set -euo pipefail
4+
REPOSITORY="${1:?repository required}"
5+
TAG="${2:?tag required}"
6+
ALLOW_MISSING="${3:-}"
7+
if [ -n "$ALLOW_MISSING" ] && [ "$ALLOW_MISSING" != '--allow-missing' ]; then
8+
echo 'ERROR: expected --allow-missing or no third argument' >&2
9+
exit 1
10+
fi
11+
export AWS_PAGER=''
12+
aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageTag="$TAG" --output json |
13+
ALLOW_MISSING="$ALLOW_MISSING" python3 -c '
14+
import json, os, re, sys
15+
response = json.load(sys.stdin)
16+
images, failures = response["images"], response["failures"]
17+
if failures:
18+
if not images and len(failures) == 1 and failures[0]["failureCode"] == "ImageNotFound" and os.environ["ALLOW_MISSING"]:
19+
print("")
20+
sys.exit(0)
21+
raise SystemExit("ERROR: ECR image lookup failed: " + ", ".join(f["failureCode"] for f in failures))
22+
if len(images) != 1:
23+
raise SystemExit("ERROR: expected exactly one ECR image")
24+
digest = images[0]["imageId"]["imageDigest"]
25+
if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
26+
raise SystemExit("ERROR: invalid ECR image digest")
27+
print(digest)
28+
'
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
# Capture the cutover lower bound at the app tag move, after the image is built.
3+
set -euo pipefail
4+
REGISTRY="${1:?registry required}"
5+
REPOSITORY="${2:?repository required}"
6+
SOURCE_TAG="${3:?source tag required}"
7+
DEPLOY_TAG="${4:?deploy tag required}"
8+
: "${GITHUB_OUTPUT:?GitHub output file required}"
9+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
10+
PREVIOUS=$(bash "$SCRIPT_DIR/get-ecr-image-digest.sh" "$REPOSITORY" "$DEPLOY_TAG" --allow-missing)
11+
EPOCH=$(date +%s)
12+
docker buildx imagetools create -t "$REGISTRY/$REPOSITORY:$DEPLOY_TAG" "$REGISTRY/$REPOSITORY:$SOURCE_TAG"
13+
DIGEST=$(bash "$SCRIPT_DIR/get-ecr-image-digest.sh" "$REPOSITORY" "$DEPLOY_TAG")
14+
CHANGED=true
15+
if [ "$DIGEST" = "$PREVIOUS" ]; then CHANGED=false; fi
16+
{
17+
echo "retag_epoch=$EPOCH"
18+
echo "app_image_digest=$DIGEST"
19+
echo "app_image_changed=$CHANGED"
20+
} >> "$GITHUB_OUTPUT"
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""Exercise the deployment gates with scripted AWS responses; no live mutations."""
2+
import json
3+
import os
4+
from pathlib import Path
5+
import subprocess
6+
import tempfile
7+
import unittest
8+
9+
SCRIPTS = Path(__file__).resolve().parent
10+
DIGEST = 'sha256:' + 'a' * 64
11+
OTHER_DIGEST = 'sha256:' + 'b' * 64
12+
13+
14+
def execution(start=1000, digest=DIGEST, identifier='execution-current'):
15+
return {
16+
'startTime': start,
17+
'pipelineExecutionId': identifier,
18+
'sourceRevisions': [{'actionName': 'ECR_Source', 'revisionId': digest}],
19+
}
20+
21+
22+
class DeploymentGateTests(unittest.TestCase):
23+
def run_script(self, script, args, responses):
24+
with tempfile.TemporaryDirectory() as directory:
25+
root = Path(directory)
26+
fixture = root / 'responses.json'
27+
fixture.write_text(json.dumps(responses))
28+
(root / 'aws').write_text('''#!/usr/bin/env python3
29+
import json, os, pathlib, sys
30+
root = pathlib.Path(os.environ['FIXTURE_DIR'])
31+
args = sys.argv[1:]
32+
if args[0] == '--cli-connect-timeout':
33+
args = args[4:]
34+
service, operation = args[:2]
35+
key = operation
36+
if operation == 'get-deployment-target':
37+
key += ':' + args[args.index('--target-id') + 1]
38+
with (root / 'calls').open('a') as stream:
39+
stream.write(' '.join(args) + '\\n')
40+
responses = json.loads((root / 'responses.json').read_text())
41+
if key not in responses:
42+
raise SystemExit('Unexpected AWS call: ' + key)
43+
response = responses[key]
44+
# Objects model steady state; lists are finite, ordered expectations.
45+
if isinstance(response, list):
46+
if not response:
47+
raise SystemExit('Unexpected extra AWS call: ' + key)
48+
next_response = response.pop(0)
49+
(root / 'responses.json').write_text(json.dumps(responses))
50+
response = next_response
51+
if response.get('error'):
52+
sys.stderr.write(response['error'])
53+
sys.exit(254)
54+
if response.get('advance_clock'):
55+
clock = root / 'clock'
56+
value = int(clock.read_text()) if clock.exists() else 1000
57+
clock.write_text(str(value + response['advance_clock']))
58+
print(response.get('text', json.dumps(response.get('json'))))
59+
''')
60+
(root / 'docker').write_text('''#!/usr/bin/env python3
61+
import os, pathlib, sys
62+
root = pathlib.Path(os.environ['FIXTURE_DIR'])
63+
with (root / 'calls').open('a') as stream:
64+
stream.write('docker ' + ' '.join(sys.argv[1:]) + '\\n')
65+
''')
66+
(root / 'date').write_text('''#!/usr/bin/env python3
67+
import os, pathlib
68+
path = pathlib.Path(os.environ['FIXTURE_DIR']) / 'clock'
69+
value = int(path.read_text()) if path.exists() else 1000
70+
path.write_text(str(value + 1))
71+
print(value)
72+
''')
73+
(root / 'sleep').write_text('#!/bin/sh\nexit 0\n')
74+
for name in ('aws', 'date', 'sleep', 'docker'):
75+
(root / name).chmod(0o755)
76+
result = subprocess.run(
77+
['bash', str(SCRIPTS / script), *args],
78+
env={**os.environ, 'PATH': f'{root}:{os.environ["PATH"]}',
79+
'FIXTURE_DIR': str(root), 'POLL_INTERVAL': '1', 'OVERALL_TIMEOUT': '12',
80+
'GITHUB_OUTPUT': str(root / 'outputs')},
81+
capture_output=True, text=True, timeout=10,
82+
)
83+
calls = (root / 'calls').read_text() if (root / 'calls').exists() else ''
84+
result.github_output = (root / 'outputs').read_text() if (root / 'outputs').exists() else ''
85+
return result, calls
86+
87+
def poll(self, updates=None, since='1000'):
88+
responses = {
89+
'list-pipeline-executions': {'json': [execution()]},
90+
'get-pipeline-execution': {'text': 'InProgress'},
91+
'list-action-executions': {'text': 'd-current'},
92+
'get-deployment': {'text': 'InProgress'},
93+
'list-deployment-targets': {'text': 'target-one\ttarget-two'},
94+
'get-deployment-target:target-one': {'text': 'Succeeded'},
95+
'get-deployment-target:target-two': {'text': 'Succeeded'},
96+
}
97+
responses.update(updates or {})
98+
return self.run_script('wait-for-ecs-cutover.sh', ['app-pipeline', DIGEST, since], responses)
99+
100+
def test_waits_for_every_target(self):
101+
targets = ('target-one', 'target-two')
102+
for pending_target in targets:
103+
with self.subTest(pending_target=pending_target):
104+
result, calls = self.poll({f'get-deployment-target:{pending_target}': [
105+
{'text': 'InProgress'}, {'text': 'Succeeded'}]})
106+
self.assertEqual(result.returncode, 0, result.stderr)
107+
for target in targets:
108+
self.assertEqual(calls.count(f'--target-id {target}'), 2)
109+
110+
def test_rejects_stale_execution_inside_former_clock_skew_window(self):
111+
result, calls = self.poll({'list-pipeline-executions': {'json': [execution(start=999)]}})
112+
self.assertNotEqual(result.returncode, 0)
113+
self.assertIn('timed out', result.stdout)
114+
self.assertNotIn('get-pipeline-execution ', calls)
115+
116+
def test_chooses_newest_matching_execution(self):
117+
result, calls = self.poll({'list-pipeline-executions': {'json': [
118+
execution(1000, identifier='execution-old'), execution(1001)]}})
119+
self.assertEqual(result.returncode, 0, result.stderr)
120+
self.assertIn('--pipeline-execution-id execution-current', calls)
121+
122+
def test_changed_image_rejects_newer_different_execution(self):
123+
result, calls = self.poll({'list-pipeline-executions': {'json': [
124+
execution(), execution(1001, digest=OTHER_DIGEST, identifier='execution-newer')]}})
125+
self.assertNotEqual(result.returncode, 0)
126+
self.assertIn('deployment was superseded', result.stderr)
127+
self.assertNotIn('get-pipeline-execution ', calls)
128+
129+
def test_rechecks_latest_digest_after_cutover(self):
130+
result, calls = self.poll({'list-pipeline-executions': [
131+
{'json': [execution()]},
132+
{'json': [execution(), execution(1001, digest=OTHER_DIGEST, identifier='execution-newer')]},
133+
]})
134+
self.assertNotEqual(result.returncode, 0)
135+
self.assertIn('deployment was superseded', result.stderr)
136+
self.assertIn('get-deployment-target ', calls)
137+
self.assertNotIn('Traffic cutover complete', result.stdout)
138+
139+
def test_rechecks_execution_identity_for_same_digest_after_cutover(self):
140+
result, _ = self.poll({'list-pipeline-executions': [
141+
{'json': [execution()]},
142+
{'json': [execution(), execution(1001, identifier='execution-newer')]},
143+
]})
144+
self.assertNotEqual(result.returncode, 0)
145+
self.assertIn('newer pipeline execution appeared', result.stdout)
146+
self.assertNotIn('Traffic cutover complete', result.stdout)
147+
148+
def test_iso_timestamps(self):
149+
result, _ = self.poll({'list-pipeline-executions': {'json': [
150+
execution('1970-01-01T00:16:40+00:00')]}})
151+
self.assertEqual(result.returncode, 0, result.stderr)
152+
153+
def test_scripted_responses_reject_unexpected_extra_calls(self):
154+
result, _ = self.poll({'list-pipeline-executions': [{'json': [execution()]}]})
155+
self.assertNotEqual(result.returncode, 0)
156+
self.assertIn('Unexpected extra AWS call: list-pipeline-executions', result.stderr)
157+
self.assertNotIn('Traffic cutover complete', result.stdout)
158+
159+
def test_access_denial_fails_immediately(self):
160+
result, calls = self.poll({'list-pipeline-executions': {'error': 'AccessDeniedException'}})
161+
self.assertNotEqual(result.returncode, 0)
162+
self.assertIn('AccessDeniedException', result.stderr)
163+
self.assertEqual(len(calls.splitlines()), 1)
164+
165+
def test_credentials_expiring_during_target_poll_fail(self):
166+
result, _ = self.poll({'get-deployment-target:target-two': {'error': 'ExpiredToken'}})
167+
self.assertNotEqual(result.returncode, 0)
168+
self.assertIn('ExpiredToken', result.stderr)
169+
170+
def test_failed_and_superseded_pipeline_never_reach_deployment(self):
171+
for status in ('Failed', 'Stopped', 'Superseded'):
172+
with self.subTest(status=status):
173+
result, calls = self.poll({'get-pipeline-execution': {'text': status}})
174+
self.assertNotEqual(result.returncode, 0)
175+
self.assertNotIn('get-deployment ', calls)
176+
177+
def test_waits_for_queued_deploy_action(self):
178+
result, calls = self.poll({'list-action-executions': [{'text': 'None'}, {'text': 'd-current'}]})
179+
self.assertEqual(result.returncode, 0, result.stderr)
180+
self.assertEqual(calls.count('list-action-executions '), 2)
181+
182+
def test_failed_deployment_never_accepts_old_cutover(self):
183+
result, calls = self.poll({'get-deployment': {'text': 'Failed'}})
184+
self.assertNotEqual(result.returncode, 0)
185+
self.assertNotIn('get-deployment-target ', calls)
186+
187+
def test_empty_targets_cannot_satisfy_gate(self):
188+
result, _ = self.poll({'list-deployment-targets': {'text': ''}})
189+
self.assertNotEqual(result.returncode, 0)
190+
self.assertIn('timed out', result.stdout)
191+
192+
def test_failed_target_fails_immediately(self):
193+
result, _ = self.poll({'get-deployment-target:target-two': {'text': 'Failed'}})
194+
self.assertNotEqual(result.returncode, 0)
195+
self.assertIn('cutover status Failed', result.stdout)
196+
197+
def test_unchanged_image_verifies_existing_cutover(self):
198+
result, calls = self.poll({'list-pipeline-executions': {'json': [execution(start=900)]}}, since='0')
199+
self.assertEqual(result.returncode, 0, result.stderr)
200+
self.assertIn('get-deployment-target ', calls)
201+
202+
def test_unchanged_image_rejects_latest_different_deploy(self):
203+
result, calls = self.poll({'list-pipeline-executions': {'json': [
204+
execution(start=900), execution(start=999, digest=OTHER_DIGEST)]}}, since='0')
205+
self.assertNotEqual(result.returncode, 0)
206+
self.assertIn('cutover is unverified', result.stderr)
207+
self.assertNotIn('get-deployment ', calls)
208+
209+
def test_unchanged_image_rejects_failed_previous_deploy(self):
210+
result, _ = self.poll({'get-deployment': {'text': 'Failed'}}, since='0')
211+
self.assertNotEqual(result.returncode, 0)
212+
213+
def test_invalid_metadata_fails_before_aws(self):
214+
result, calls = self.poll(since='corrupted')
215+
self.assertNotEqual(result.returncode, 0)
216+
self.assertEqual(calls, '')
217+
218+
def test_ecr_digest_and_missing_tag(self):
219+
result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy'], {
220+
'batch-get-image': {'json': {'images': [{'imageId': {'imageDigest': DIGEST}}], 'failures': []}}})
221+
self.assertEqual(result.returncode, 0, result.stderr)
222+
self.assertEqual(result.stdout.strip(), DIGEST)
223+
missing = {'batch-get-image': {'json': {'images': [], 'failures': [{'failureCode': 'ImageNotFound'}]}}}
224+
result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy', '--allow-missing'], missing)
225+
self.assertEqual(result.returncode, 0, result.stderr)
226+
self.assertEqual(result.stdout.strip(), '')
227+
result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy'], missing)
228+
self.assertNotEqual(result.returncode, 0)
229+
230+
def test_ecr_response_failures_are_not_missing_images(self):
231+
for response in ({'error': 'AccessDeniedException'}, {'json': {'images': [], 'failures': [{'failureCode': 'KmsError'}]}}, {'json': {'images': [], 'failures': []}}):
232+
with self.subTest(response=response):
233+
result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy', '--allow-missing'], {'batch-get-image': response})
234+
self.assertNotEqual(result.returncode, 0)
235+
236+
def test_tag_move_uses_push_boundary_and_final_manifest_digest(self):
237+
result, calls = self.run_script('promote-app-image.sh', ['registry', 'app', 'commit-dev', 'dev'], {
238+
'batch-get-image': [
239+
{'advance_clock': 30, 'json': {'images': [{'imageId': {'imageDigest': DIGEST}}], 'failures': []}},
240+
{'json': {'images': [{'imageId': {'imageDigest': OTHER_DIGEST}}], 'failures': []}},
241+
]})
242+
self.assertEqual(result.returncode, 0, result.stderr)
243+
self.assertIn('retag_epoch=1030', result.github_output)
244+
self.assertIn(f'app_image_digest={OTHER_DIGEST}', result.github_output)
245+
self.assertIn('app_image_changed=true', result.github_output)
246+
self.assertEqual([line.split()[0] for line in calls.splitlines()], ['ecr', 'docker', 'ecr'])
247+
self.assertIn('registry/app:commit-dev', calls)
248+
249+
def test_tag_move_aborts_before_docker_when_ecr_read_fails(self):
250+
result, calls = self.run_script('promote-app-image.sh', ['registry', 'app', 'commit', 'deploy'], {
251+
'batch-get-image': {'error': 'AccessDeniedException'}})
252+
self.assertNotEqual(result.returncode, 0)
253+
self.assertNotIn('docker', calls)
254+
self.assertEqual(result.github_output, '')
255+
256+
def test_same_digest_tag_move_reports_unchanged(self):
257+
result, _ = self.run_script('promote-app-image.sh', ['registry', 'app', 'commit', 'deploy'], {
258+
'batch-get-image': {'json': {'images': [{'imageId': {'imageDigest': DIGEST}}], 'failures': []}}})
259+
self.assertEqual(result.returncode, 0, result.stderr)
260+
self.assertIn('app_image_changed=false', result.github_output)
261+
262+
263+
if __name__ == '__main__':
264+
unittest.main()

0 commit comments

Comments
 (0)