diff --git a/.github/scripts/test-trigger-deploy.py b/.github/scripts/test-trigger-deploy.py deleted file mode 100644 index 64febf4b611..00000000000 --- a/.github/scripts/test-trigger-deploy.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Exercise the deployment gates with scripted AWS responses; no live mutations.""" -import json -import os -from pathlib import Path -import re -import subprocess -import tempfile -import unittest - -SCRIPTS = Path(__file__).resolve().parent -DIGEST = 'sha256:' + 'a' * 64 -OTHER_DIGEST = 'sha256:' + 'b' * 64 - - -def execution(start=1000, digest=DIGEST, identifier='execution-current'): - return { - 'startTime': start, - 'pipelineExecutionId': identifier, - 'sourceRevisions': [{'actionName': 'ECR_Source', 'revisionId': digest}], - } - - -def deploy_action(identifier='action-current', start=1000, status='InProgress'): - return {'actionName': 'Deploy_to_ECS', 'actionExecutionId': identifier, - 'startTime': start, 'status': status, 'output': {}} - - -def deploy_state(execution_id='execution-current', action_id='action-current', deployment_id='d-current'): - return {'latestExecution': {'pipelineExecutionId': execution_id}, 'actionStates': [ - {'actionName': 'Deploy_to_ECS', 'latestExecution': { - 'actionExecutionId': action_id, 'externalExecutionId': deployment_id, 'status': 'InProgress'}}]} - - -class DeploymentGateTests(unittest.TestCase): - def run_script(self, script, args, responses): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - fixture = root / 'responses.json' - fixture.write_text(json.dumps(responses)) - (root / 'aws').write_text('''#!/usr/bin/env python3 -import json, os, pathlib, sys -root = pathlib.Path(os.environ['FIXTURE_DIR']) -args = sys.argv[1:] -if args[0] == '--cli-connect-timeout': - args = args[4:] -service, operation = args[:2] -key = operation -if operation == 'get-deployment-target': - key += ':' + args[args.index('--target-id') + 1] -with (root / 'calls').open('a') as stream: - stream.write(' '.join(args) + '\\n') -responses = json.loads((root / 'responses.json').read_text()) -if key not in responses: - raise SystemExit('Unexpected AWS call: ' + key) -response = responses[key] -# Objects model steady state; lists are finite, ordered expectations. -if isinstance(response, list): - if not response: - raise SystemExit('Unexpected extra AWS call: ' + key) - next_response = response.pop(0) - (root / 'responses.json').write_text(json.dumps(responses)) - response = next_response -if response.get('error'): - sys.stderr.write(response['error']) - sys.exit(254) -if response.get('advance_clock'): - clock = root / 'clock' - value = int(clock.read_text()) if clock.exists() else 1000 - clock.write_text(str(value + response['advance_clock'])) -print(response.get('text', json.dumps(response.get('json')))) -''') - (root / 'docker').write_text('''#!/usr/bin/env python3 -import os, pathlib, sys -root = pathlib.Path(os.environ['FIXTURE_DIR']) -with (root / 'calls').open('a') as stream: - stream.write('docker ' + ' '.join(sys.argv[1:]) + '\\n') -''') - (root / 'date').write_text('''#!/usr/bin/env python3 -import os, pathlib -path = pathlib.Path(os.environ['FIXTURE_DIR']) / 'clock' -value = int(path.read_text()) if path.exists() else 1000 -path.write_text(str(value + 1)) -print(value) -''') - (root / 'sleep').write_text('#!/bin/sh\nexit 0\n') - for name in ('aws', 'date', 'sleep', 'docker'): - (root / name).chmod(0o755) - result = subprocess.run( - ['bash', str(SCRIPTS / script), *args], - env={**os.environ, 'PATH': f'{root}:{os.environ["PATH"]}', - 'FIXTURE_DIR': str(root), 'POLL_INTERVAL': '1', 'OVERALL_TIMEOUT': '12', - 'GITHUB_OUTPUT': str(root / 'outputs')}, - capture_output=True, text=True, timeout=10, - ) - calls = (root / 'calls').read_text() if (root / 'calls').exists() else '' - result.github_output = (root / 'outputs').read_text() if (root / 'outputs').exists() else '' - return result, calls - - def poll(self, updates=None, since='1000'): - responses = { - 'list-pipeline-executions': {'json': [execution()]}, - 'get-pipeline-execution': {'text': 'InProgress'}, - 'get-pipeline-state': {'json': deploy_state()}, - 'list-action-executions': {'json': [deploy_action()]}, - 'get-deployment': {'text': 'InProgress'}, - 'list-deployment-targets': {'text': 'target-one\ttarget-two'}, - 'get-deployment-target:target-one': {'text': 'Succeeded'}, - 'get-deployment-target:target-two': {'text': 'Succeeded'}, - } - responses.update(updates or {}) - return self.run_script('wait-for-ecs-cutover.sh', ['app-pipeline', DIGEST, since], responses) - - def test_waits_for_every_target(self): - targets = ('target-one', 'target-two') - for pending_target in targets: - with self.subTest(pending_target=pending_target): - result, calls = self.poll({f'get-deployment-target:{pending_target}': [ - {'text': 'InProgress'}, {'text': 'Succeeded'}]}) - self.assertEqual(result.returncode, 0, result.stderr) - for target in targets: - self.assertEqual(calls.count(f'--target-id {target}'), 2) - - def test_rejects_stale_execution_inside_former_clock_skew_window(self): - result, calls = self.poll({'list-pipeline-executions': {'json': [execution(start=999)]}}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('timed out', result.stdout) - self.assertNotIn('get-pipeline-execution ', calls) - - def test_chooses_newest_matching_execution(self): - result, calls = self.poll({'list-pipeline-executions': {'json': [ - execution(1000, identifier='execution-old'), execution(1001)]}}) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn('--pipeline-execution-id execution-current', calls) - - def test_changed_image_rejects_newer_different_execution(self): - result, calls = self.poll({'list-pipeline-executions': {'json': [ - execution(), execution(1001, digest=OTHER_DIGEST, identifier='execution-newer')]}}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('deployment was superseded', result.stderr) - self.assertNotIn('get-pipeline-execution ', calls) - - def test_rechecks_latest_digest_after_cutover(self): - result, calls = self.poll({'list-pipeline-executions': [ - {'json': [execution()]}, - {'json': [execution(), execution(1001, digest=OTHER_DIGEST, identifier='execution-newer')]}, - ]}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('deployment was superseded', result.stderr) - self.assertIn('get-deployment-target ', calls) - self.assertNotIn('Traffic cutover complete', result.stdout) - - def test_rechecks_execution_identity_for_same_digest_after_cutover(self): - result, _ = self.poll({'list-pipeline-executions': [ - {'json': [execution()]}, - {'json': [execution(), execution(1001, identifier='execution-newer')]}, - ]}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('newer pipeline execution appeared', result.stdout) - self.assertNotIn('Traffic cutover complete', result.stdout) - - def test_iso_timestamps(self): - result, _ = self.poll({'list-pipeline-executions': {'json': [ - execution('1970-01-01T00:16:40+00:00')]}}) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_scripted_responses_reject_unexpected_extra_calls(self): - result, _ = self.poll({'list-pipeline-executions': [{'json': [execution()]}]}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('Unexpected extra AWS call: list-pipeline-executions', result.stderr) - self.assertNotIn('Traffic cutover complete', result.stdout) - - def test_access_denial_fails_immediately(self): - result, calls = self.poll({'list-pipeline-executions': {'error': 'AccessDeniedException'}}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('AccessDeniedException', result.stderr) - self.assertEqual(len(calls.splitlines()), 1) - - def test_credentials_expiring_during_target_poll_fail(self): - result, _ = self.poll({'get-deployment-target:target-two': {'error': 'ExpiredToken'}}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('ExpiredToken', result.stderr) - - def test_failed_and_superseded_pipeline_never_reach_deployment(self): - for status in ('Failed', 'Stopped', 'Superseded'): - with self.subTest(status=status): - result, calls = self.poll({'get-pipeline-execution': {'text': status}}) - self.assertNotEqual(result.returncode, 0) - self.assertNotIn('get-deployment ', calls) - - def test_waits_for_queued_deploy_action(self): - result, calls = self.poll({'list-action-executions': [ - {'json': []}, {'json': [deploy_action()]}]}) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(calls.count('list-action-executions '), 2) - - def test_finds_live_deployment_before_action_history_has_output(self): - result, calls = self.poll() - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn('get-pipeline-state --name app-pipeline', calls) - self.assertIn('get-deployment --deployment-id d-current', calls) - self.assertIn('Traffic cutover complete', result.stdout) - - def test_waits_for_state_from_the_exact_pipeline_and_action(self): - for stale in (None, deploy_state(execution_id='execution-old'), - deploy_state(action_id='action-old'), deploy_state(deployment_id='')): - with self.subTest(stale=stale): - result, calls = self.poll({'get-pipeline-state': [ - {'json': stale}, {'json': deploy_state()}]}) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(calls.count('get-pipeline-state '), 2) - self.assertEqual(calls.count('get-deployment '), 1) - - def test_old_live_action_cannot_satisfy_a_retry(self): - result, calls = self.poll({ - 'list-action-executions': {'json': [deploy_action(), deploy_action('action-old', start=999)]}, - 'get-pipeline-state': [ - {'json': deploy_state(action_id='action-old', deployment_id='d-old')}, - {'json': deploy_state()}], - }) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertNotIn('--deployment-id d-old', calls) - - def test_live_retry_waits_for_history_to_include_the_same_attempt(self): - for previous_status in ('Failed', 'Abandoned'): - with self.subTest(previous_status=previous_status): - previous = deploy_action('action-old', start=999, status=previous_status) - result, calls = self.poll({ - 'list-action-executions': [ - {'json': [previous]}, - {'json': [previous, deploy_action()]}, - ], - }) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(calls.count('get-pipeline-state '), 2) - self.assertEqual(calls.count('get-deployment '), 1) - self.assertIn('get-deployment --deployment-id d-current', calls) - - def test_bad_live_state_and_failed_actions_fail_closed(self): - for updates in ( - {'get-pipeline-state': {'error': 'AccessDeniedException'}}, - {'get-pipeline-state': {'json': deploy_state(deployment_id='wrong-provider-id')}}, - {'list-action-executions': {'json': [deploy_action(status='Failed')]}}, - ): - with self.subTest(updates=updates): - result, calls = self.poll(updates) - self.assertNotEqual(result.returncode, 0) - self.assertNotIn('get-deployment ', calls) - - def test_failed_deployment_never_accepts_old_cutover(self): - result, calls = self.poll({'get-deployment': {'text': 'Failed'}}) - self.assertNotEqual(result.returncode, 0) - self.assertNotIn('get-deployment-target ', calls) - - def test_empty_targets_cannot_satisfy_gate(self): - result, _ = self.poll({'list-deployment-targets': {'text': ''}}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('timed out', result.stdout) - - def test_failed_target_fails_immediately(self): - result, _ = self.poll({'get-deployment-target:target-two': {'text': 'Failed'}}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('cutover status Failed', result.stdout) - - def test_unchanged_image_verifies_existing_cutover(self): - result, calls = self.poll({'list-pipeline-executions': {'json': [execution(start=900)]}}, since='0') - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn('get-deployment-target ', calls) - - def test_unchanged_image_rejects_latest_different_deploy(self): - result, calls = self.poll({'list-pipeline-executions': {'json': [ - execution(start=900), execution(start=999, digest=OTHER_DIGEST)]}}, since='0') - self.assertNotEqual(result.returncode, 0) - self.assertIn('cutover is unverified', result.stderr) - self.assertNotIn('get-deployment ', calls) - - def test_unchanged_image_rejects_failed_previous_deploy(self): - result, _ = self.poll({'get-deployment': {'text': 'Failed'}}, since='0') - self.assertNotEqual(result.returncode, 0) - - def test_invalid_metadata_fails_before_aws(self): - result, calls = self.poll(since='corrupted') - self.assertNotEqual(result.returncode, 0) - self.assertEqual(calls, '') - - def test_ecr_digest_and_missing_tag(self): - result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy'], { - 'batch-get-image': {'json': {'images': [{'imageId': {'imageDigest': DIGEST}}], 'failures': []}}}) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), DIGEST) - missing = {'batch-get-image': {'json': {'images': [], 'failures': [{'failureCode': 'ImageNotFound'}]}}} - result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy', '--allow-missing'], missing) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), '') - result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy'], missing) - self.assertNotEqual(result.returncode, 0) - - def test_ecr_response_failures_are_not_missing_images(self): - for response in ({'error': 'AccessDeniedException'}, {'json': {'images': [], 'failures': [{'failureCode': 'KmsError'}]}}, {'json': {'images': [], 'failures': []}}): - with self.subTest(response=response): - result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy', '--allow-missing'], {'batch-get-image': response}) - self.assertNotEqual(result.returncode, 0) - - def test_tag_move_uses_push_boundary_and_final_manifest_digest(self): - result, calls = self.run_script('promote-app-image.sh', ['registry', 'app', 'commit-dev', 'dev'], { - 'batch-get-image': [ - {'advance_clock': 30, 'json': {'images': [{'imageId': {'imageDigest': DIGEST}}], 'failures': []}}, - {'json': {'images': [{'imageId': {'imageDigest': OTHER_DIGEST}}], 'failures': []}}, - ]}) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn('retag_epoch=1030', result.github_output) - self.assertIn(f'app_image_digest={OTHER_DIGEST}', result.github_output) - self.assertIn('app_image_changed=true', result.github_output) - self.assertEqual([line.split()[0] for line in calls.splitlines()], ['ecr', 'docker', 'ecr']) - self.assertIn('registry/app:commit-dev', calls) - - def test_tag_move_aborts_before_docker_when_ecr_read_fails(self): - result, calls = self.run_script('promote-app-image.sh', ['registry', 'app', 'commit', 'deploy'], { - 'batch-get-image': {'error': 'AccessDeniedException'}}) - self.assertNotEqual(result.returncode, 0) - self.assertNotIn('docker', calls) - self.assertEqual(result.github_output, '') - - def test_same_digest_tag_move_reports_unchanged(self): - result, _ = self.run_script('promote-app-image.sh', ['registry', 'app', 'commit', 'deploy'], { - 'batch-get-image': {'json': {'images': [{'imageId': {'imageDigest': DIGEST}}], 'failures': []}}}) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn('app_image_changed=false', result.github_output) - - -class ReleaseOrderingTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - workflow = SCRIPTS.parent / 'workflows' / 'ci.yml' - parsed = subprocess.run([ - 'bun', '-e', 'console.log(JSON.stringify(Bun.YAML.parse(await Bun.file(process.argv[1]).text())))', - str(workflow)], check=True, capture_output=True, text=True) - cls.jobs = json.loads(parsed.stdout)['jobs'] - - def eligible(self, job, branch, results, event='push', cancelled=False, promoted='true'): - expression = self.jobs[job]['if'] - expression = re.sub(r'needs\.([\w-]+)\.result', lambda m: repr(results[m[1]]), expression) - expression = expression.replace('needs.promote-images.outputs.promoted', repr(promoted)) - expression = expression.replace('github.ref', repr('refs/heads/' + branch)) - expression = expression.replace('github.event_name', repr(event)) - expression = expression.replace('!cancelled()', repr(not cancelled)) - expression = expression.replace('&&', ' and ').replace('||', ' or ') - return eval(' '.join(expression.split()), {'__builtins__': {}}) - - def release_results(self, branch): - active = ('migrate-dev', 'build-dev', 'deploy-trigger-dev') if branch == 'dev' else ( - 'migrate', 'build-amd64', 'deploy-trigger') - results = {name: 'success' if name in active else 'skipped' - for name in self.jobs['promote-images']['needs']} - return active, results - - def test_uploads_and_image_builds_can_start_before_migration(self): - for job in ('deploy-trigger', 'deploy-trigger-dev', 'build-amd64', 'build-dev'): - self.assertFalse(self.jobs[job].get('needs'), job) - for job in ('deploy-trigger', 'deploy-trigger-dev'): - upload = next(step for step in self.jobs[job]['steps'] if step.get('id') == 'deploy') - self.assertIn('--skip-promotion', upload['run']) - - def test_each_release_waits_for_all_three_gates(self): - for branch in ('main', 'staging', 'dev'): - active, ready = self.release_results(branch) - self.assertTrue(self.eligible('promote-images', branch, ready)) - for gate in active: - self.assertIn(gate, self.jobs['promote-images']['needs']) - for failure in ('failure', 'cancelled', 'skipped'): - with self.subTest(branch=branch, gate=gate, result=failure): - self.assertFalse(self.eligible('promote-images', branch, {**ready, gate: failure})) - self.assertFalse(self.eligible('promote-images', branch, ready, cancelled=True)) - self.assertFalse(self.eligible('promote-images', branch, ready, event='pull_request')) - - def test_migrations_still_require_successful_tests(self): - self.assertIn('test-build', self.jobs['migrate']['needs']) - for branch in ('main', 'staging'): - self.assertTrue(self.eligible('migrate', branch, {'test-build': 'success'})) - for result in ('failure', 'cancelled', 'skipped'): - self.assertFalse(self.eligible('migrate', branch, {'test-build': result})) - - def test_dev_build_cannot_move_deploy_tags(self): - steps = self.jobs['build-dev']['steps'] - build = next(step for step in steps if step.get('uses') == './.github/actions/docker-build') - self.assertTrue(build['with']['tags'].endswith(':${{ github.sha }}-dev')) - self.assertNotIn('promote-app-image.sh', json.dumps(steps)) - self.assertNotIn('imagetools create', json.dumps(steps)) - - def test_task_promotion_requires_a_successful_fresh_app_release(self): - for branch, job, upload in (('main', 'promote-trigger', 'deploy-trigger'), - ('staging', 'promote-trigger', 'deploy-trigger'), - ('dev', 'promote-trigger-dev', 'deploy-trigger-dev')): - ready = {'promote-images': 'success', upload: 'success'} - self.assertIn('promote-images', self.jobs[job]['needs']) - self.assertTrue(self.eligible(job, branch, ready)) - self.assertFalse(self.eligible(job, branch, ready, promoted='false')) - self.assertFalse(self.eligible(job, branch, {**ready, 'promote-images': 'failure'})) - steps = self.jobs[job]['steps'] - wait = next(i for i, step in enumerate(steps) if 'wait-for-ecs-cutover.sh' in step.get('run', '')) - promote = next(i for i, step in enumerate(steps) if 'promote "$VERSION"' in step.get('run', '')) - self.assertLess(wait, promote) - self.assertEqual(steps[promote]['env']['VERSION'], '${{ needs.' + upload + '.outputs.version }}') - - def test_permission_check_and_other_images_precede_app_rollout(self): - steps = self.jobs['promote-images']['steps'] - preflight = next(i for i, step in enumerate(steps) if 'get-pipeline-state' in step.get('run', '')) - retag = next(i for i, step in enumerate(steps) if step.get('id') == 'promote') - self.assertLess(preflight, retag) - self.assertTrue(steps[retag]['env']['ECR_REPOS'].strip().endswith('${{ secrets.ECR_APP }}')) - - -if __name__ == '__main__': - unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41aef55d921..9c110d101e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,154 +223,19 @@ jobs: tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:${{ github.sha }}-dev max-cache-size-mb: ${{ matrix.cache_mb }} - # Upload without promotion in parallel; the release gate waits for the schema. - deploy-trigger-dev: - name: Deploy Trigger.dev (Dev) - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} - timeout-minutes: 15 - outputs: - version: ${{ steps.deploy.outputs.deploymentVersion }} - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.4.1 - - - name: Cache Bun dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: | - ~/.bun/install/cache - node_modules - **/node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts - - - name: Deploy to Trigger.dev (skip promotion) - id: deploy - working-directory: ./apps/sim - env: - TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} - TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} - run: | - set -eo pipefail - if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then - echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 - exit 1 - fi - bunx trigger.dev@4.5.12 deploy --env preview --branch dev-sim --skip-promotion - - - name: Validate deployment version output - env: - VERSION: ${{ steps.deploy.outputs.deploymentVersion }} - run: | - if ! [[ "$VERSION" =~ ^[0-9]{8}\.[0-9]+$ ]]; then - echo "ERROR: Trigger.dev did not report a valid deploymentVersion output" >&2 - exit 1 - fi - - # Promote only after the gated app tag move, then observe live traffic cutover. - promote-trigger-dev: - name: Promote Trigger.dev (Dev) - needs: [promote-images, deploy-trigger-dev] - if: >- - !cancelled() && - github.event_name == 'push' && github.ref == 'refs/heads/dev' && - needs.promote-images.result == 'success' && - needs.promote-images.outputs.promoted == 'true' && - needs.deploy-trigger-dev.result == 'success' - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} - # Dev bake is 5 min and dev deploys don't queue behind a bake (serialized by the - # ci- group), so a 20-min poll is ample; the 40-min job leaves ~20 min for - # setup + promote above it (mirrors the prod 90-vs-70 margin), and the 40-min - # session outlasts the poll. - timeout-minutes: 40 - permissions: - contents: read - id-token: write - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.4.1 - - - name: Cache Bun dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: | - ~/.bun/install/cache - node_modules - **/node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 - with: - role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }} - aws-region: ${{ secrets.DEV_AWS_REGION }} - role-duration-seconds: 2400 - - - name: Wait for ECS traffic cutover - env: - OVERALL_TIMEOUT: "1200" - CHANGED: ${{ needs.promote-images.outputs.app_image_changed }} - DIGEST: ${{ needs.promote-images.outputs.app_image_digest }} - EPOCH: ${{ needs.promote-images.outputs.retag_epoch }} - run: | - set -eo pipefail - case "$CHANGED" in - true) ;; - false) EPOCH=0 ;; - *) echo "ERROR: invalid app image change metadata" >&2; exit 1 ;; - esac - bash .github/scripts/wait-for-ecs-cutover.sh sim-dev-us-east-1-app-deployment "$DIGEST" "$EPOCH" - - - name: Promote Trigger.dev version - working-directory: ./apps/sim - env: - TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} - TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} - VERSION: ${{ needs.deploy-trigger-dev.outputs.version }} - run: | - set -eo pipefail - if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then - echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 - exit 1 - fi - if [ -z "$VERSION" ]; then - echo "ERROR: no deployed version passed from deploy-trigger-dev" >&2 - exit 1 - fi - echo "Promoting Trigger.dev version $VERSION (preview / dev-sim)" - bunx trigger.dev@4.5.12 promote "$VERSION" --env preview --branch dev-sim - # Build and upload tasks alongside tests and images. The unpromoted version # cannot serve new runs; promote-images waits for it and successful migrations. - deploy-trigger: - name: Deploy Trigger.dev + prepare-trigger: + name: Prepare Trigger.dev if: >- github.event_name == 'push' && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 outputs: version: ${{ steps.deploy.outputs.deploymentVersion }} + environment: ${{ steps.target.outputs.environment }} + preview_branch: ${{ steps.target.outputs.preview_branch }} steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -394,20 +259,37 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts - - name: Deploy to Trigger.dev (skip promotion) + - name: Select Trigger environment + id: target + run: | + case "$GITHUB_REF" in + refs/heads/main) TRIGGER_ENV=prod; TRIGGER_BRANCH='' ;; + refs/heads/staging) TRIGGER_ENV=staging; TRIGGER_BRANCH='' ;; + refs/heads/dev) TRIGGER_ENV=preview; TRIGGER_BRANCH=dev-sim ;; + *) echo "ERROR: unsupported Trigger release ref: $GITHUB_REF" >&2; exit 1 ;; + esac + echo "environment=$TRIGGER_ENV" >> "$GITHUB_OUTPUT" + echo "preview_branch=$TRIGGER_BRANCH" >> "$GITHUB_OUTPUT" + + - name: Upload Trigger.dev version without promotion id: deploy working-directory: ./apps/sim env: TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} - TRIGGER_ENV: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }} + TRIGGER_ENV: ${{ steps.target.outputs.environment }} + TRIGGER_BRANCH: ${{ steps.target.outputs.preview_branch }} run: | set -eo pipefail if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then echo "ERROR: TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 exit 1 fi - bunx trigger.dev@4.5.12 deploy --env "$TRIGGER_ENV" --skip-promotion + TARGET_ARGS=(--env "$TRIGGER_ENV") + if [ -n "$TRIGGER_BRANCH" ]; then + TARGET_ARGS+=(--branch "$TRIGGER_BRANCH") + fi + bunx trigger.dev@4.5.12 deploy "${TARGET_ARGS[@]}" --skip-promotion - name: Validate deployment version output env: @@ -552,19 +434,18 @@ jobs: # moves; a missing image can't produce a partial mixed-version deploy. promote-images: name: Promote Images - needs: [migrate, build-amd64, deploy-trigger, migrate-dev, build-dev, deploy-trigger-dev] + needs: [migrate, build-amd64, prepare-trigger, migrate-dev, build-dev] # Explicit results: see migrate's comment. if: >- !cancelled() && github.event_name == 'push' && + needs.prepare-trigger.result == 'success' && ( ((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') && needs.migrate.result == 'success' && - needs.build-amd64.result == 'success' && - needs.deploy-trigger.result == 'success') || + needs.build-amd64.result == 'success') || (github.ref == 'refs/heads/dev' && needs.migrate-dev.result == 'success' && - needs.build-dev.result == 'success' && - needs.deploy-trigger-dev.result == 'success') + needs.build-dev.result == 'success') ) runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 10 @@ -665,7 +546,7 @@ jobs: fi done - # Main/staging: promote the parked Trigger.dev version after observing the ECS + # Promote the parked Trigger.dev version after observing the ECS # traffic cutover (CodeDeploy AllowTraffic on every target). The image retag # triggers the ECS pipeline; this job correlates it via the digest + retag epoch # (rejecting a stale execution reusing the digest) and promotes at cutover. @@ -674,20 +555,19 @@ jobs: # promote never fires and this job fails visibly. promote-trigger: name: Promote Trigger.dev - needs: [promote-images, deploy-trigger] + needs: [promote-images, prepare-trigger] # Explicit results also suppress skip propagation from optional ancestors. if: >- !cancelled() && github.event_name == 'push' && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') && needs.promote-images.result == 'success' && - needs.deploy-trigger.result == 'success' && + needs.prepare-trigger.result == 'success' && needs.promote-images.outputs.promoted == 'true' runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} - # Must exceed the poll script's OVERALL_TIMEOUT (70 min, covering a prod deploy - # queued behind a ~50-min bake) PLUS runner setup + the final promote step, so - # the Actions timeout never kills the job before the script's own deadline. - timeout-minutes: 90 + # Leave setup/promotion headroom above the cutover poll (dev: 20 min; + # staging/prod: 70 min, including a deploy queued behind a long bake). + timeout-minutes: ${{ github.ref == 'refs/heads/dev' && 40 || 90 }} permissions: contents: read id-token: write @@ -717,21 +597,19 @@ jobs: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: - role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} - aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} - # The poll can run up to ~70 min (prod deploy queued behind a bake), which - # outlasts the default 1h session. Hold the session for the full job so AWS - # calls don't start failing mid-poll. Requires the deploy role's - # MaxSessionDuration to be >= this value (roles are managed outside the repo). - role-duration-seconds: 5400 + 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 }} + aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || github.ref == 'refs/heads/dev' && secrets.DEV_AWS_REGION || secrets.STAGING_AWS_REGION }} + # Match each environment's session budget; both outlast their polls. + role-duration-seconds: ${{ github.ref == 'refs/heads/dev' && 2400 || 5400 }} # An unchanged tag may belong to a failed or still-running earlier deploy. # Verify its latest cutover rather than treating tag equality as success. - name: Wait for ECS traffic cutover env: + OVERALL_TIMEOUT: ${{ github.ref == 'refs/heads/dev' && 1200 || 4200 }} APP_IMAGE_CHANGED: ${{ needs.promote-images.outputs.app_image_changed }} DIGEST: ${{ needs.promote-images.outputs.app_image_digest }} - PIPELINE: sim-${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}-us-east-1-app-deployment + PIPELINE: sim-${{ github.ref == 'refs/heads/main' && 'production' || github.ref == 'refs/heads/dev' && 'dev' || 'staging' }}-us-east-1-app-deployment RETAG_EPOCH: ${{ needs.promote-images.outputs.retag_epoch }} run: | set -eo pipefail @@ -747,8 +625,9 @@ jobs: env: TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} - TRIGGER_ENV: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }} - VERSION: ${{ needs.deploy-trigger.outputs.version }} + TRIGGER_ENV: ${{ needs.prepare-trigger.outputs.environment }} + TRIGGER_BRANCH: ${{ needs.prepare-trigger.outputs.preview_branch }} + VERSION: ${{ needs.prepare-trigger.outputs.version }} run: | set -eo pipefail if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then @@ -756,11 +635,15 @@ jobs: exit 1 fi if [ -z "$VERSION" ]; then - echo "ERROR: no deployed version passed from deploy-trigger" >&2 + echo "ERROR: no deployed version passed from prepare-trigger" >&2 exit 1 fi echo "Promoting Trigger.dev version $VERSION ($TRIGGER_ENV)" - bunx trigger.dev@4.5.12 promote "$VERSION" --env "$TRIGGER_ENV" + TARGET_ARGS=(--env "$TRIGGER_ENV") + if [ -n "$TRIGGER_BRANCH" ]; then + TARGET_ARGS+=(--branch "$TRIGGER_BRANCH") + fi + bunx trigger.dev@4.5.12 promote "$VERSION" "${TARGET_ARGS[@]}" # Build ARM64 images for GHCR (main branch only, runs in parallel with # tests). Pushes only the immutable sha tag — latest-arm64/version-arm64 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index c2ac8153946..ee25a9bc578 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -370,9 +370,6 @@ jobs: - name: Lint code run: bun run lint:check - - name: Test Trigger deployment gates - run: python3 .github/scripts/test-trigger-deploy.py - # Every zero-argument `check:*` script, run concurrently. The list is derived in # scripts/run-audits.ts, which also writes the per-audit timing table to the job # summary and annotates failures. Audits needing a base ref stay separate below.