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

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