|
41 | 41 | type: choice |
42 | 42 | options: [staging, prod] |
43 | 43 | default: staging |
| 44 | + operation: |
| 45 | + description: "Operation to run" |
| 46 | + required: true |
| 47 | + type: choice |
| 48 | + options: [deploy, rollback] |
| 49 | + default: deploy |
| 50 | + rollback_tag: |
| 51 | + description: "Required when operation=rollback (for example: staging-a1b2c3d or v1.2.3)" |
| 52 | + required: false |
| 53 | + type: string |
44 | 54 |
|
45 | 55 | permissions: |
46 | 56 | contents: read |
|
68 | 78 | timeout-minutes: 20 |
69 | 79 | if: > |
70 | 80 | (github.event_name == 'workflow_run' |
71 | | - && github.event.workflow_run.conclusion == 'success')|| (github.event_name == 'workflow_dispatch' |
72 | | - && inputs.environment == 'staging') |
| 81 | + && github.event.workflow_run.conclusion == 'success') || (github.event_name == 'workflow_dispatch' |
| 82 | + && inputs.environment == 'staging' |
| 83 | + && inputs.operation != 'rollback') |
73 | 84 | environment: staging |
74 | 85 | strategy: |
75 | 86 | matrix: |
@@ -148,6 +159,92 @@ jobs: |
148 | 159 | echo "Waiting 10 minutes for Terraform apply to complete..." |
149 | 160 | sleep 600 |
150 | 161 |
|
| 162 | + # --------------------------------------------------------------------------- |
| 163 | + # 1b2. Trigger Ansible deploy on EC2 infra repo (staging) |
| 164 | + # --------------------------------------------------------------------------- |
| 165 | + ec2-deploy-staging: |
| 166 | + name: "EC2 Deploy [staging] via Ansible" |
| 167 | + runs-on: ubuntu-latest |
| 168 | + timeout-minutes: 5 |
| 169 | + needs: [build-staging] |
| 170 | + environment: staging |
| 171 | + steps: |
| 172 | + - name: Dispatch ansible-ec2-deploy to infra3 repo |
| 173 | + id: dispatch_ec2_staging |
| 174 | + uses: actions/github-script@v7 |
| 175 | + with: |
| 176 | + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} |
| 177 | + script: | |
| 178 | + const headSha = (context.payload.workflow_run && context.payload.workflow_run.head_sha) || context.sha; |
| 179 | + const imageTag = `staging-${headSha.substring(0, 7)}`; |
| 180 | + await github.rest.actions.createWorkflowDispatch({ |
| 181 | + owner: context.repo.owner, |
| 182 | + repo: 'mypythonproject1-infra3', |
| 183 | + workflow_id: 'ansible-ec2-deploy.yml', |
| 184 | + ref: 'main', |
| 185 | + inputs: { |
| 186 | + environment: 'staging', |
| 187 | + operation: 'deploy', |
| 188 | + image_tag: imageTag, |
| 189 | + }, |
| 190 | + }); |
| 191 | + console.log(`Dispatched ansible-ec2-deploy on mypythonproject1-infra3 (staging, tag=${imageTag})`); |
| 192 | + core.setOutput('image_tag', imageTag); |
| 193 | +
|
| 194 | + ec2-verify-staging: |
| 195 | + name: "Verify EC2 Deploy [staging]" |
| 196 | + runs-on: ubuntu-latest |
| 197 | + timeout-minutes: 30 |
| 198 | + needs: [ec2-deploy-staging] |
| 199 | + environment: staging |
| 200 | + steps: |
| 201 | + - name: Wait for infra3 ansible workflow result |
| 202 | + uses: actions/github-script@v7 |
| 203 | + with: |
| 204 | + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} |
| 205 | + script: | |
| 206 | + const owner = context.repo.owner; |
| 207 | + const repo = 'mypythonproject1-infra3'; |
| 208 | + const expectedTag = `${((context.payload.workflow_run && context.payload.workflow_run.head_sha) || context.sha).substring(0, 7)}`; |
| 209 | + const tagNeedle = `tag=staging-${expectedTag}`; |
| 210 | + const deadline = Date.now() + 30 * 60 * 1000; |
| 211 | +
|
| 212 | + let matchedRun = null; |
| 213 | + while (Date.now() < deadline) { |
| 214 | + const resp = await github.rest.actions.listWorkflowRuns({ |
| 215 | + owner, |
| 216 | + repo, |
| 217 | + workflow_id: 'ansible-ec2-deploy.yml', |
| 218 | + event: 'workflow_dispatch', |
| 219 | + per_page: 20, |
| 220 | + }); |
| 221 | +
|
| 222 | + matchedRun = resp.data.workflow_runs.find((r) => { |
| 223 | + const title = r.display_title || ''; |
| 224 | + return title.includes('[staging]') && title.includes(tagNeedle); |
| 225 | + }); |
| 226 | +
|
| 227 | + if (!matchedRun) { |
| 228 | + await new Promise((resolve) => setTimeout(resolve, 15000)); |
| 229 | + continue; |
| 230 | + } |
| 231 | +
|
| 232 | + if (matchedRun.status !== 'completed') { |
| 233 | + await new Promise((resolve) => setTimeout(resolve, 15000)); |
| 234 | + continue; |
| 235 | + } |
| 236 | +
|
| 237 | + if (matchedRun.conclusion !== 'success') { |
| 238 | + core.setFailed(`infra3 ansible deploy failed: ${matchedRun.html_url}`); |
| 239 | + return; |
| 240 | + } |
| 241 | +
|
| 242 | + core.notice(`infra3 ansible deploy succeeded: ${matchedRun.html_url}`); |
| 243 | + return; |
| 244 | + } |
| 245 | +
|
| 246 | + core.setFailed('Timed out waiting for infra3 ansible staging deployment run to complete'); |
| 247 | +
|
151 | 248 | # --------------------------------------------------------------------------- |
152 | 249 | # 1c. ECS rolling deploy (staging) |
153 | 250 | # --------------------------------------------------------------------------- |
@@ -253,7 +350,9 @@ jobs: |
253 | 350 | name: "Build & Push [production] (${{ matrix.service }})" |
254 | 351 | runs-on: ubuntu-latest |
255 | 352 | timeout-minutes: 20 |
256 | | - if: startsWith(github.ref, 'refs/tags/v') |
| 353 | + if: > |
| 354 | + startsWith(github.ref, 'refs/tags/v') || |
| 355 | + (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod' && inputs.operation != 'rollback') |
257 | 356 | environment: prod |
258 | 357 | strategy: |
259 | 358 | matrix: |
@@ -339,6 +438,191 @@ jobs: |
339 | 438 | echo "Waiting 15 minutes for Terraform apply to complete..." |
340 | 439 | sleep 900 |
341 | 440 |
|
| 441 | + # --------------------------------------------------------------------------- |
| 442 | + # 2c2. Trigger Ansible deploy on EC2 infra repo (production) |
| 443 | + # --------------------------------------------------------------------------- |
| 444 | + ec2-deploy-production: |
| 445 | + name: "EC2 Deploy [production] via Ansible" |
| 446 | + runs-on: ubuntu-latest |
| 447 | + timeout-minutes: 5 |
| 448 | + needs: [build-production] |
| 449 | + environment: prod |
| 450 | + steps: |
| 451 | + - name: Dispatch ansible-ec2-deploy to infra3 repo |
| 452 | + id: dispatch_ec2_prod |
| 453 | + uses: actions/github-script@v7 |
| 454 | + with: |
| 455 | + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} |
| 456 | + script: | |
| 457 | + const tag = context.ref.replace('refs/tags/', ''); |
| 458 | + await github.rest.actions.createWorkflowDispatch({ |
| 459 | + owner: context.repo.owner, |
| 460 | + repo: 'mypythonproject1-infra3', |
| 461 | + workflow_id: 'ansible-ec2-deploy.yml', |
| 462 | + ref: 'main', |
| 463 | + inputs: { |
| 464 | + environment: 'prod', |
| 465 | + operation: 'deploy', |
| 466 | + image_tag: tag, |
| 467 | + production_confirmation: 'APPROVE_PROD_DEPLOY', |
| 468 | + }, |
| 469 | + }); |
| 470 | + console.log(`Dispatched ansible-ec2-deploy on mypythonproject1-infra3 (prod, tag=${tag})`); |
| 471 | + core.setOutput('image_tag', tag); |
| 472 | +
|
| 473 | + ec2-verify-production: |
| 474 | + name: "Verify EC2 Deploy [production]" |
| 475 | + runs-on: ubuntu-latest |
| 476 | + timeout-minutes: 45 |
| 477 | + needs: [ec2-deploy-production] |
| 478 | + environment: prod |
| 479 | + steps: |
| 480 | + - name: Wait for infra3 ansible workflow result |
| 481 | + uses: actions/github-script@v7 |
| 482 | + with: |
| 483 | + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} |
| 484 | + script: | |
| 485 | + const owner = context.repo.owner; |
| 486 | + const repo = 'mypythonproject1-infra3'; |
| 487 | + const tag = context.ref.replace('refs/tags/', ''); |
| 488 | + const tagNeedle = `tag=${tag}`; |
| 489 | + const deadline = Date.now() + 45 * 60 * 1000; |
| 490 | +
|
| 491 | + let matchedRun = null; |
| 492 | + while (Date.now() < deadline) { |
| 493 | + const resp = await github.rest.actions.listWorkflowRuns({ |
| 494 | + owner, |
| 495 | + repo, |
| 496 | + workflow_id: 'ansible-ec2-deploy.yml', |
| 497 | + event: 'workflow_dispatch', |
| 498 | + per_page: 20, |
| 499 | + }); |
| 500 | +
|
| 501 | + matchedRun = resp.data.workflow_runs.find((r) => { |
| 502 | + const title = r.display_title || ''; |
| 503 | + return title.includes('[prod]') && title.includes(tagNeedle); |
| 504 | + }); |
| 505 | +
|
| 506 | + if (!matchedRun) { |
| 507 | + await new Promise((resolve) => setTimeout(resolve, 20000)); |
| 508 | + continue; |
| 509 | + } |
| 510 | +
|
| 511 | + if (matchedRun.status !== 'completed') { |
| 512 | + await new Promise((resolve) => setTimeout(resolve, 20000)); |
| 513 | + continue; |
| 514 | + } |
| 515 | +
|
| 516 | + if (matchedRun.conclusion !== 'success') { |
| 517 | + core.setFailed(`infra3 ansible deploy failed: ${matchedRun.html_url}`); |
| 518 | + return; |
| 519 | + } |
| 520 | +
|
| 521 | + core.notice(`infra3 ansible deploy succeeded: ${matchedRun.html_url}`); |
| 522 | + return; |
| 523 | + } |
| 524 | +
|
| 525 | + core.setFailed('Timed out waiting for infra3 ansible production deployment run to complete'); |
| 526 | +
|
| 527 | + # --------------------------------------------------------------------------- |
| 528 | + # 3. Manual rollback on EC2 infra repo via Ansible |
| 529 | + # --------------------------------------------------------------------------- |
| 530 | + ec2-rollback-manual: |
| 531 | + name: "EC2 Rollback [manual] via Ansible" |
| 532 | + runs-on: ubuntu-latest |
| 533 | + timeout-minutes: 5 |
| 534 | + if: github.event_name == 'workflow_dispatch' && inputs.operation == 'rollback' |
| 535 | + environment: ${{ inputs.environment }} |
| 536 | + outputs: |
| 537 | + rollback_tag: ${{ steps.dispatch_rollback.outputs.rollback_tag }} |
| 538 | + steps: |
| 539 | + - name: Validate rollback tag input |
| 540 | + shell: bash |
| 541 | + run: | |
| 542 | + if [ -z "${{ inputs.rollback_tag }}" ]; then |
| 543 | + echo "::error::rollback_tag is required when operation=rollback" |
| 544 | + exit 1 |
| 545 | + fi |
| 546 | +
|
| 547 | + - name: Dispatch ansible rollback to infra3 repo |
| 548 | + id: dispatch_rollback |
| 549 | + uses: actions/github-script@v7 |
| 550 | + with: |
| 551 | + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} |
| 552 | + script: | |
| 553 | + const env = '${{ inputs.environment }}'; |
| 554 | + const rollbackTag = '${{ inputs.rollback_tag }}'; |
| 555 | + const isProd = env === 'prod'; |
| 556 | + await github.rest.actions.createWorkflowDispatch({ |
| 557 | + owner: context.repo.owner, |
| 558 | + repo: 'mypythonproject1-infra3', |
| 559 | + workflow_id: 'ansible-ec2-deploy.yml', |
| 560 | + ref: 'main', |
| 561 | + inputs: { |
| 562 | + environment: env, |
| 563 | + operation: 'rollback', |
| 564 | + image_tag: rollbackTag, |
| 565 | + production_confirmation: isProd ? 'APPROVE_PROD_DEPLOY' : '', |
| 566 | + }, |
| 567 | + }); |
| 568 | + console.log(`Dispatched ansible rollback on mypythonproject1-infra3 (${env}, tag=${rollbackTag})`); |
| 569 | + core.setOutput('rollback_tag', rollbackTag); |
| 570 | +
|
| 571 | + ec2-verify-rollback-manual: |
| 572 | + name: "Verify EC2 Rollback [manual]" |
| 573 | + runs-on: ubuntu-latest |
| 574 | + timeout-minutes: 45 |
| 575 | + if: github.event_name == 'workflow_dispatch' && inputs.operation == 'rollback' |
| 576 | + needs: [ec2-rollback-manual] |
| 577 | + environment: ${{ inputs.environment }} |
| 578 | + steps: |
| 579 | + - name: Wait for infra3 ansible rollback result |
| 580 | + uses: actions/github-script@v7 |
| 581 | + with: |
| 582 | + github-token: ${{ secrets.INFRA_DEPLOY_TOKEN }} |
| 583 | + script: | |
| 584 | + const owner = context.repo.owner; |
| 585 | + const repo = 'mypythonproject1-infra3'; |
| 586 | + const env = '${{ inputs.environment }}'; |
| 587 | + const rollbackTag = '${{ inputs.rollback_tag }}'; |
| 588 | + const envNeedle = `[${env}]`; |
| 589 | + const opNeedle = 'rollback'; |
| 590 | + const tagNeedle = `tag=${rollbackTag}`; |
| 591 | + const timeoutMs = env === 'prod' ? 45 * 60 * 1000 : 30 * 60 * 1000; |
| 592 | + const pollMs = 20000; |
| 593 | + const deadline = Date.now() + timeoutMs; |
| 594 | +
|
| 595 | + let matchedRun = null; |
| 596 | + while (Date.now() < deadline) { |
| 597 | + const resp = await github.rest.actions.listWorkflowRuns({ |
| 598 | + owner, |
| 599 | + repo, |
| 600 | + workflow_id: 'ansible-ec2-deploy.yml', |
| 601 | + event: 'workflow_dispatch', |
| 602 | + per_page: 20, |
| 603 | + }); |
| 604 | +
|
| 605 | + matchedRun = resp.data.workflow_runs.find((r) => { |
| 606 | + const title = (r.display_title || '').toLowerCase(); |
| 607 | + return title.includes(envNeedle.toLowerCase()) && title.includes(opNeedle) && title.includes(tagNeedle.toLowerCase()); |
| 608 | + }); |
| 609 | +
|
| 610 | + if (!matchedRun || matchedRun.status !== 'completed') { |
| 611 | + await new Promise((resolve) => setTimeout(resolve, pollMs)); |
| 612 | + continue; |
| 613 | + } |
| 614 | +
|
| 615 | + if (matchedRun.conclusion !== 'success') { |
| 616 | + core.setFailed(`infra3 ansible rollback failed: ${matchedRun.html_url}`); |
| 617 | + return; |
| 618 | + } |
| 619 | +
|
| 620 | + core.notice(`infra3 ansible rollback succeeded: ${matchedRun.html_url}`); |
| 621 | + return; |
| 622 | + } |
| 623 | +
|
| 624 | + core.setFailed('Timed out waiting for infra3 ansible rollback run to complete'); |
| 625 | +
|
342 | 626 | # --------------------------------------------------------------------------- |
343 | 627 | # 2d. ECS rolling deploy (production) |
344 | 628 | # --------------------------------------------------------------------------- |
|
0 commit comments