@@ -48,7 +48,17 @@ def run_script(self, script, args, responses):
4848if response.get('error'):
4949 sys.stderr.write(response['error'])
5050 sys.exit(254)
51+ if response.get('advance_clock'):
52+ clock = root / 'clock'
53+ value = int(clock.read_text()) if clock.exists() else 1000
54+ clock.write_text(str(value + response['advance_clock']))
5155print(response.get('text', json.dumps(response.get('json'))))
56+ ''' )
57+ (root / 'docker' ).write_text ('''#!/usr/bin/env python3
58+ import os, pathlib, sys
59+ root = pathlib.Path(os.environ['FIXTURE_DIR'])
60+ with (root / 'calls').open('a') as stream:
61+ stream.write('docker ' + ' '.join(sys.argv[1:]) + '\\ n')
5262''' )
5363 (root / 'date' ).write_text ('''#!/usr/bin/env python3
5464import os, pathlib
@@ -58,15 +68,17 @@ def run_script(self, script, args, responses):
5868print(value)
5969''' )
6070 (root / 'sleep' ).write_text ('#!/bin/sh\n exit 0\n ' )
61- for name in ('aws' , 'date' , 'sleep' ):
71+ for name in ('aws' , 'date' , 'sleep' , 'docker' ):
6272 (root / name ).chmod (0o755 )
6373 result = subprocess .run (
6474 ['bash' , str (SCRIPTS / script ), * args ],
6575 env = {** os .environ , 'PATH' : f'{ root } :{ os .environ ["PATH" ]} ' ,
66- 'FIXTURE_DIR' : str (root ), 'POLL_INTERVAL' : '1' , 'OVERALL_TIMEOUT' : '12' },
76+ 'FIXTURE_DIR' : str (root ), 'POLL_INTERVAL' : '1' , 'OVERALL_TIMEOUT' : '12' ,
77+ 'GITHUB_OUTPUT' : str (root / 'outputs' )},
6778 capture_output = True , text = True , timeout = 10 ,
6879 )
6980 calls = (root / 'calls' ).read_text () if (root / 'calls' ).exists () else ''
81+ result .github_output = (root / 'outputs' ).read_text () if (root / 'outputs' ).exists () else ''
7082 return result , calls
7183
7284 def poll (self , updates = None , since = '1000' ):
@@ -100,6 +112,32 @@ def test_chooses_newest_matching_execution(self):
100112 self .assertEqual (result .returncode , 0 , result .stderr )
101113 self .assertIn ('--pipeline-execution-id execution-current' , calls )
102114
115+ def test_changed_image_rejects_newer_different_execution (self ):
116+ result , calls = self .poll ({'list-pipeline-executions' : {'json' : [
117+ execution (), execution (1001 , digest = OTHER_DIGEST , identifier = 'execution-newer' )]}})
118+ self .assertNotEqual (result .returncode , 0 )
119+ self .assertIn ('deployment was superseded' , result .stderr )
120+ self .assertNotIn ('get-pipeline-execution ' , calls )
121+
122+ def test_rechecks_latest_digest_after_cutover (self ):
123+ result , calls = self .poll ({'list-pipeline-executions' : [
124+ {'json' : [execution ()]},
125+ {'json' : [execution (), execution (1001 , digest = OTHER_DIGEST , identifier = 'execution-newer' )]},
126+ ]})
127+ self .assertNotEqual (result .returncode , 0 )
128+ self .assertIn ('deployment was superseded' , result .stderr )
129+ self .assertIn ('get-deployment-target ' , calls )
130+ self .assertNotIn ('Traffic cutover complete' , result .stdout )
131+
132+ def test_rechecks_execution_identity_for_same_digest_after_cutover (self ):
133+ result , _ = self .poll ({'list-pipeline-executions' : [
134+ {'json' : [execution ()]},
135+ {'json' : [execution (), execution (1001 , identifier = 'execution-newer' )]},
136+ ]})
137+ self .assertNotEqual (result .returncode , 0 )
138+ self .assertIn ('newer pipeline execution appeared' , result .stdout )
139+ self .assertNotIn ('Traffic cutover complete' , result .stdout )
140+
103141 def test_iso_timestamps (self ):
104142 result , _ = self .poll ({'list-pipeline-executions' : {'json' : [
105143 execution ('1970-01-01T00:16:40+00:00' )]}})
@@ -182,6 +220,32 @@ def test_ecr_response_failures_are_not_missing_images(self):
182220 result , _ = self .run_script ('get-ecr-image-digest.sh' , ['app' , 'deploy' , '--allow-missing' ], {'batch-get-image' : response })
183221 self .assertNotEqual (result .returncode , 0 )
184222
223+ def test_tag_move_uses_push_boundary_and_final_manifest_digest (self ):
224+ result , calls = self .run_script ('promote-app-image.sh' , ['registry' , 'app' , 'commit-dev' , 'dev' ], {
225+ 'batch-get-image' : [
226+ {'advance_clock' : 30 , 'json' : {'images' : [{'imageId' : {'imageDigest' : DIGEST }}], 'failures' : []}},
227+ {'json' : {'images' : [{'imageId' : {'imageDigest' : OTHER_DIGEST }}], 'failures' : []}},
228+ ]})
229+ self .assertEqual (result .returncode , 0 , result .stderr )
230+ self .assertIn ('retag_epoch=1030' , result .github_output )
231+ self .assertIn (f'app_image_digest={ OTHER_DIGEST } ' , result .github_output )
232+ self .assertIn ('app_image_changed=true' , result .github_output )
233+ self .assertEqual ([line .split ()[0 ] for line in calls .splitlines ()], ['ecr' , 'docker' , 'ecr' ])
234+ self .assertIn ('registry/app:commit-dev' , calls )
235+
236+ def test_tag_move_aborts_before_docker_when_ecr_read_fails (self ):
237+ result , calls = self .run_script ('promote-app-image.sh' , ['registry' , 'app' , 'commit' , 'deploy' ], {
238+ 'batch-get-image' : {'error' : 'AccessDeniedException' }})
239+ self .assertNotEqual (result .returncode , 0 )
240+ self .assertNotIn ('docker' , calls )
241+ self .assertEqual (result .github_output , '' )
242+
243+ def test_same_digest_tag_move_reports_unchanged (self ):
244+ result , _ = self .run_script ('promote-app-image.sh' , ['registry' , 'app' , 'commit' , 'deploy' ], {
245+ 'batch-get-image' : {'json' : {'images' : [{'imageId' : {'imageDigest' : DIGEST }}], 'failures' : []}}})
246+ self .assertEqual (result .returncode , 0 , result .stderr )
247+ self .assertIn ('app_image_changed=false' , result .github_output )
248+
185249
186250if __name__ == '__main__' :
187251 unittest .main ()
0 commit comments