@@ -336,8 +336,10 @@ def setUpClass(cls):
336336 str (workflow )], check = True , capture_output = True , text = True )
337337 cls .jobs = json .loads (parsed .stdout )['jobs' ]
338338
339- def eligible (self , job , branch , results , event = 'push' , cancelled = False , promoted = 'true' ):
340- expression = self .jobs [job ]['if' ]
339+ def evaluate (self , expression , branch , results = None , event = 'push' , cancelled = False , promoted = 'true' ):
340+ results = results or {}
341+ if expression .startswith ('${{' ):
342+ expression = expression [3 :- 2 ]
341343 expression = re .sub (r'needs\.([\w-]+)\.result' , lambda m : repr (results [m [1 ]]), expression )
342344 expression = expression .replace ('needs.promote-images.outputs.promoted' , repr (promoted ))
343345 expression = expression .replace ('github.ref' , repr ('refs/heads/' + branch ))
@@ -346,19 +348,21 @@ def eligible(self, job, branch, results, event='push', cancelled=False, promoted
346348 expression = expression .replace ('&&' , ' and ' ).replace ('||' , ' or ' )
347349 return eval (' ' .join (expression .split ()), {'__builtins__' : {}})
348350
351+ def eligible (self , job , branch , results , ** options ):
352+ return self .evaluate (self .jobs [job ]['if' ], branch , results , ** options )
353+
349354 def release_results (self , branch ):
350- active = ('migrate-dev' , 'build-dev' , 'deploy -trigger-dev ' ) if branch == 'dev' else (
351- 'migrate' , 'build-amd64' , 'deploy -trigger' )
355+ active = ('migrate-dev' , 'build-dev' , 'prepare -trigger' ) if branch == 'dev' else (
356+ 'migrate' , 'build-amd64' , 'prepare -trigger' )
352357 results = {name : 'success' if name in active else 'skipped'
353358 for name in self .jobs ['promote-images' ]['needs' ]}
354359 return active , results
355360
356361 def test_uploads_and_image_builds_can_start_before_migration (self ):
357- for job in ('deploy -trigger' , 'deploy-trigger-dev ' , 'build-amd64' , 'build-dev' ):
362+ for job in ('prepare -trigger' , 'build-amd64' , 'build-dev' ):
358363 self .assertFalse (self .jobs [job ].get ('needs' ), job )
359- for job in ('deploy-trigger' , 'deploy-trigger-dev' ):
360- upload = next (step for step in self .jobs [job ]['steps' ] if step .get ('id' ) == 'deploy' )
361- self .assertIn ('--skip-promotion' , upload ['run' ])
364+ upload = next (step for step in self .jobs ['prepare-trigger' ]['steps' ] if step .get ('id' ) == 'deploy' )
365+ self .assertIn ('--skip-promotion' , upload ['run' ])
362366
363367 def test_each_release_waits_for_all_three_gates (self ):
364368 for branch in ('main' , 'staging' , 'dev' ):
@@ -387,9 +391,9 @@ def test_dev_build_cannot_move_deploy_tags(self):
387391 self .assertNotIn ('imagetools create' , json .dumps (steps ))
388392
389393 def test_task_promotion_requires_a_successful_fresh_app_release (self ):
390- for branch , job , upload in (('main' , 'promote-trigger' , 'deploy -trigger' ),
391- ('staging' , 'promote-trigger' , 'deploy -trigger' ),
392- ('dev' , 'promote-trigger-dev ' , 'deploy -trigger-dev ' )):
394+ for branch , job , upload in (('main' , 'promote-trigger' , 'prepare -trigger' ),
395+ ('staging' , 'promote-trigger' , 'prepare -trigger' ),
396+ ('dev' , 'promote-trigger' , 'prepare -trigger' )):
393397 ready = {'promote-images' : 'success' , upload : 'success' }
394398 self .assertIn ('promote-images' , self .jobs [job ]['needs' ])
395399 self .assertTrue (self .eligible (job , branch , ready ))
@@ -401,6 +405,81 @@ def test_task_promotion_requires_a_successful_fresh_app_release(self):
401405 self .assertLess (wait , promote )
402406 self .assertEqual (steps [promote ]['env' ]['VERSION' ], '${{ needs.' + upload + '.outputs.version }}' )
403407
408+ def test_shared_trigger_jobs_use_the_same_target_for_upload_and_promotion (self ):
409+ self .assertEqual ({job for job in self .jobs if 'trigger' in job }, {'prepare-trigger' , 'promote-trigger' })
410+ prepare = self .jobs ['prepare-trigger' ]
411+ select = next (step for step in prepare ['steps' ] if step .get ('id' ) == 'target' )
412+ upload = next (step for step in prepare ['steps' ] if step .get ('id' ) == 'deploy' )
413+ promote = next (step for step in self .jobs ['promote-trigger' ]['steps' ]
414+ if 'promote "$VERSION"' in step .get ('run' , '' ))
415+ for variable , output in (('TRIGGER_ENV' , 'environment' ), ('TRIGGER_BRANCH' , 'preview_branch' )):
416+ self .assertEqual (upload ['env' ][variable ], '${{ steps.target.outputs.' + output + ' }}' )
417+ self .assertEqual (prepare ['outputs' ][output ], '${{ steps.target.outputs.' + output + ' }}' )
418+ self .assertEqual (promote ['env' ][variable ], '${{ needs.prepare-trigger.outputs.' + output + ' }}' )
419+ for branch , target , preview_branch in (('main' , 'prod' , '' ), ('staging' , 'staging' , '' ),
420+ ('dev' , 'preview' , 'dev-sim' )):
421+ with self .subTest (branch = branch ), tempfile .TemporaryDirectory () as directory :
422+ root = Path (directory )
423+ env = {** os .environ , 'GITHUB_REF' : 'refs/heads/' + branch ,
424+ 'GITHUB_OUTPUT' : str (root / 'outputs' ), 'PATH' : f'{ root } :{ os .environ ["PATH" ]} ' }
425+ subprocess .run (['bash' , '-eo' , 'pipefail' , '-c' , select ['run' ]], env = env , check = True )
426+ outputs = dict (line .split ('=' , 1 ) for line in (root / 'outputs' ).read_text ().splitlines ())
427+ self .assertEqual (outputs , {'environment' : target , 'preview_branch' : preview_branch })
428+ (root / 'bunx' ).write_text ('''#!/usr/bin/env python3
429+ import json, os, sys
430+ with open(os.environ['CLI_CALLS'], 'a') as stream:
431+ stream.write(json.dumps(sys.argv[1:]) + '\\ n')
432+ ''' )
433+ (root / 'bunx' ).chmod (0o755 )
434+ env .update ({'TRIGGER_ENV' : outputs ['environment' ], 'TRIGGER_BRANCH' : outputs ['preview_branch' ],
435+ 'TRIGGER_ACCESS_TOKEN' : 'test-token' , 'TRIGGER_PROJECT_ID' : 'test-project' ,
436+ 'VERSION' : '20260101.1' , 'CLI_CALLS' : str (root / 'calls' )})
437+ for step in (upload , promote ):
438+ subprocess .run (['bash' , '-eo' , 'pipefail' , '-c' , step ['run' ]], env = env ,
439+ check = True , capture_output = True , text = True )
440+ calls = [json .loads (line ) for line in (root / 'calls' ).read_text ().splitlines ()]
441+ flags = ['--env' , target ] + (['--branch' , preview_branch ] if preview_branch else [])
442+ self .assertEqual (calls , [
443+ ['trigger.dev@4.5.12' , 'deploy' , * flags , '--skip-promotion' ],
444+ ['trigger.dev@4.5.12' , 'promote' , '20260101.1' , * flags ],
445+ ])
446+ self .assertTrue (self .eligible ('prepare-trigger' , branch , {}))
447+ self .assertFalse (self .eligible ('prepare-trigger' , branch , {}, event = 'pull_request' ))
448+
449+ def test_unsupported_trigger_ref_fails_before_upload (self ):
450+ select = next (step for step in self .jobs ['prepare-trigger' ]['steps' ] if step .get ('id' ) == 'target' )
451+ with tempfile .TemporaryDirectory () as directory :
452+ output = Path (directory ) / 'outputs'
453+ result = subprocess .run (['bash' , '-eo' , 'pipefail' , '-c' , select ['run' ]],
454+ env = {** os .environ , 'GITHUB_REF' : 'refs/heads/unsupported' ,
455+ 'GITHUB_OUTPUT' : str (output )}, capture_output = True , text = True )
456+ self .assertNotEqual (result .returncode , 0 )
457+ self .assertIn ('unsupported Trigger release ref' , result .stderr )
458+ self .assertFalse (output .exists ())
459+ self .assertFalse (self .eligible ('prepare-trigger' , 'unsupported' , {}))
460+
461+ def test_shared_promotion_preserves_environment_session_and_poll_budgets (self ):
462+ job = self .jobs ['promote-trigger' ]
463+ credentials = next (step for step in job ['steps' ] if step ['name' ] == 'Configure AWS credentials' )['with' ]
464+ wait = next (step for step in job ['steps' ] if step ['name' ] == 'Wait for ECS traffic cutover' )['env' ]
465+ for branch , poll , session , minutes , role in (
466+ ('main' , 4200 , 5400 , 90 , 'prod-role' ),
467+ ('staging' , 4200 , 5400 , 90 , 'staging-role' ),
468+ ('dev' , 1200 , 2400 , 40 , 'dev-role' ),
469+ ):
470+ with self .subTest (branch = branch ):
471+ self .assertEqual (self .evaluate (wait ['OVERALL_TIMEOUT' ], branch ), poll )
472+ self .assertEqual (self .evaluate (credentials ['role-duration-seconds' ], branch ), session )
473+ self .assertEqual (self .evaluate (job ['timeout-minutes' ], branch ), minutes )
474+ role_expression = credentials ['role-to-assume' ]
475+ for key , value in (('AWS_ROLE_TO_ASSUME' , 'prod-role' ),
476+ ('STAGING_AWS_ROLE_TO_ASSUME' , 'staging-role' ),
477+ ('DEV_AWS_ROLE_TO_ASSUME' , 'dev-role' )):
478+ role_expression = role_expression .replace ('secrets.' + key , repr (value ))
479+ self .assertEqual (self .evaluate (role_expression , branch ), role )
480+ self .assertGreater (session , poll )
481+ self .assertGreater (minutes * 60 , poll )
482+
404483 def test_permission_check_and_other_images_precede_app_rollout (self ):
405484 steps = self .jobs ['promote-images' ]['steps' ]
406485 preflight = next (i for i , step in enumerate (steps ) if 'get-pipeline-state' in step .get ('run' , '' ))
0 commit comments