@empire-builder-kit/nx 1.0.0-rc.33 → 1.0.0-rc.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +3 -3
  2. package/src/aws-authority/stage-authority.js +4 -0
  3. package/src/aws-authority/stage-authority.js.map +1 -1
  4. package/src/executors/deployment-plan/deployment-plan.js +3 -0
  5. package/src/executors/deployment-plan/deployment-plan.js.map +1 -1
  6. package/src/executors/github-bootstrap/github-bootstrap.js +1 -0
  7. package/src/executors/github-bootstrap/github-bootstrap.js.map +1 -1
  8. package/src/executors/local-deploy/local-deploy.d.ts +12 -3
  9. package/src/executors/local-deploy/local-deploy.js +156 -0
  10. package/src/executors/local-deploy/local-deploy.js.map +1 -1
  11. package/src/executors/local-deploy/schema.json +21 -0
  12. package/src/executors/router-waf-rehearsal/router-waf-rehearsal.d.ts +13 -0
  13. package/src/executors/router-waf-rehearsal/router-waf-rehearsal.js +323 -137
  14. package/src/executors/router-waf-rehearsal/router-waf-rehearsal.js.map +1 -1
  15. package/src/executors/router-waf-rehearsal/schema.json +5 -0
  16. package/src/generators/preset/workspace-files-thin-deploy.d.ts +14 -1
  17. package/src/generators/preset/workspace-files-thin-deploy.js +897 -28
  18. package/src/generators/preset/workspace-files-thin-deploy.js.map +1 -1
  19. package/src/generators/preset/workspace-files-thin-docs.d.ts +2 -0
  20. package/src/generators/preset/workspace-files-thin-docs.js +51 -6
  21. package/src/generators/preset/workspace-files-thin-docs.js.map +1 -1
  22. package/src/generators/preset/workspace-files.d.ts +7 -1
  23. package/src/generators/preset/workspace-files.js +80 -18
  24. package/src/generators/preset/workspace-files.js.map +1 -1
  25. package/src/generators/repository-policy/repository-policy.js +1 -0
  26. package/src/generators/repository-policy/repository-policy.js.map +1 -1
  27. package/src/generators/repository-policy/schema.d.ts +2 -0
  28. package/src/generators/repository-policy/schema.json +26 -6
  29. package/src/generators/slice/built-in-plan-infrastructure-files.js +12 -0
  30. package/src/generators/slice/built-in-plan-infrastructure-files.js.map +1 -1
  31. package/src/generators/upgrade/upgrade.d.ts +12 -0
  32. package/src/generators/upgrade/upgrade.js +68 -0
  33. package/src/generators/upgrade/upgrade.js.map +1 -1
  34. package/src/git-policy/action-pins.d.ts +5 -0
  35. package/src/git-policy/action-pins.js +5 -0
  36. package/src/git-policy/action-pins.js.map +1 -1
  37. package/src/git-policy/github-bootstrap.d.ts +10 -2
  38. package/src/git-policy/github-bootstrap.js +129 -34
  39. package/src/git-policy/github-bootstrap.js.map +1 -1
  40. package/src/git-policy/routing.d.ts +5 -1
  41. package/src/git-policy/routing.js +50 -0
  42. package/src/git-policy/routing.js.map +1 -1
  43. package/src/repository-policy/policy.d.ts +12 -1
  44. package/src/repository-policy/policy.js +42 -2
  45. package/src/repository-policy/policy.js.map +1 -1
  46. package/src/schemas/repository-policy-v1.json +55 -16
  47. package/src/supply-chain/generated-container-release-command.js +281 -74
  48. package/src/supply-chain/generated-container-release-command.js.map +1 -1
  49. package/src/supply-chain/generated-files.js +10 -11
  50. package/src/supply-chain/generated-files.js.map +1 -1
  51. package/src/workspace-deployment/affected-lifecycle.d.ts +53 -0
  52. package/src/workspace-deployment/affected-lifecycle.js +84 -0
  53. package/src/workspace-deployment/affected-lifecycle.js.map +1 -0
  54. package/src/workspace-deployment/index.d.ts +1 -0
  55. package/src/workspace-deployment/index.js +1 -0
  56. package/src/workspace-deployment/index.js.map +1 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.THIN_DEPLOY_WORKFLOW_PATH = void 0;
3
+ exports.EBK_RECORD_MAIN_TOKEN_SECRET = exports.THIN_DEPLOY_WORKFLOW_PATH = void 0;
4
4
  exports.renderThinDeployVerifyCaller = renderThinDeployVerifyCaller;
5
5
  exports.protectedBrowserInstallScript = protectedBrowserInstallScript;
6
6
  exports.exactStageDeployerGuardScript = exactStageDeployerGuardScript;
@@ -13,10 +13,17 @@ exports.postdeploySupplyChainBindingScript = postdeploySupplyChainBindingScript;
13
13
  exports.renderThinDeployWorkflowFilePlan = renderThinDeployWorkflowFilePlan;
14
14
  const tool_versions_js_1 = require("./tool-versions.js");
15
15
  exports.THIN_DEPLOY_WORKFLOW_PATH = '.github/workflows/deploy.yml';
16
+ /** The production environment secret that lets record-main fast-forward main. */
17
+ exports.EBK_RECORD_MAIN_TOKEN_SECRET = 'EBK_RECORD_MAIN_TOKEN';
16
18
  const EXACT_SEMVER = /^\d+\.\d+\.\d+$/;
17
19
  const WORKSPACE_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
18
20
  const ACTION_PIN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[a-f0-9]{40}(?: # [^\r\n]+)?$/;
19
21
  function assertOptions(options) {
22
+ if (options.productionApprovalMode === 'trunk-promotion' &&
23
+ (options.deploymentProfile === 'starter' ||
24
+ options.deliveryLane === 'trunk')) {
25
+ throw new Error('trunk-promotion requires an isolated deployment profile on the protected pair.');
26
+ }
20
27
  if (!WORKSPACE_NAME.test(options.name)) {
21
28
  throw new Error('Workspace name must be a bounded lowercase slug.');
22
29
  }
@@ -31,8 +38,8 @@ function assertOptions(options) {
31
38
  }
32
39
  }
33
40
  }
34
- function workflowCallTrigger(profile) {
35
- return profile === 'starter'
41
+ function workflowCallTrigger(profile, deliveryLane = 'protected-pair') {
42
+ return profile === 'starter' && deliveryLane !== 'trunk'
36
43
  ? ''
37
44
  : ` workflow_call:
38
45
  inputs:
@@ -53,10 +60,23 @@ function workflowCallTrigger(profile) {
53
60
  type: string
54
61
  `;
55
62
  }
56
- function renderThinDeployVerifyCaller(profile) {
57
- if (profile === 'starter')
63
+ function renderThinDeployVerifyCaller(profile, deliveryLane = 'protected-pair', productionApprovalMode = 'promotion-pr') {
64
+ const trunk = deliveryLane === 'trunk';
65
+ if (profile === 'starter' && !trunk)
58
66
  return '';
59
- const protectedBranchCondition = "(github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main')";
67
+ // GitHub validates every nested deploy.yml job against this caller's
68
+ // permissions on every event, so the ceiling must admit the managed
69
+ // rehearsal, which pushes its sealed receipt branch and opens the pull
70
+ // request with the workflow token (isolated profiles only).
71
+ const nestedWrite = profile !== 'starter';
72
+ // Trunk lane: every merged main push is a production delivery.
73
+ // Trunk promotion: production deploys from the develop run itself and
74
+ // record-main fast-forwards main afterwards, so a main push only verifies.
75
+ const protectedBranchCondition = trunk
76
+ ? "github.ref == 'refs/heads/main'"
77
+ : productionApprovalMode === 'trunk-promotion'
78
+ ? "github.ref == 'refs/heads/develop'"
79
+ : "(github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main')";
60
80
  return `
61
81
 
62
82
  deploy:
@@ -68,18 +88,26 @@ function renderThinDeployVerifyCaller(profile) {
68
88
  ${protectedBranchCondition} &&
69
89
  needs.acceptance.result == 'success' &&
70
90
  needs.acceptance.outputs.history_only != 'true' &&
71
- needs.release-candidate.result == 'success'
91
+ (needs.release-candidate.result == 'success' ||
92
+ (github.ref == 'refs/heads/main' && needs.release-candidate.result == 'skipped'))
93
+ # Ceiling for the nested deploy.yml jobs; each nested job declares its
94
+ # own least-privilege set. Isolated profiles admit the rehearse job's
95
+ # receipt push and pull request (EBK-INT-2026-080).
72
96
  permissions:
73
97
  actions: read
74
- contents: read
75
- id-token: write
98
+ contents: ${nestedWrite ? 'write' : 'read'}
99
+ id-token: write${nestedWrite ? '\n pull-requests: write' : ''}
76
100
  uses: ./.github/workflows/deploy.yml
77
101
  with:
78
102
  source_branch: \${{ github.ref_name }}
79
103
  source_commit: \${{ github.sha }}
80
104
  source_ref: \${{ github.ref }}
81
105
  verify_run_attempt: \${{ github.run_attempt }}
82
- verify_run_id: \${{ github.run_id }}`;
106
+ verify_run_id: \${{ github.run_id }}
107
+ # The reusable workflow reads the production environment secret
108
+ # AWS_STAGING_PROMOTION_PULL_ROLE_ARN, which only reaches a called
109
+ # workflow when the caller inherits secrets (EBK-INT-2026-075).
110
+ secrets: inherit`;
83
111
  }
84
112
  function indentedScript(source, spaces) {
85
113
  const prefix = ' '.repeat(spaces);
@@ -334,6 +362,13 @@ if (hasSlices) {
334
362
  const artifactId = required('EBK_CANDIDATE_ARTIFACT_ID', /^[1-9][0-9]*$/);
335
363
  const artifactDigest = required('EBK_CANDIDATE_ARTIFACT_DIGEST', /^sha256:[a-f0-9]{64}$/);
336
364
  const containerArtifactId = required('EBK_CONTAINER_ARTIFACT_ID', /^[1-9][0-9]*$/);
365
+ // actions/upload-artifact emits the bare SHA-256 hex in artifact-digest while
366
+ // the artifacts API and the promotion compare the sha256:<hex> registry form
367
+ // (EBK-INT-2026-073). Normalize before the exact validation.
368
+ const containerArtifactDigestRaw = process.env.EBK_CONTAINER_ARTIFACT_DIGEST?.trim() ?? '';
369
+ process.env.EBK_CONTAINER_ARTIFACT_DIGEST = /^[a-f0-9]{64}$/.test(containerArtifactDigestRaw)
370
+ ? 'sha256:' + containerArtifactDigestRaw
371
+ : containerArtifactDigestRaw;
337
372
  const containerArtifactDigest = required('EBK_CONTAINER_ARTIFACT_DIGEST', /^sha256:[a-f0-9]{64}$/);
338
373
  const root = '.ebk/evidence/release-candidate/packages';
339
374
  const projects = readdirSync(root, { withFileTypes: true })
@@ -442,22 +477,35 @@ writeFileSync(
442
477
  }
443
478
  function renderThinDeployWorkflow(options) {
444
479
  const { actionPins, deploymentProfile, name, toolVersions } = options;
480
+ const trunkPromotion = options.productionApprovalMode === 'trunk-promotion';
445
481
  return `name: Deploy
446
482
  run-name: Deploy \${{ inputs.source_commit || github.sha }}
447
483
 
448
484
  on:
449
- ${workflowCallTrigger(deploymentProfile)} workflow_dispatch:
485
+ ${workflowCallTrigger(deploymentProfile, options.deliveryLane)} workflow_dispatch:
450
486
  inputs:
451
487
  action:
452
488
  description: Native lifecycle action
453
489
  required: true
454
490
  default: deploy
455
491
  type: choice
456
- options: [deploy, refresh, unlock, remove]
492
+ options: [deploy, refresh, unlock, remove${deploymentProfile === 'starter' ? '' : ', rehearse'}]
457
493
  project:
458
494
  description: Required registered project for unlock or remove recovery
459
495
  required: false
460
- type: string
496
+ type: string${deploymentProfile === 'starter'
497
+ ? ''
498
+ : `
499
+ probe_url:
500
+ description: rehearse only; exact HTTPS Router health URL in /<kebab-slice>/api/health form for the staging blocking rehearsal
501
+ required: false
502
+ type: string`}
503
+ lifecycle:
504
+ description: affected skips Slices unchanged since the newest successful deployment; full deploys everything
505
+ required: false
506
+ default: affected
507
+ type: choice
508
+ options: [affected, full]
461
509
 
462
510
  concurrency:
463
511
  group: protected-lifecycle-\${{ inputs.source_branch || github.ref_name }}
@@ -478,6 +526,7 @@ jobs:
478
526
  runs-on: ubuntu-latest
479
527
  outputs:
480
528
  action: \${{ steps.route.outputs.action }}
529
+ probe_url: \${{ steps.route.outputs.probe_url }}
481
530
  account_id: \${{ steps.account.outputs.account_id }}
482
531
  has_slices: \${{ steps.policy.outputs.has_slices }}
483
532
  ordered_projects: \${{ steps.policy.outputs.ordered_projects }}
@@ -487,9 +536,14 @@ jobs:
487
536
  project: \${{ steps.route.outputs.project }}
488
537
  region: \${{ steps.policy.outputs.deployment_region }}
489
538
  slice_projects: \${{ steps.policy.outputs.slice_projects }}
539
+ slice_matrix: \${{ steps.policy.outputs.slice_matrix }}
490
540
  staging_account_id: \${{ steps.policy.outputs.staging_account_id }}
491
541
  stage: \${{ steps.route.outputs.stage }}
492
- stage_deploy_role_arn: \${{ steps.account.outputs.role_arn }}
542
+ stage_deploy_role_arn: \${{ steps.account.outputs.role_arn }}${trunkPromotion
543
+ ? `
544
+ production_account_id: \${{ steps.production-account.outputs.account_id }}
545
+ production_deploy_role_arn: \${{ steps.production-account.outputs.role_arn }}`
546
+ : ''}
493
547
  steps:
494
548
  - name: Verify exact protected branch tip before setup
495
549
  env:
@@ -551,6 +605,7 @@ jobs:
551
605
  name: Resolve protected stage and action
552
606
  env:
553
607
  EBK_DISPATCH_ACTION: \${{ inputs.action || '' }}
608
+ EBK_DISPATCH_PROBE_URL: \${{ inputs.probe_url || '' }}
554
609
  EBK_DISPATCH_PROJECT: \${{ inputs.project || '' }}
555
610
  EBK_PROFILE: ${deploymentProfile}
556
611
  EBK_SOURCE_BRANCH: \${{ env.EBK_DEPLOYMENT_SOURCE_BRANCH }}
@@ -566,12 +621,25 @@ jobs:
566
621
  fi
567
622
  case "$EBK_SOURCE_COMMIT" in ''|*[!0-9a-f]*) exit 1 ;; esac
568
623
  { [ "\${#EBK_SOURCE_COMMIT}" -eq 40 ] || [ "\${#EBK_SOURCE_COMMIT}" -eq 64 ]; } || exit 1
569
- case "$action" in deploy|refresh|unlock|remove) ;; *) exit 1 ;; esac
624
+ case "$action" in deploy|refresh|unlock|remove|rehearse) ;; *) exit 1 ;; esac
570
625
  case "$project" in ''|*[!A-Za-z0-9_-]*) [ -z "$project" ] || exit 1 ;; esac
571
626
  case "$action" in
572
627
  unlock|remove) [ -n "$project" ] || { echo 'unlock and remove require an explicit project'; exit 1; } ;;
573
628
  deploy|refresh) [ -z "$project" ] || { echo 'deploy and refresh operate on the policy-derived project order'; exit 1; } ;;
629
+ rehearse)
630
+ [ -z "$project" ] || { echo 'rehearse targets the foundation Router WAF and takes no project'; exit 1; }
631
+ [ "$EBK_PROFILE" != starter ] || { echo 'starter rehearses development locally; rehearse is for isolated staging'; exit 1; }
632
+ [ "$EBK_SOURCE_BRANCH" = develop ] || { echo 'rehearse runs from develop against staging'; exit 1; }
633
+ case "$EBK_DISPATCH_PROBE_URL" in
634
+ https://*/*/api/health) ;;
635
+ *) echo 'rehearse requires probe_url in https://<router>/<kebab-slice>/api/health form'; exit 1 ;;
636
+ esac
637
+ ;;
574
638
  esac
639
+ # The committed deployment plan knows deploy, refresh, unlock, and
640
+ # remove; a rehearsal derives the same project inventory as deploy.
641
+ plan_action="$action"
642
+ [ "$action" != rehearse ] || plan_action=deploy
575
643
  case "$EBK_SOURCE_BRANCH" in
576
644
  main) stage=production ;;
577
645
  develop)
@@ -584,12 +652,14 @@ jobs:
584
652
  *) echo 'Protected lifecycle must run from develop or main'; exit 1 ;;
585
653
  esac
586
654
  echo "action=$action" >> "$GITHUB_OUTPUT"
655
+ echo "plan_action=$plan_action" >> "$GITHUB_OUTPUT"
656
+ echo "probe_url=$EBK_DISPATCH_PROBE_URL" >> "$GITHUB_OUTPUT"
587
657
  echo "project=$project" >> "$GITHUB_OUTPUT"
588
658
  echo "stage=$stage" >> "$GITHUB_OUTPUT"
589
659
  - id: policy
590
660
  name: Derive ordered projects from committed policy
591
661
  env:
592
- EBK_ACTION: \${{ steps.route.outputs.action }}
662
+ EBK_ACTION: \${{ steps.route.outputs.plan_action }}
593
663
  EBK_PROJECT: \${{ steps.route.outputs.project }}
594
664
  EBK_STAGE: \${{ steps.route.outputs.stage }}
595
665
  run: |
@@ -611,7 +681,22 @@ jobs:
611
681
  EBK_STAGE: \${{ steps.route.outputs.stage }}
612
682
  EBK_STAGING_ACCOUNT: \${{ steps.policy.outputs.staging_account_id }}
613
683
  run: |
614
- ${indentedScript(`set -euo pipefail\n${canonicalStageAuthorityDerivationScript()}`, 10)}
684
+ ${indentedScript(`set -euo pipefail\n${canonicalStageAuthorityDerivationScript()}`, 10)}${trunkPromotion
685
+ ? `
686
+ # Trunk promotion deploys production from the develop run, so the
687
+ # production authority is derived here beside the staging authority.
688
+ - id: production-account
689
+ name: Derive canonical committed production authority for trunk promotion
690
+ if: steps.route.outputs.stage == 'staging'
691
+ env:
692
+ EBK_DEVELOPMENT_ACCOUNT: \${{ steps.policy.outputs.development_account_id }}
693
+ EBK_PRODUCTION_ACCOUNT: \${{ steps.policy.outputs.production_account_id }}
694
+ EBK_REGION: \${{ steps.policy.outputs.deployment_region }}
695
+ EBK_STAGE: production
696
+ EBK_STAGING_ACCOUNT: \${{ steps.policy.outputs.staging_account_id }}
697
+ run: |
698
+ ${indentedScript(`set -euo pipefail\n${canonicalStageAuthorityDerivationScript()}`, 10)}`
699
+ : ''}
615
700
 
616
701
  immutable-artifacts:
617
702
  name: prepare exact immutable release artifacts
@@ -634,6 +719,11 @@ ${indentedScript(`set -euo pipefail\n${canonicalStageAuthorityDerivationScript()
634
719
  container_artifact_id: \${{ steps.staging.outputs.container_artifact_id }}
635
720
  staging_deployment_run_attempt: \${{ steps.staging.outputs.deployment_run_attempt }}
636
721
  staging_deployment_run_id: \${{ steps.staging.outputs.deployment_run_id }}
722
+ baseline_artifact_digest: \${{ steps.baseline.outputs.artifact_digest }}
723
+ baseline_artifact_id: \${{ steps.baseline.outputs.artifact_id }}
724
+ baseline_commit: \${{ steps.baseline.outputs.commit }}
725
+ baseline_found: \${{ steps.baseline.outputs.found }}
726
+ baseline_run_id: \${{ steps.baseline.outputs.run_id }}
637
727
  producer_run_attempt: \${{ steps.current.outputs.producer_run_attempt || steps.staging.outputs.producer_run_attempt }}
638
728
  producer_run_id: \${{ steps.current.outputs.producer_run_id || steps.staging.outputs.producer_run_id }}
639
729
  source_commit: \${{ steps.current.outputs.source_commit || steps.staging.outputs.source_commit }}
@@ -684,6 +774,46 @@ ${indentedScript(`set -euo pipefail\n${canonicalStageAuthorityDerivationScript()
684
774
  echo "producer_run_attempt=$run_attempt" >> "$GITHUB_OUTPUT"
685
775
  echo "producer_run_id=$run_id" >> "$GITHUB_OUTPUT"
686
776
  echo "source_commit=$EBK_SOURCE_COMMIT" >> "$GITHUB_OUTPUT"
777
+ - id: baseline
778
+ name: Select the newest successful deployment receipt on this branch
779
+ if: >-
780
+ needs.prepare.outputs.has_slices == 'true' &&
781
+ (needs.prepare.outputs.stage != 'production' || needs.prepare.outputs.profile == 'starter')
782
+ env:
783
+ EBK_SOURCE_BRANCH: \${{ env.EBK_DEPLOYMENT_SOURCE_BRANCH }}
784
+ EBK_SOURCE_COMMIT: \${{ env.EBK_DEPLOYMENT_SOURCE_COMMIT }}
785
+ EBK_STAGE: \${{ needs.prepare.outputs.stage }}
786
+ GH_REPO: \${{ github.repository }}
787
+ GH_TOKEN: \${{ github.token }}
788
+ run: |
789
+ set -euo pipefail
790
+ # A baseline lets unchanged OCI artifacts reuse their retained evidence.
791
+ # Declining is always safe: without one, every artifact is rebuilt.
792
+ echo "found=false" >> "$GITHUB_OUTPUT"
793
+ runs="$(gh api "/repos/$GH_REPO/actions/workflows/ci.yml/runs?event=push&branch=$EBK_SOURCE_BRANCH&status=success&per_page=50")"
794
+ for candidate in $(printf '%s' "$runs" | jq -r --arg branch "$EBK_SOURCE_BRANCH" \\
795
+ '.workflow_runs | sort_by(.run_number) | reverse | .[] | select(.head_branch == $branch and .event == "push" and .conclusion == "success" and .path == ".github/workflows/ci.yml") | "\\(.id):\\(.run_attempt):\\(.head_sha)"'); do
796
+ IFS=':' read -r run_id run_attempt head_sha <<< "$candidate"
797
+ case "$run_id:$run_attempt" in *[!0-9:]*) continue ;; esac
798
+ case "$head_sha" in ????????????????????????????????????????) ;; *) continue ;; esac
799
+ [ "$head_sha" != "$EBK_SOURCE_COMMIT" ] || continue
800
+ git merge-base --is-ancestor "$head_sha" "$EBK_SOURCE_COMMIT" 2>/dev/null || continue
801
+ artifacts="$(gh api "/repos/$GH_REPO/actions/runs/$run_id/artifacts?per_page=100")"
802
+ receipt="$(printf '%s' "$artifacts" | jq -c --arg name "deployment-receipt-$EBK_STAGE-$run_id-$run_attempt" \\
803
+ '[.artifacts[] | select(.name == $name and .expired == false)] | if length == 1 then .[0] else empty end')"
804
+ [ -n "$receipt" ] || continue
805
+ artifact_id="$(printf '%s' "$receipt" | jq -r '.id // empty')"
806
+ artifact_digest="$(printf '%s' "$receipt" | jq -r '.digest // empty')"
807
+ case "$artifact_id" in ''|*[!0-9]*) continue ;; esac
808
+ case "$artifact_digest" in sha256:????????????????????????????????????????????????????????????????) ;; *) continue ;; esac
809
+ echo "artifact_digest=$artifact_digest" >> "$GITHUB_OUTPUT"
810
+ echo "artifact_id=$artifact_id" >> "$GITHUB_OUTPUT"
811
+ echo "commit=$head_sha" >> "$GITHUB_OUTPUT"
812
+ echo "found=true" >> "$GITHUB_OUTPUT"
813
+ echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
814
+ echo "Baseline deployment receipt: run $run_id attempt $run_attempt at $head_sha"
815
+ break
816
+ done
687
817
  - id: staging-receipt
688
818
  name: Select exact successful staging deployment receipt
689
819
  if: needs.prepare.outputs.stage == 'production' && needs.prepare.outputs.profile != 'starter'
@@ -751,7 +881,9 @@ ${indentedScript(`set -euo pipefail\n${canonicalStageAuthorityDerivationScript()
751
881
  EBK_EXPECTED_STAGING_ACCOUNT_ID: \${{ needs.prepare.outputs.staging_account_id }}
752
882
  EBK_STAGING_RECEIPT_ARTIFACT_DIGEST: \${{ steps.staging-receipt.outputs.artifact_digest }}
753
883
  EBK_STAGING_RECEIPT_ARTIFACT_ID: \${{ steps.staging-receipt.outputs.artifact_id }}
754
- EBK_STAGING_RECEIPT_ROOT: \${{ runner.temp }}/staging-deployment-receipt
884
+ # The receipt artifact keeps .ebk/evidence as its root, so latest.json
885
+ # and promotion.json live under deployment/ (EBK-INT-2026-074).
886
+ EBK_STAGING_RECEIPT_ROOT: \${{ runner.temp }}/staging-deployment-receipt/deployment
755
887
  EBK_STAGING_SOURCE_COMMIT: \${{ steps.staging-receipt.outputs.source_commit }}
756
888
  GH_REPO: \${{ github.repository }}
757
889
  GH_TOKEN: \${{ github.token }}
@@ -787,7 +919,7 @@ ${indentedScript(stagingPromotionReceiptValidationScript(), 10)}
787
919
  fi
788
920
 
789
921
  container-platform:
790
- name: native \${{ needs.prepare.outputs.stage }} OCI \${{ matrix.platform }}
922
+ name: native \${{ needs.prepare.outputs.stage }} OCI \${{ matrix.project.project }} \${{ matrix.platform }}
791
923
  needs: [prepare, immutable-artifacts]
792
924
  if: >-
793
925
  always() &&
@@ -801,7 +933,10 @@ ${indentedScript(stagingPromotionReceiptValidationScript(), 10)}
801
933
  )
802
934
  strategy:
803
935
  fail-fast: false
936
+ # One job per Slice and architecture: Slices build in parallel instead of
937
+ # looping on one runner per architecture.
804
938
  matrix:
939
+ project: \${{ fromJson(needs.prepare.outputs.slice_matrix) }}
805
940
  include:
806
941
  - architecture: amd64
807
942
  platform: linux/amd64
@@ -830,6 +965,7 @@ ${indentedScript(stagingPromotionReceiptValidationScript(), 10)}
830
965
  EBK_SOURCE_COMMIT: \${{ inputs.source_commit || github.sha }}
831
966
  EBK_STAGE: \${{ needs.prepare.outputs.stage }}
832
967
  EBK_SLICE_PROJECTS: \${{ needs.prepare.outputs.slice_projects }}
968
+ EBK_SLICE_PROJECT: \${{ matrix.project.project }}
833
969
  steps:
834
970
  - name: Check out immutable container source
835
971
  uses: ${actionPins.checkout}
@@ -870,6 +1006,17 @@ ${indentedScript(stagingPromotionReceiptValidationScript(), 10)}
870
1006
  github-token: \${{ github.token }}
871
1007
  path: .ebk/evidence/release-candidate
872
1008
  run-id: \${{ needs.immutable-artifacts.outputs.producer_run_id }}
1009
+ - name: Restore the baseline deployment receipt for unchanged artifacts
1010
+ if: needs.immutable-artifacts.outputs.baseline_found == 'true'
1011
+ uses: ${actionPins.downloadArtifact}
1012
+ env:
1013
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1014
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1015
+ with:
1016
+ artifact-ids: \${{ needs.immutable-artifacts.outputs.baseline_artifact_id }}
1017
+ github-token: \${{ github.token }}
1018
+ path: \${{ runner.temp }}/ebk-deployment-baseline
1019
+ run-id: \${{ needs.immutable-artifacts.outputs.baseline_run_id }}
873
1020
  - name: Revalidate exact protected branch tip before OIDC
874
1021
  env:
875
1022
  ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
@@ -900,22 +1047,48 @@ ${indentedScript(stagingPromotionReceiptValidationScript(), 10)}
900
1047
  [ "$AWS_EXPECTED_ACCOUNT_ID" = "$EBK_POLICY_ACCOUNT_ID" ]
901
1048
  [ "$AWS_REGION" = "$EBK_POLICY_REGION" ]
902
1049
  [ "$(aws sts get-caller-identity --query Account --output text)" = "$EBK_POLICY_ACCOUNT_ID" ]
1050
+ - name: Restore the container layer and release tool caches
1051
+ uses: ${actionPins.cache}
1052
+ env:
1053
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1054
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1055
+ with:
1056
+ path: |
1057
+ \${{ runner.temp }}/ebk-buildx-cache
1058
+ \${{ runner.temp }}/ebk-release-tools
1059
+ key: ebk-container-\${{ needs.prepare.outputs.stage }}-\${{ matrix.project.project }}-\${{ matrix.architecture }}-\${{ hashFiles('pnpm-lock.yaml') }}-\${{ github.run_id }}
1060
+ restore-keys: |
1061
+ ebk-container-\${{ needs.prepare.outputs.stage }}-\${{ matrix.project.project }}-\${{ matrix.architecture }}-\${{ hashFiles('pnpm-lock.yaml') }}-
1062
+ ebk-container-\${{ needs.prepare.outputs.stage }}-\${{ matrix.project.project }}-\${{ matrix.architecture }}-
903
1063
  - name: Build and run exact native OCI artifacts
1064
+ env:
1065
+ EBK_BUILDX_CACHE_DIR: \${{ runner.temp }}/ebk-buildx-cache
1066
+ EBK_RELEASE_TOOL_CACHE_DIR: \${{ runner.temp }}/ebk-release-tools
1067
+ EBK_CONTAINER_BASELINE_COMMIT: \${{ needs.immutable-artifacts.outputs.baseline_commit }}
1068
+ EBK_CONTAINER_BASELINE_ROOT: \${{ needs.immutable-artifacts.outputs.baseline_found == 'true' && format('{0}/ebk-deployment-baseline', runner.temp) || '' }}
904
1069
  run: |
905
1070
  set -euo pipefail
906
1071
  ${indentedScript(exactStageDeployerGuardScript('Container build', '$EBK_POLICY_ACCOUNT_ID'), 10)}
907
- IFS=',' read -r -a projects <<< "$EBK_SLICE_PROJECTS"
908
- for project in "\${projects[@]}"; do
909
- test -n "$project" || continue
910
- pnpm nx run "$project:\${{ matrix.target }}"
911
- done
1072
+ case "$EBK_SLICE_PROJECT" in
1073
+ ''|-*|*-|*[!a-z0-9-]*) echo 'Container build Slice project must be an exact normalized Slice name'; exit 1 ;;
1074
+ esac
1075
+ case ",$EBK_SLICE_PROJECTS," in
1076
+ *",$EBK_SLICE_PROJECT,"*) ;;
1077
+ *) echo 'Container build Slice project is not in the committed deployment plan'; exit 1 ;;
1078
+ esac
1079
+ pnpm nx run "$EBK_SLICE_PROJECT:\${{ matrix.target }}"
912
1080
  - name: Retain native platform evidence
913
1081
  uses: ${actionPins.uploadArtifact}
914
1082
  with:
915
- name: container-platform-\${{ needs.prepare.outputs.stage }}-\${{ matrix.architecture }}-\${{ github.run_id }}-\${{ github.run_attempt }}
1083
+ name: container-platform-\${{ needs.prepare.outputs.stage }}-\${{ matrix.project.project }}-\${{ matrix.architecture }}-\${{ github.run_id }}-\${{ github.run_attempt }}
916
1084
  if-no-files-found: error
917
1085
  include-hidden-files: true
918
1086
  retention-days: 30
1087
+ # The glob keeps partials/<stage> as the artifact root: the assemble
1088
+ # job merges every platform artifact into that directory and the
1089
+ # container release tool reads <project>/<architecture>/ beneath it.
1090
+ # A literal <project>/<architecture> path would make the architecture
1091
+ # directory the root and flatten the merged restore.
919
1092
  path: .ebk/evidence/container-release/partials/\${{ needs.prepare.outputs.stage }}/*/\${{ matrix.architecture }}
920
1093
 
921
1094
  container-assemble:
@@ -1213,8 +1386,10 @@ ${indentedScript(exactStageDeployerGuardScript('Container promotion', '$AWS_EXPE
1213
1386
  IFS=',' read -r -a projects <<< "$EBK_SLICE_PROJECTS"
1214
1387
  for project in "\${projects[@]}"; do
1215
1388
  test -n "$project" || continue
1216
- export EBK_CONTAINER_PROMOTION_SOURCE_BINDING_PATH="../../.ebk/evidence/container-release/staging/packages/$project/binding.json"
1217
- export EBK_CONTAINER_PROMOTION_SOURCE_SIDECAR_PATH="../../.ebk/evidence/container-release/staging/packages/$project/release-bound-binding.json"
1389
+ # The container release tool resolves these against the repository
1390
+ # root, so they stay repository-relative (EBK-INT-2026-077).
1391
+ export EBK_CONTAINER_PROMOTION_SOURCE_BINDING_PATH=".ebk/evidence/container-release/staging/packages/$project/binding.json"
1392
+ export EBK_CONTAINER_PROMOTION_SOURCE_SIDECAR_PATH=".ebk/evidence/container-release/staging/packages/$project/release-bound-binding.json"
1218
1393
  pnpm nx run "$project:container-release-promote"
1219
1394
  done
1220
1395
  - id: retain
@@ -1233,6 +1408,7 @@ ${indentedScript(exactStageDeployerGuardScript('Container promotion', '$AWS_EXPE
1233
1408
  if: >-
1234
1409
  always() &&
1235
1410
  needs.prepare.result == 'success' &&
1411
+ needs.prepare.outputs.action != 'rehearse' &&
1236
1412
  (
1237
1413
  needs.prepare.outputs.action != 'deploy' ||
1238
1414
  (
@@ -1284,6 +1460,9 @@ ${indentedScript(exactStageDeployerGuardScript('Container promotion', '$AWS_EXPE
1284
1460
  EBK_SLICE_PROJECTS: \${{ needs.prepare.outputs.slice_projects }}
1285
1461
  EBK_SOURCE_COMMIT: \${{ inputs.source_commit || github.sha }}
1286
1462
  EBK_STAGE: \${{ needs.prepare.outputs.stage }}
1463
+ EBK_LIFECYCLE_SCOPE: \${{ inputs.lifecycle || 'affected' }}
1464
+ EBK_BASELINE_FOUND: \${{ needs.immutable-artifacts.outputs.baseline_found }}
1465
+ EBK_BASELINE_RUN_ID: \${{ needs.immutable-artifacts.outputs.baseline_run_id }}
1287
1466
  steps:
1288
1467
  - name: Check out immutable deployment source
1289
1468
  uses: ${actionPins.checkout}
@@ -1325,6 +1504,17 @@ ${indentedScript(exactStageDeployerGuardScript('Container promotion', '$AWS_EXPE
1325
1504
  github-token: \${{ github.token }}
1326
1505
  path: .ebk/evidence/release-candidate
1327
1506
  run-id: \${{ needs.immutable-artifacts.outputs.producer_run_id }}
1507
+ - name: Restore the baseline deployment receipt for unchanged Slices
1508
+ if: needs.prepare.outputs.action == 'deploy' && needs.immutable-artifacts.outputs.baseline_found == 'true'
1509
+ uses: ${actionPins.downloadArtifact}
1510
+ env:
1511
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1512
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1513
+ with:
1514
+ artifact-ids: \${{ needs.immutable-artifacts.outputs.baseline_artifact_id }}
1515
+ github-token: \${{ github.token }}
1516
+ path: \${{ runner.temp }}/ebk-deployment-baseline
1517
+ run-id: \${{ needs.immutable-artifacts.outputs.baseline_run_id }}
1328
1518
  - name: Restore exact current-stage container binding
1329
1519
  if: >-
1330
1520
  needs.prepare.outputs.action == 'deploy' &&
@@ -1476,9 +1666,21 @@ ${indentedScript(exactStageDeployerGuardScript('Native lifecycle', '$EBK_POLICY_
1476
1666
  set -euo pipefail
1477
1667
  test -n "$AWS_DEPLOY_ROLE_ARN" || { echo 'Stage deployment role is absent'; exit 1; }
1478
1668
  if [ "$EBK_ACTION" = deploy ]; then
1669
+ case "$EBK_LIFECYCLE_SCOPE" in affected|full) ;; *) echo 'Lifecycle scope must be affected or full'; exit 1 ;; esac
1670
+ baseline_arguments=()
1671
+ if [ "$EBK_LIFECYCLE_SCOPE" = affected ] && [ "$EBK_BASELINE_FOUND" = true ]; then
1672
+ case "$EBK_BASELINE_RUN_ID" in ''|*[!0-9]*) echo 'Baseline run id is malformed'; exit 1 ;; esac
1673
+ baseline_arguments=(
1674
+ --baselineReceipt="$RUNNER_TEMP/ebk-deployment-baseline/deployment/latest.json"
1675
+ --baselineRunId="$EBK_BASELINE_RUN_ID"
1676
+ --baselineRunAttempt=1
1677
+ )
1678
+ fi
1479
1679
  pnpm nx run ${name}:deploy-local -- \
1480
1680
  --stage="$EBK_STAGE" \
1481
- --expectedRoleArn="$AWS_DEPLOY_ROLE_ARN"
1681
+ --expectedRoleArn="$AWS_DEPLOY_ROLE_ARN" \
1682
+ --lifecycleScope="$EBK_LIFECYCLE_SCOPE" \
1683
+ "\${baseline_arguments[@]}"
1482
1684
  else
1483
1685
  [ -n "$EBK_ORDERED_PROJECTS" ] || { echo 'Committed deployment order is empty'; exit 1; }
1484
1686
  IFS=',' read -r -a projects <<< "$EBK_ORDERED_PROJECTS"
@@ -1546,6 +1748,673 @@ ${indentedScript(postdeploySupplyChainBindingScript(), 10)}
1546
1748
  .ebk/evidence/deployment/promotion.json
1547
1749
  .ebk/evidence/container-release/**
1548
1750
  retention-days: 30
1751
+ ${deploymentProfile === 'starter' ? '' : renderRehearseJob(options)}${trunkPromotion ? renderTrunkPromotionJobs(options) : ''}`;
1752
+ }
1753
+ /**
1754
+ * Managed Router WAF blocking rehearsal (EBK-INT-2026-079). The protected
1755
+ * dispatch runs the foundation rehearsal target under the staging deployment
1756
+ * role with seedProbes, so the runner performs the operator probes itself
1757
+ * inside the private-smoke allowlist admission, proves the admission removed,
1758
+ * and opens the pull request that carries the sealed evidence and receipt
1759
+ * back to develop through the ordinary review path.
1760
+ */
1761
+ function renderRehearseJob(options) {
1762
+ const { actionPins, name, toolVersions } = options;
1763
+ return `
1764
+ rehearse:
1765
+ name: rehearse staging Router WAF blocking
1766
+ needs: prepare
1767
+ if: >-
1768
+ needs.prepare.result == 'success' &&
1769
+ needs.prepare.outputs.action == 'rehearse' &&
1770
+ needs.prepare.outputs.stage == 'staging' &&
1771
+ needs.prepare.outputs.profile != 'starter'
1772
+ runs-on: ubuntu-latest
1773
+ environment: \${{ needs.prepare.outputs.stage }}
1774
+ permissions:
1775
+ actions: read
1776
+ contents: write
1777
+ id-token: write
1778
+ pull-requests: write
1779
+ env:
1780
+ AWS_DEPLOY_ROLE_ARN: \${{ needs.prepare.outputs.stage_deploy_role_arn }}
1781
+ AWS_EXPECTED_ACCOUNT_ID: \${{ vars.AWS_EXPECTED_ACCOUNT_ID }}
1782
+ AWS_PARTITION: \${{ needs.prepare.outputs.partition }}
1783
+ AWS_REGION: \${{ vars.AWS_REGION }}
1784
+ EBK_ACTION: rehearse
1785
+ EBK_DEPLOYMENT_PROFILE: \${{ needs.prepare.outputs.profile }}
1786
+ EBK_POLICY_ACCOUNT_ID: \${{ needs.prepare.outputs.account_id }}
1787
+ EBK_POLICY_REGION: \${{ needs.prepare.outputs.region }}
1788
+ EBK_PROBE_URL: \${{ needs.prepare.outputs.probe_url }}
1789
+ EBK_SOURCE_COMMIT: \${{ inputs.source_commit || github.sha }}
1790
+ EBK_STAGE: \${{ needs.prepare.outputs.stage }}
1791
+ steps:
1792
+ - name: Check out immutable deployment source
1793
+ uses: ${actionPins.checkout}
1794
+ env:
1795
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1796
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1797
+ with:
1798
+ fetch-depth: 0
1799
+ persist-credentials: false
1800
+ ref: \${{ env.EBK_DEPLOYMENT_SOURCE_COMMIT }}
1801
+ - uses: ${actionPins.pnpmSetup}
1802
+ env:
1803
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1804
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1805
+ - uses: ${actionPins.setupNode}
1806
+ env:
1807
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1808
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1809
+ with:
1810
+ node-version: ${toolVersions.node}
1811
+ cache: pnpm
1812
+ - env:
1813
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1814
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1815
+ run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
1816
+ - name: Verify managed workspace integrity before AWS authority
1817
+ env:
1818
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1819
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1820
+ run: pnpm nx run ${name}:workspace-health
1821
+ - name: Require the exact pending rehearsal seeds
1822
+ env:
1823
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1824
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1825
+ run: |
1826
+ set -euo pipefail
1827
+ for seed in router-waf-live-evidence router-waf-blocking-rehearsal; do
1828
+ [ "$(jq -r '.status // empty' "packages/foundation/security/$seed.json")" = pending ] || {
1829
+ echo "packages/foundation/security/$seed.json must be reset to its pending seed before another rehearsal"; exit 1;
1830
+ }
1831
+ done
1832
+ - name: Revalidate exact protected branch tip before OIDC
1833
+ env:
1834
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1835
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1836
+ EBK_SOURCE_BRANCH: \${{ env.EBK_DEPLOYMENT_SOURCE_BRANCH }}
1837
+ EBK_SOURCE_COMMIT: \${{ env.EBK_DEPLOYMENT_SOURCE_COMMIT }}
1838
+ GH_REPO: \${{ github.repository }}
1839
+ GH_TOKEN: \${{ github.token }}
1840
+ run: |
1841
+ set -euo pipefail
1842
+ [ "$(git rev-parse HEAD)" = "$EBK_SOURCE_COMMIT" ] || {
1843
+ echo 'Deployment checkout differs from the exact protected source'; exit 1;
1844
+ }
1845
+ branch_tip="$(gh api "/repos/$GH_REPO/git/ref/heads/$EBK_SOURCE_BRANCH" --jq '.object.sha')"
1846
+ [ "$branch_tip" = "$EBK_SOURCE_COMMIT" ] || {
1847
+ echo 'Deployment source is no longer the exact protected branch tip'; exit 1;
1848
+ }
1849
+ - name: Assume the one stage deployment role
1850
+ uses: ${actionPins.configureAwsCredentials}
1851
+ with:
1852
+ audience: \${{ needs.prepare.outputs.oidc_audience }}
1853
+ aws-region: \${{ vars.AWS_REGION }}
1854
+ role-session-name: ebk-rehearse-\${{ github.run_id }}-\${{ github.run_attempt }}
1855
+ role-to-assume: \${{ env.AWS_DEPLOY_ROLE_ARN }}
1856
+ - name: Verify exact stage authority
1857
+ run: |
1858
+ set -euo pipefail
1859
+ [ "$AWS_EXPECTED_ACCOUNT_ID" = "$EBK_POLICY_ACCOUNT_ID" ]
1860
+ [ "$AWS_REGION" = "$EBK_POLICY_REGION" ]
1861
+ ${indentedScript(exactStageDeployerGuardScript('Router WAF rehearsal', '$EBK_POLICY_ACCOUNT_ID'), 10)}
1862
+ - name: Rehearse Router WAF blocking with runner-seeded probes
1863
+ id: rehearsal
1864
+ env:
1865
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1866
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1867
+ EBK_GITHUB_JOB_WORKFLOW_REF: \${{ job.workflow_ref }}
1868
+ GITHUB_TOKEN: \${{ github.token }}
1869
+ run: |
1870
+ set -euo pipefail
1871
+ test -n "$AWS_DEPLOY_ROLE_ARN" || { echo 'Stage deployment role is absent'; exit 1; }
1872
+ pnpm nx run foundation:router-waf-rehearsal -- \\
1873
+ --probeUrl="$EBK_PROBE_URL" \\
1874
+ --seedProbes=true
1875
+ - name: Prove temporary smoke admission removed
1876
+ if: always() && steps.rehearsal.outcome != 'skipped'
1877
+ env:
1878
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1879
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1880
+ EBK_GITHUB_JOB_WORKFLOW_REF: \${{ job.workflow_ref }}
1881
+ GITHUB_TOKEN: \${{ github.token }}
1882
+ run: |
1883
+ set -euo pipefail
1884
+ ${indentedScript(exactStageDeployerGuardScript('Smoke access cleanup', '$EBK_POLICY_ACCOUNT_ID'), 10)}
1885
+ [ "$(git rev-parse HEAD)" = "$EBK_SOURCE_COMMIT" ]
1886
+ node --input-type=module <<'NODE'
1887
+ import { cleanupGithubPrivateSmoke } from '@empire-builder-kit/nx/foundation';
1888
+ await cleanupGithubPrivateSmoke({
1889
+ account: process.env.EBK_POLICY_ACCOUNT_ID,
1890
+ deploymentProfile: process.env.EBK_DEPLOYMENT_PROFILE,
1891
+ root: process.cwd(),
1892
+ source: process.env.EBK_SOURCE_COMMIT,
1893
+ stage: process.env.EBK_STAGE,
1894
+ workspace: '${name}',
1895
+ });
1896
+ NODE
1897
+ - name: Retain the sealed rehearsal evidence
1898
+ uses: ${actionPins.uploadArtifact}
1899
+ with:
1900
+ if-no-files-found: error
1901
+ name: router-waf-rehearsal-\${{ github.run_id }}-\${{ github.run_attempt }}
1902
+ path: |
1903
+ packages/foundation/security/router-waf-live-evidence.json
1904
+ packages/foundation/security/router-waf-blocking-rehearsal.json
1905
+ retention-days: 90
1906
+ - name: Open the rehearsal receipt pull request
1907
+ env:
1908
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1909
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1910
+ EBK_RUN_ATTEMPT: \${{ github.run_attempt }}
1911
+ EBK_RUN_ID: \${{ github.run_id }}
1912
+ GH_REPO: \${{ github.repository }}
1913
+ GH_TOKEN: \${{ github.token }}
1914
+ run: |
1915
+ set -euo pipefail
1916
+ case "$EBK_RUN_ID:$EBK_RUN_ATTEMPT" in *[!0-9:]*|:*|*:) exit 1 ;; esac
1917
+ branch="ebk/router-waf-rehearsal-$EBK_RUN_ID-$EBK_RUN_ATTEMPT"
1918
+ git diff --quiet -- packages/foundation/security && { echo 'The rehearsal sealed nothing new'; exit 1; }
1919
+ git config user.name 'ebk-router-waf-rehearsal[bot]'
1920
+ git config user.email 'ebk-router-waf-rehearsal[bot]@users.noreply.github.com'
1921
+ git checkout -q -b "$branch"
1922
+ git add packages/foundation/security/router-waf-live-evidence.json packages/foundation/security/router-waf-blocking-rehearsal.json
1923
+ git commit -q -m "chore(security): seal the staging Router WAF blocking rehearsal (run $EBK_RUN_ID)"
1924
+ git -c http.https://github.com/.extraheader="AUTHORIZATION: bearer $GH_TOKEN" push -q origin "HEAD:refs/heads/$branch"
1925
+ gh pr create \\
1926
+ --head "$branch" \\
1927
+ --base develop \\
1928
+ --title 'chore(security): seal the staging Router WAF blocking rehearsal' \\
1929
+ --body "Managed rehearsal run $EBK_RUN_ID attempt $EBK_RUN_ATTEMPT sealed the staging Router WAF blocking rehearsal for the committed policy digest. Merge to unblock the production Router WAF gate; the receipt binds the exact account, Region, and policy digest."
1930
+ `;
1931
+ }
1932
+ /**
1933
+ * Trunk promotion: after a successful staging lifecycle on develop, the same
1934
+ * run binds its own staging receipt, promotes the reviewed OCI indexes into
1935
+ * production, deploys production, and fast-forwards main to the deployed
1936
+ * commit. Every job carries the production environment so environment
1937
+ * reviewers can still gate it, and every step reuses the promotion-pr scripts.
1938
+ */
1939
+ function renderTrunkPromotionJobs(options) {
1940
+ const { actionPins, name, toolVersions } = options;
1941
+ const checkoutAndInstall = (integrityStep) => ` - name: Check out immutable deployment source
1942
+ uses: ${actionPins.checkout}
1943
+ env:
1944
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1945
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1946
+ with:
1947
+ fetch-depth: 0
1948
+ persist-credentials: false
1949
+ ref: \${{ env.EBK_DEPLOYMENT_SOURCE_COMMIT }}
1950
+ - uses: ${actionPins.pnpmSetup}
1951
+ env:
1952
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1953
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1954
+ - uses: ${actionPins.setupNode}
1955
+ env:
1956
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1957
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1958
+ with:
1959
+ node-version: ${toolVersions.node}
1960
+ cache: pnpm
1961
+ - env:
1962
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1963
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1964
+ run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
1965
+ - name: ${integrityStep}
1966
+ env:
1967
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1968
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1969
+ run: pnpm nx run ${name}:workspace-health`;
1970
+ const revalidateTip = ` - name: Revalidate exact protected branch tip before OIDC
1971
+ env:
1972
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
1973
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
1974
+ EBK_SOURCE_BRANCH: \${{ env.EBK_DEPLOYMENT_SOURCE_BRANCH }}
1975
+ EBK_SOURCE_COMMIT: \${{ env.EBK_DEPLOYMENT_SOURCE_COMMIT }}
1976
+ GH_REPO: \${{ github.repository }}
1977
+ GH_TOKEN: \${{ github.token }}
1978
+ run: |
1979
+ set -euo pipefail
1980
+ [ "$(git rev-parse HEAD)" = "$EBK_SOURCE_COMMIT" ] || {
1981
+ echo 'Deployment checkout differs from the exact protected source'; exit 1;
1982
+ }
1983
+ branch_tip="$(gh api "/repos/$GH_REPO/git/ref/heads/$EBK_SOURCE_BRANCH" --jq '.object.sha')"
1984
+ [ "$branch_tip" = "$EBK_SOURCE_COMMIT" ] || {
1985
+ echo 'Deployment source is no longer the exact protected branch tip'; exit 1;
1986
+ }`;
1987
+ const trunkPromotionGate = ` needs.prepare.outputs.action == 'deploy' &&
1988
+ needs.prepare.outputs.stage == 'staging' &&
1989
+ needs.prepare.outputs.profile != 'starter' &&
1990
+ needs.prepare.outputs.has_slices == 'true'`;
1991
+ return `
1992
+ promote-production:
1993
+ name: promote the staging-tested candidate to production
1994
+ needs: [prepare, immutable-artifacts, container-assemble, execute]
1995
+ if: >-
1996
+ always() &&
1997
+ needs.prepare.result == 'success' &&
1998
+ needs.immutable-artifacts.result == 'success' &&
1999
+ needs.container-assemble.result == 'success' &&
2000
+ needs.execute.result == 'success' &&
2001
+ ${trunkPromotionGate}
2002
+ runs-on: ubuntu-latest
2003
+ environment: production
2004
+ permissions:
2005
+ actions: read
2006
+ contents: read
2007
+ id-token: write
2008
+ outputs:
2009
+ artifact_digest: \${{ steps.retain.outputs.artifact-digest }}
2010
+ artifact_id: \${{ steps.retain.outputs.artifact-id }}
2011
+ candidate_artifact_digest: \${{ steps.staging.outputs.artifact_digest }}
2012
+ candidate_artifact_id: \${{ steps.staging.outputs.artifact_id }}
2013
+ candidate_material_digest: \${{ steps.staging.outputs.candidate_material_digest }}
2014
+ candidate_producer_run_attempt: \${{ steps.staging.outputs.producer_run_attempt }}
2015
+ candidate_producer_run_id: \${{ steps.staging.outputs.producer_run_id }}
2016
+ env:
2017
+ AWS_DEPLOY_ROLE_ARN: \${{ needs.prepare.outputs.production_deploy_role_arn }}
2018
+ AWS_EXPECTED_ACCOUNT_ID: \${{ vars.AWS_EXPECTED_ACCOUNT_ID }}
2019
+ AWS_PARTITION: \${{ needs.prepare.outputs.partition }}
2020
+ AWS_REGION: \${{ vars.AWS_REGION }}
2021
+ EBK_ACTION: deploy
2022
+ EBK_DEPLOYMENT_PROFILE: \${{ needs.prepare.outputs.profile }}
2023
+ EBK_POLICY_PRODUCTION_ACCOUNT_ID: \${{ needs.prepare.outputs.production_account_id }}
2024
+ EBK_POLICY_REGION: \${{ needs.prepare.outputs.region }}
2025
+ EBK_POLICY_STAGING_ACCOUNT_ID: \${{ needs.prepare.outputs.staging_account_id }}
2026
+ EBK_RELEASE_SOURCE_COMMIT: \${{ needs.immutable-artifacts.outputs.source_commit }}
2027
+ EBK_SOURCE_COMMIT: \${{ inputs.source_commit || github.sha }}
2028
+ EBK_STAGE: production
2029
+ EBK_SLICE_PROJECTS: \${{ needs.prepare.outputs.slice_projects }}
2030
+ steps:
2031
+ ${checkoutAndInstall('Verify managed workspace integrity before container authority')}
2032
+ - id: staging-receipt
2033
+ name: Select this run's staging deployment receipt
2034
+ env:
2035
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2036
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2037
+ EBK_DEPLOYMENT_RUN_ATTEMPT: \${{ github.run_attempt }}
2038
+ EBK_DEPLOYMENT_RUN_ID: \${{ github.run_id }}
2039
+ GH_REPO: \${{ github.repository }}
2040
+ GH_TOKEN: \${{ github.token }}
2041
+ run: |
2042
+ set -euo pipefail
2043
+ case "$EBK_DEPLOYMENT_RUN_ID:$EBK_DEPLOYMENT_RUN_ATTEMPT" in *[!0-9:]*|:*|*:) exit 1 ;; esac
2044
+ artifacts="$(gh api "/repos/$GH_REPO/actions/runs/$EBK_DEPLOYMENT_RUN_ID/artifacts?per_page=100")"
2045
+ receipt_name="deployment-receipt-staging-$EBK_DEPLOYMENT_RUN_ID-$EBK_DEPLOYMENT_RUN_ATTEMPT"
2046
+ receipt="$(printf '%s' "$artifacts" | jq -c --arg name "$receipt_name" \\
2047
+ '[.artifacts[] | select(.name == $name and .expired == false)] | if length == 1 then .[0] else empty end')"
2048
+ [ -n "$receipt" ] || { echo 'This run retained no exact staging deployment receipt'; exit 1; }
2049
+ artifact_id="$(printf '%s' "$receipt" | jq -r '.id // empty')"
2050
+ artifact_digest="$(printf '%s' "$receipt" | jq -r '.digest // empty')"
2051
+ case "$artifact_id" in ''|*[!0-9]*) exit 1 ;; esac
2052
+ case "$artifact_digest" in sha256:????????????????????????????????????????????????????????????????) ;; *) exit 1 ;; esac
2053
+ echo "artifact_digest=$artifact_digest" >> "$GITHUB_OUTPUT"
2054
+ echo "artifact_id=$artifact_id" >> "$GITHUB_OUTPUT"
2055
+ - name: Restore this run's staging deployment receipt
2056
+ uses: ${actionPins.downloadArtifact}
2057
+ env:
2058
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2059
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2060
+ with:
2061
+ artifact-ids: \${{ steps.staging-receipt.outputs.artifact_id }}
2062
+ github-token: \${{ github.token }}
2063
+ path: \${{ runner.temp }}/staging-deployment-receipt
2064
+ - id: staging
2065
+ name: Bind exact staging deployment and release evidence
2066
+ env:
2067
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2068
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2069
+ EBK_STAGING_DEPLOYMENT_RUN_ATTEMPT: \${{ github.run_attempt }}
2070
+ EBK_STAGING_DEPLOYMENT_RUN_ID: \${{ github.run_id }}
2071
+ EBK_EXPECTED_HAS_SLICES: \${{ needs.prepare.outputs.has_slices }}
2072
+ EBK_EXPECTED_DEPLOYMENT_PROFILE: \${{ needs.prepare.outputs.profile }}
2073
+ EBK_EXPECTED_DEPLOYMENT_REGION: \${{ needs.prepare.outputs.region }}
2074
+ EBK_EXPECTED_STAGING_ACCOUNT_ID: \${{ needs.prepare.outputs.staging_account_id }}
2075
+ EBK_STAGING_RECEIPT_ARTIFACT_DIGEST: \${{ steps.staging-receipt.outputs.artifact_digest }}
2076
+ EBK_STAGING_RECEIPT_ARTIFACT_ID: \${{ steps.staging-receipt.outputs.artifact_id }}
2077
+ # The receipt artifact keeps .ebk/evidence as its root (EBK-INT-2026-074).
2078
+ EBK_STAGING_RECEIPT_ROOT: \${{ runner.temp }}/staging-deployment-receipt/deployment
2079
+ EBK_STAGING_SOURCE_COMMIT: \${{ env.EBK_DEPLOYMENT_SOURCE_COMMIT }}
2080
+ GH_REPO: \${{ github.repository }}
2081
+ GH_TOKEN: \${{ github.token }}
2082
+ run: |
2083
+ node --input-type=module <<'NODE'
2084
+ ${indentedScript(stagingPromotionReceiptValidationScript(), 10)}
2085
+ NODE
2086
+ promotion="$EBK_STAGING_RECEIPT_ROOT/promotion.json"
2087
+ candidate_run_id="$(jq -r '.candidate.producerRunId' "$promotion")"
2088
+ candidate_run_attempt="$(jq -r '.candidate.producerRunAttempt' "$promotion")"
2089
+ candidate_artifact_id="$(jq -r '.candidate.artifactId' "$promotion")"
2090
+ candidate_artifact_digest="$(jq -r '.candidate.artifactDigest' "$promotion")"
2091
+ candidate_run="$(gh api "/repos/$GH_REPO/actions/runs/$candidate_run_id")"
2092
+ printf '%s' "$candidate_run" | jq -e \\
2093
+ --arg source "$EBK_STAGING_SOURCE_COMMIT" \\
2094
+ --arg attempt "$candidate_run_attempt" \\
2095
+ '.head_sha == $source and .head_branch == "develop" and .event == "push" and .path == ".github/workflows/ci.yml" and .conclusion == "success" and (.run_attempt | tostring) == $attempt' >/dev/null
2096
+ candidate_artifacts="$(gh api "/repos/$GH_REPO/actions/runs/$candidate_run_id/artifacts?per_page=100")"
2097
+ printf '%s' "$candidate_artifacts" | jq -e \\
2098
+ --arg id "$candidate_artifact_id" \\
2099
+ --arg digest "$candidate_artifact_digest" \\
2100
+ --arg name "release-candidate-$EBK_STAGING_SOURCE_COMMIT" \\
2101
+ '[.artifacts[] | select((.id | tostring) == $id and .name == $name and .digest == $digest and .expired == false)] | length == 1' >/dev/null
2102
+ - name: Restore exact immutable release candidate for promotion
2103
+ uses: ${actionPins.downloadArtifact}
2104
+ env:
2105
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2106
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2107
+ with:
2108
+ artifact-ids: \${{ steps.staging.outputs.artifact_id }}
2109
+ github-token: \${{ github.token }}
2110
+ path: .ebk/evidence/release-candidate
2111
+ run-id: \${{ steps.staging.outputs.producer_run_id }}
2112
+ - name: Restore this run's staging container bundle
2113
+ uses: ${actionPins.downloadArtifact}
2114
+ env:
2115
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2116
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2117
+ with:
2118
+ artifact-ids: \${{ steps.staging.outputs.container_artifact_id }}
2119
+ github-token: \${{ github.token }}
2120
+ path: .ebk/evidence/container-release
2121
+ ${revalidateTip}
2122
+ - name: Assume exact staging registry pull role
2123
+ uses: ${actionPins.configureAwsCredentials}
2124
+ with:
2125
+ audience: \${{ needs.prepare.outputs.oidc_audience }}
2126
+ aws-region: \${{ vars.AWS_STAGING_PROMOTION_REGION }}
2127
+ role-session-name: ebk-staging-container-pull-\${{ github.run_id }}-\${{ github.run_attempt }}
2128
+ role-to-assume: \${{ secrets.AWS_STAGING_PROMOTION_PULL_ROLE_ARN }}
2129
+ - name: Authenticate exact staging source registry
2130
+ env:
2131
+ AWS_STAGING_PROMOTION_PULL_ROLE_ARN: \${{ secrets.AWS_STAGING_PROMOTION_PULL_ROLE_ARN }}
2132
+ EBK_STAGING_ACCOUNT_ID: \${{ vars.AWS_STAGING_PROMOTION_EXPECTED_ACCOUNT_ID }}
2133
+ EBK_STAGING_REGION: \${{ vars.AWS_STAGING_PROMOTION_REGION }}
2134
+ run: |
2135
+ set -euo pipefail
2136
+ [ "$EBK_STAGING_ACCOUNT_ID" = "$EBK_POLICY_STAGING_ACCOUNT_ID" ]
2137
+ [ "$EBK_STAGING_REGION" = "$EBK_POLICY_REGION" ]
2138
+ ${indentedScript(exactStagingPromotionPullGuardScript(), 10)}
2139
+ [ "$EBK_STAGING_ACCOUNT_ID" != "$AWS_EXPECTED_ACCOUNT_ID" ]
2140
+ registry_suffix=.com
2141
+ case "$EBK_STAGING_REGION" in cn-*) registry_suffix=.com.cn ;; esac
2142
+ aws ecr get-login-password --region "$EBK_STAGING_REGION" |
2143
+ docker login --username AWS --password-stdin \\
2144
+ "$EBK_STAGING_ACCOUNT_ID.dkr.ecr.$EBK_STAGING_REGION.amazonaws$registry_suffix"
2145
+ ${revalidateTip}
2146
+ - name: Assume the production deployment role for container promotion
2147
+ uses: ${actionPins.configureAwsCredentials}
2148
+ with:
2149
+ audience: \${{ needs.prepare.outputs.oidc_audience }}
2150
+ aws-region: \${{ vars.AWS_REGION }}
2151
+ role-session-name: ebk-production-container-push-\${{ github.run_id }}-\${{ github.run_attempt }}
2152
+ role-to-assume: \${{ env.AWS_DEPLOY_ROLE_ARN }}
2153
+ - name: Promote exact staging indexes without rebuilding
2154
+ env:
2155
+ EBK_STAGING_PROMOTION_EXPECTED_ACCOUNT_ID: \${{ vars.AWS_STAGING_PROMOTION_EXPECTED_ACCOUNT_ID }}
2156
+ EBK_STAGING_PROMOTION_REGION: \${{ vars.AWS_STAGING_PROMOTION_REGION }}
2157
+ run: |
2158
+ set -euo pipefail
2159
+ [ "$AWS_EXPECTED_ACCOUNT_ID" = "$EBK_POLICY_PRODUCTION_ACCOUNT_ID" ]
2160
+ [ "$AWS_REGION" = "$EBK_POLICY_REGION" ]
2161
+ ${indentedScript(exactStageDeployerGuardScript('Container promotion', '$AWS_EXPECTED_ACCOUNT_ID'), 10)}
2162
+ IFS=',' read -r -a projects <<< "$EBK_SLICE_PROJECTS"
2163
+ for project in "\${projects[@]}"; do
2164
+ test -n "$project" || continue
2165
+ # Repository-relative for the container release tool (EBK-INT-2026-077).
2166
+ export EBK_CONTAINER_PROMOTION_SOURCE_BINDING_PATH=".ebk/evidence/container-release/staging/packages/$project/binding.json"
2167
+ export EBK_CONTAINER_PROMOTION_SOURCE_SIDECAR_PATH=".ebk/evidence/container-release/staging/packages/$project/release-bound-binding.json"
2168
+ pnpm nx run "$project:container-release-promote"
2169
+ done
2170
+ - id: retain
2171
+ name: Retain production target container binding
2172
+ uses: ${actionPins.uploadArtifact}
2173
+ with:
2174
+ name: container-release-production-\${{ github.run_id }}-\${{ github.run_attempt }}
2175
+ if-no-files-found: error
2176
+ include-hidden-files: true
2177
+ retention-days: 90
2178
+ path: .ebk/evidence/container-release
2179
+
2180
+ execute-production:
2181
+ name: native deploy production
2182
+ needs: [prepare, immutable-artifacts, promote-production]
2183
+ if: >-
2184
+ always() &&
2185
+ needs.prepare.result == 'success' &&
2186
+ needs.immutable-artifacts.result == 'success' &&
2187
+ needs.promote-production.result == 'success' &&
2188
+ ${trunkPromotionGate}
2189
+ runs-on: ubuntu-latest
2190
+ environment: production
2191
+ permissions:
2192
+ actions: read
2193
+ contents: read
2194
+ id-token: write
2195
+ env:
2196
+ AWS_DEPLOY_ROLE_ARN: \${{ needs.prepare.outputs.production_deploy_role_arn }}
2197
+ AWS_EXPECTED_ACCOUNT_ID: \${{ vars.AWS_EXPECTED_ACCOUNT_ID }}
2198
+ AWS_PARTITION: \${{ needs.prepare.outputs.partition }}
2199
+ AWS_REGION: \${{ vars.AWS_REGION }}
2200
+ EBK_ACTION: deploy
2201
+ EBK_ALLOW_PRODUCTION_REMOVE: \${{ vars.EBK_ALLOW_PRODUCTION_REMOVE }}
2202
+ EBK_CANDIDATE_ARTIFACT_DIGEST: \${{ needs.promote-production.outputs.candidate_artifact_digest }}
2203
+ EBK_CANDIDATE_ARTIFACT_ID: \${{ needs.promote-production.outputs.candidate_artifact_id }}
2204
+ EBK_CANDIDATE_MATERIAL_DIGEST: \${{ needs.promote-production.outputs.candidate_material_digest }}
2205
+ EBK_CANDIDATE_PRODUCER_RUN_ATTEMPT: \${{ needs.promote-production.outputs.candidate_producer_run_attempt }}
2206
+ EBK_CANDIDATE_PRODUCER_RUN_ID: \${{ needs.promote-production.outputs.candidate_producer_run_id }}
2207
+ EBK_DEPLOYMENT_PROFILE: \${{ needs.prepare.outputs.profile }}
2208
+ EBK_ORDERED_PROJECTS: \${{ needs.prepare.outputs.ordered_projects }}
2209
+ EBK_POLICY_ACCOUNT_ID: \${{ needs.prepare.outputs.production_account_id }}
2210
+ EBK_POLICY_REGION: \${{ needs.prepare.outputs.region }}
2211
+ EBK_RELEASE_SOURCE_COMMIT: \${{ needs.immutable-artifacts.outputs.source_commit || inputs.source_commit || github.sha }}
2212
+ EBK_SLICE_PROJECTS: \${{ needs.prepare.outputs.slice_projects }}
2213
+ EBK_SOURCE_COMMIT: \${{ inputs.source_commit || github.sha }}
2214
+ EBK_STAGE: production
2215
+ # Production has no same-branch baseline receipt in this run; the
2216
+ # full lifecycle keeps its evidence complete.
2217
+ EBK_LIFECYCLE_SCOPE: full
2218
+ EBK_BASELINE_FOUND: 'false'
2219
+ EBK_BASELINE_RUN_ID: ''
2220
+ steps:
2221
+ ${checkoutAndInstall('Verify managed workspace integrity before AWS authority')}
2222
+ - name: Restore exact immutable release candidate
2223
+ uses: ${actionPins.downloadArtifact}
2224
+ env:
2225
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2226
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2227
+ with:
2228
+ artifact-ids: \${{ needs.promote-production.outputs.candidate_artifact_id }}
2229
+ github-token: \${{ github.token }}
2230
+ path: .ebk/evidence/release-candidate
2231
+ run-id: \${{ needs.promote-production.outputs.candidate_producer_run_id }}
2232
+ - name: Restore exact promoted production container binding
2233
+ uses: ${actionPins.downloadArtifact}
2234
+ env:
2235
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2236
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2237
+ with:
2238
+ name: container-release-production-\${{ github.run_id }}-\${{ github.run_attempt }}
2239
+ path: .ebk/evidence/container-release
2240
+ - name: Verify exact predeploy container binding inventory
2241
+ env:
2242
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2243
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2244
+ run: |
2245
+ set -euo pipefail
2246
+ IFS=',' read -r -a projects <<< "$EBK_SLICE_PROJECTS"
2247
+ for project in "\${projects[@]}"; do
2248
+ test -n "$project" || continue
2249
+ test -f ".ebk/evidence/container-release/$EBK_STAGE/packages/$project/binding.json" || {
2250
+ echo "Exact predeploy container binding is absent for $project"
2251
+ exit 1
2252
+ }
2253
+ done
2254
+ - name: Verify exact restored staging-tested release candidate
2255
+ env:
2256
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2257
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2258
+ run: |
2259
+ node --input-type=module <<'NODE'
2260
+ ${indentedScript(restoredCandidateValidationScript(), 10)}
2261
+ NODE
2262
+ - name: Authorize deployed Blueprint trust before protected mutation
2263
+ env:
2264
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2265
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2266
+ EBK_PROTECTED_BLUEPRINT_COMMIT: \${{ env.EBK_DEPLOYMENT_SOURCE_COMMIT }}
2267
+ run: |
2268
+ set -euo pipefail
2269
+ case "$EBK_PROTECTED_BLUEPRINT_COMMIT" in
2270
+ ''|*[!0-9a-f]*) echo 'Protected Blueprint authority must be one full lowercase Git object ID'; exit 1 ;;
2271
+ esac
2272
+ test "\${#EBK_PROTECTED_BLUEPRINT_COMMIT}" -eq 40 ||
2273
+ test "\${#EBK_PROTECTED_BLUEPRINT_COMMIT}" -eq 64 ||
2274
+ { echo 'Protected Blueprint authority must be one full Git object ID'; exit 1; }
2275
+ test "$(git rev-parse HEAD)" = "$EBK_PROTECTED_BLUEPRINT_COMMIT" ||
2276
+ { echo 'Protected Blueprint authority differs from the exact workflow checkout'; exit 1; }
2277
+ node tools/blueprint-trust-check.mjs \\
2278
+ --operation=deploy \\
2279
+ --protected-branch=main \\
2280
+ --protected-ref="$EBK_PROTECTED_BLUEPRINT_COMMIT" \\
2281
+ --output=.ebk/evidence/supply-chain/blueprint-trust.json
2282
+ - name: Produce subject-bound protected scanner evidence
2283
+ env:
2284
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2285
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2286
+ run: pnpm nx run ${name}:supply-chain-scan
2287
+ - name: Enforce protected supply-chain policy before mutation
2288
+ env:
2289
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2290
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2291
+ run: pnpm nx run ${name}:supply-chain-check-protected
2292
+ - name: Install pinned uv for generated Python workloads
2293
+ env:
2294
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2295
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2296
+ run: |
2297
+ set -euo pipefail
2298
+ python3 -m pip install --user --disable-pip-version-check 'uv==${tool_versions_js_1.WORKSPACE_UV_VERSION}'
2299
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
2300
+ "$HOME/.local/bin/uv" --version
2301
+ - name: Install pinned Playwright Chromium for deployed smoke proofs
2302
+ env:
2303
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2304
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2305
+ run: |
2306
+ ${indentedScript(protectedBrowserInstallScript(), 10)}
2307
+ ${revalidateTip}
2308
+ - name: Assume the one stage deployment role
2309
+ uses: ${actionPins.configureAwsCredentials}
2310
+ with:
2311
+ audience: \${{ needs.prepare.outputs.oidc_audience }}
2312
+ aws-region: \${{ vars.AWS_REGION }}
2313
+ role-session-name: ebk-native-\${{ github.run_id }}-\${{ github.run_attempt }}
2314
+ role-to-assume: \${{ env.AWS_DEPLOY_ROLE_ARN }}
2315
+ - name: Verify exact stage authority
2316
+ run: |
2317
+ set -euo pipefail
2318
+ [ "$AWS_EXPECTED_ACCOUNT_ID" = "$EBK_POLICY_ACCOUNT_ID" ]
2319
+ [ "$AWS_REGION" = "$EBK_POLICY_REGION" ]
2320
+ ${indentedScript(exactStageDeployerGuardScript('Native lifecycle', '$EBK_POLICY_ACCOUNT_ID'), 10)}
2321
+ - name: Run ordered native lifecycle targets
2322
+ id: native-lifecycle
2323
+ env:
2324
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2325
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2326
+ EBK_GITHUB_JOB_WORKFLOW_REF: \${{ job.workflow_ref }}
2327
+ GITHUB_TOKEN: \${{ github.token }}
2328
+ run: |
2329
+ set -euo pipefail
2330
+ test -n "$AWS_DEPLOY_ROLE_ARN" || { echo 'Stage deployment role is absent'; exit 1; }
2331
+ pnpm nx run ${name}:deploy-local -- \\
2332
+ --stage="$EBK_STAGE" \\
2333
+ --expectedRoleArn="$AWS_DEPLOY_ROLE_ARN" \\
2334
+ --lifecycleScope="$EBK_LIFECYCLE_SCOPE"
2335
+ - name: Prove temporary smoke admission removed
2336
+ if: always() && steps.native-lifecycle.outcome != 'skipped'
2337
+ env:
2338
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2339
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2340
+ EBK_GITHUB_JOB_WORKFLOW_REF: \${{ job.workflow_ref }}
2341
+ GITHUB_TOKEN: \${{ github.token }}
2342
+ run: |
2343
+ set -euo pipefail
2344
+ ${indentedScript(exactStageDeployerGuardScript('Smoke access cleanup', '$EBK_POLICY_ACCOUNT_ID'), 10)}
2345
+ [ "$(git rev-parse HEAD)" = "$EBK_SOURCE_COMMIT" ]
2346
+ git diff --quiet HEAD
2347
+ node --input-type=module <<'NODE'
2348
+ import { cleanupGithubPrivateSmoke } from '@empire-builder-kit/nx/foundation';
2349
+ await cleanupGithubPrivateSmoke({
2350
+ account: process.env.EBK_POLICY_ACCOUNT_ID,
2351
+ deploymentProfile: process.env.EBK_DEPLOYMENT_PROFILE,
2352
+ root: process.cwd(),
2353
+ source: process.env.EBK_SOURCE_COMMIT,
2354
+ stage: process.env.EBK_STAGE,
2355
+ workspace: '${name}',
2356
+ });
2357
+ NODE
2358
+ - name: Seal exact postdeploy supply-chain binding
2359
+ env:
2360
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: ''
2361
+ ACTIONS_ID_TOKEN_REQUEST_URL: ''
2362
+ run: |
2363
+ node --input-type=module <<'NODE'
2364
+ ${indentedScript(postdeploySupplyChainBindingScript(), 10)}
2365
+ NODE
2366
+ - name: Retain bounded deployment receipt
2367
+ if: always()
2368
+ uses: ${actionPins.uploadArtifact}
2369
+ with:
2370
+ if-no-files-found: warn
2371
+ name: deployment-receipt-production-\${{ github.run_id }}-\${{ github.run_attempt }}
2372
+ path: |
2373
+ .ebk/evidence/deployment/latest.json
2374
+ .ebk/evidence/deployment/postdeploy-supply-chain.json
2375
+ .ebk/evidence/deployment/promotion.json
2376
+ .ebk/evidence/container-release/**
2377
+ retention-days: 30
2378
+
2379
+ record-main:
2380
+ name: record the deployed commit on main
2381
+ needs: [prepare, execute-production]
2382
+ if: >-
2383
+ always() &&
2384
+ needs.prepare.result == 'success' &&
2385
+ needs.execute-production.result == 'success' &&
2386
+ ${trunkPromotionGate}
2387
+ runs-on: ubuntu-latest
2388
+ environment: production
2389
+ # The fast-forward uses the scoped environment token, not the workflow token.
2390
+ permissions:
2391
+ contents: read
2392
+ steps:
2393
+ - name: Fast-forward protected main to the deployed commit
2394
+ env:
2395
+ EBK_RECORD_MAIN_TOKEN: \${{ secrets.${exports.EBK_RECORD_MAIN_TOKEN_SECRET} }}
2396
+ EBK_SOURCE_BRANCH: \${{ env.EBK_DEPLOYMENT_SOURCE_BRANCH }}
2397
+ EBK_SOURCE_COMMIT: \${{ env.EBK_DEPLOYMENT_SOURCE_COMMIT }}
2398
+ GH_REPO: \${{ github.repository }}
2399
+ run: |
2400
+ set -euo pipefail
2401
+ test -n "$EBK_RECORD_MAIN_TOKEN" || { echo '${exports.EBK_RECORD_MAIN_TOKEN_SECRET} is absent from the production environment'; exit 1; }
2402
+ [ "$EBK_SOURCE_BRANCH" = develop ] || { echo 'Trunk promotion records main only from develop'; exit 1; }
2403
+ case "$EBK_SOURCE_COMMIT" in ''|*[!0-9a-f]*) exit 1 ;; esac
2404
+ export GH_TOKEN="$EBK_RECORD_MAIN_TOKEN"
2405
+ develop_tip="$(gh api "/repos/$GH_REPO/git/ref/heads/develop" --jq '.object.sha')"
2406
+ [ "$develop_tip" = "$EBK_SOURCE_COMMIT" ] || { echo 'Deployed commit is no longer the develop tip; leaving main unchanged'; exit 1; }
2407
+ main_tip="$(gh api "/repos/$GH_REPO/git/ref/heads/main" --jq '.object.sha')"
2408
+ if [ "$main_tip" = "$EBK_SOURCE_COMMIT" ]; then
2409
+ echo 'main already records the deployed commit'
2410
+ exit 0
2411
+ fi
2412
+ # GitHub refuses a non-fast-forward reference update unless force is
2413
+ # requested; it is never requested here.
2414
+ gh api -X PATCH "/repos/$GH_REPO/git/refs/heads/main" -f sha="$EBK_SOURCE_COMMIT" -F force=false >/dev/null
2415
+ recorded="$(gh api "/repos/$GH_REPO/git/ref/heads/main" --jq '.object.sha')"
2416
+ [ "$recorded" = "$EBK_SOURCE_COMMIT" ] || { echo 'main did not record the deployed commit'; exit 1; }
2417
+ echo "main now records $EBK_SOURCE_COMMIT (previously $main_tip)"
1549
2418
  `;
1550
2419
  }
1551
2420
  function renderThinDeployWorkflowFilePlan(options) {