cpflow 5.3.0 → 6.0.0

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/agent-workflow.yml +16 -25
  3. data/.agents/bin/README.md +19 -9
  4. data/.agents/bin/docs +1 -1
  5. data/.agents/bin/lint +1 -1
  6. data/.agents/bin/setup +1 -1
  7. data/.agents/bin/test +1 -1
  8. data/.agents/bin/validate +2 -1
  9. data/.agents/legacy-workflow-policy.yml +11 -0
  10. data/.agents/shaka.md +20 -0
  11. data/.agents/trusted-actions.yml +12 -0
  12. data/.coderabbit.yaml +14 -0
  13. data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
  14. data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
  15. data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
  16. data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
  17. data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
  18. data/.github/actions/cpflow-setup-environment/action.yml +8 -6
  19. data/.github/dependabot.yml +10 -0
  20. data/.github/workflows/check_cpln_links.yml +6 -1
  21. data/.github/workflows/claude-code-review.yml +5 -2
  22. data/.github/workflows/claude.yml +6 -4
  23. data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
  24. data/.github/workflows/command_docs.yml +7 -2
  25. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
  26. data/.github/workflows/cpflow-delete-review-app.yml +18 -11
  27. data/.github/workflows/cpflow-deploy-review-app.yml +38 -17
  28. data/.github/workflows/cpflow-deploy-staging.yml +28 -11
  29. data/.github/workflows/cpflow-help-command.yml +3 -3
  30. data/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  31. data/.github/workflows/cpflow-review-app-help.yml +1 -1
  32. data/.github/workflows/rspec-shared.yml +34 -26
  33. data/.github/workflows/rspec-specific.yml +10 -2
  34. data/.github/workflows/rspec.yml +95 -4
  35. data/.github/workflows/rubocop.yml +9 -2
  36. data/.github/workflows/trigger-docs-site.yml +2 -0
  37. data/.rubocop.yml +6 -1
  38. data/AGENTS.md +18 -3
  39. data/CHANGELOG.md +39 -1
  40. data/CONTRIBUTING.md +39 -2
  41. data/Gemfile.lock +1 -1
  42. data/README.md +10 -2
  43. data/cpflow.gemspec +3 -15
  44. data/docs/ai-github-flow-prompt.md +3 -3
  45. data/docs/ci-automation.md +284 -68
  46. data/docs/commands.md +38 -15
  47. data/docs/postgres-review-apps.md +90 -0
  48. data/docs/postgres.md +233 -375
  49. data/docs/rds-private-networking.md +12 -12
  50. data/docs/releasing.md +15 -4
  51. data/docs/secrets-and-env-values.md +42 -0
  52. data/docs/sidebars.ts +1 -0
  53. data/examples/controlplane.yml +5 -0
  54. data/lib/command/ai_github_flow_prompt.rb +2 -2
  55. data/lib/command/apply_template.rb +36 -4
  56. data/lib/command/base.rb +56 -3
  57. data/lib/command/cleanup_stale_apps.rb +28 -4
  58. data/lib/command/copy_image_from_upstream.rb +3 -0
  59. data/lib/command/delete.rb +174 -4
  60. data/lib/command/deploy_image.rb +44 -1
  61. data/lib/command/generate.rb +247 -3
  62. data/lib/command/generate_github_actions.rb +43 -13
  63. data/lib/command/run.rb +224 -34
  64. data/lib/command/setup_app.rb +92 -10
  65. data/lib/command/update_github_actions.rb +82 -43
  66. data/lib/core/config.rb +73 -0
  67. data/lib/core/controlplane.rb +167 -80
  68. data/lib/core/controlplane_api.rb +28 -2
  69. data/lib/core/controlplane_api_direct.rb +17 -0
  70. data/lib/core/disposable_postgres_helpers.rb +167 -0
  71. data/lib/core/doctor_service.rb +7 -0
  72. data/lib/core/github_flow_readiness/checks.rb +2 -1
  73. data/lib/core/github_flow_readiness_service.rb +4 -0
  74. data/lib/core/repo_introspection.rb +112 -2
  75. data/lib/core/shell.rb +35 -10
  76. data/lib/core/timed_command.rb +111 -0
  77. data/lib/cpflow/version.rb +1 -1
  78. data/lib/cpflow.rb +1 -1
  79. data/lib/generator_templates/.dockerignore +16 -0
  80. data/lib/generator_templates/Dockerfile +9 -5
  81. data/lib/generator_templates/entrypoint.sh +2 -0
  82. data/lib/generator_templates/templates/postgres.yml +9 -0
  83. data/lib/generator_templates_sqlite/controlplane.yml +2 -1
  84. data/lib/generator_templates_sqlite/release_script.sh +1 -1
  85. data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
  86. data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
  87. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
  88. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
  89. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
  90. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
  91. data/lib/github_flow_templates/bin/test-cpflow-github-flow +125 -46
  92. data/lib/patches/hash.rb +2 -2
  93. data/rakelib/create_release.rake +14 -14
  94. data/script/check_shell_scripts +66 -0
  95. metadata +18 -17
@@ -132,21 +132,30 @@ production org, using production-only secrets and values.
132
132
 
133
133
  ## Version Locking
134
134
 
135
- Generated wrappers pin Control Plane Flow with a release tag, for example
136
- `__CPFLOW_GITHUB_ACTIONS_REF__`. Reusable review-app, staging, cleanup, and
137
- helper workflows pin the tag in their `uses:` ref. Production promotion pins
138
- the same tag in the `Checkout control-plane-flow actions` step so the
135
+ Freshly generated wrappers start with a Control Plane Flow release tag, for
136
+ example `__CPFLOW_GITHUB_ACTIONS_REF__`. Before review and merge, use the pin
137
+ helper below to replace that tag with its immutable commit SHA and retain the
138
+ reviewed tag in a readable comment. Reusable review-app, staging, cleanup, and
139
+ helper workflows then use that SHA. Production promotion uses the same SHA and
140
+ tag in the `Checkout control-plane-flow actions` step so the
139
141
  caller-owned job can keep `environment: production` and receive production
140
142
  environment secrets directly.
141
143
 
142
144
  Leave `CPFLOW_VERSION` unset so the workflow builds cpflow from the same
143
- checked-out upstream source. If you set `CPFLOW_VERSION`, it must match the
144
- release tag your wrappers are pinned to: a `CPFLOW_VERSION=__CPFLOW_MINOR_SERIES__` runtime
145
- override goes with a wrapper pinned to `uses: ...@v__CPFLOW_MINOR_SERIES__` (substitute the
146
- release you pinned above).
145
+ checked-out upstream source. `CPFLOW_VERSION` is supported only while the
146
+ wrapper itself uses an actual release-tag ref; it must match that tag. Keep it
147
+ unset after replacing the wrapper ref with an immutable SHA because the
148
+ checked-out source already determines the runtime version.
147
149
 
148
- After updating the `cpflow` gem in this repo, update the generated wrappers in
149
- the same PR:
150
+ If an existing `bin/test-cpflow-github-flow` differs from cpflow's generated
151
+ validator, first preserve its downstream checks in executable
152
+ `bin/test-cpflow-github-flow-custom`, then remove or rename the old generated
153
+ validator before retrying the update.
154
+
155
+ After updating the `cpflow` gem in this repo, refresh the generated local
156
+ actions and validation helpers in the same PR. The default command
157
+ preserves every top-level workflow, including its refs, triggers, permissions, and
158
+ downstream customizations:
150
159
 
151
160
  ```sh
152
161
  cpflow update-github-actions
@@ -160,12 +169,30 @@ bundle exec cpflow update-github-actions
160
169
  bin/test-cpflow-github-flow bundle exec cpflow
161
170
  ```
162
171
 
172
+ Replace workflow wrappers only after explicitly selecting and reviewing them:
173
+
174
+ ```sh
175
+ cpflow update-github-actions --workflows FILE...
176
+ ```
177
+
178
+ Reapply required downstream customizations after a selected replacement, then
179
+ pin and validate the complete flow.
180
+
181
+ For a reviewed release, resolve its tag to the exact commit SHA, then pin and
182
+ validate that immutable source:
183
+
184
+ ```sh
185
+ bin/pin-cpflow-github-ref --version vX.Y.Z <40-character-control-plane-flow-commit-sha>
186
+ bin/test-cpflow-github-flow
187
+ ```
188
+
163
189
  Do not leave downstream apps pinned to a moving branch such as `main`. For a
164
190
  short-lived test of an unreleased upstream PR, pin to a full 40-character commit
165
- SHA and leave `CPFLOW_VERSION` unset:
191
+ SHA, use the upstream source's current in-progress version tag as the readable
192
+ `--version` label, and leave `CPFLOW_VERSION` unset:
166
193
 
167
194
  ```sh
168
- bin/pin-cpflow-github-ref <40-character-control-plane-flow-commit-sha>
195
+ bin/pin-cpflow-github-ref --version vX.Y.Z <40-character-control-plane-flow-commit-sha>
169
196
  bin/test-cpflow-github-flow ruby /path/to/control-plane-flow/bin/cpflow
170
197
  ```
171
198
 
@@ -42,4 +42,5 @@ jobs:
42
42
  uses: shakacode/control-plane-flow/.github/workflows/cpflow-deploy-review-app.yml@__CPFLOW_GITHUB_ACTIONS_REF__
43
43
  secrets:
44
44
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
45
+ REVIEW_APP_DOCKER_BUILD_SECRETS: ${{ secrets.REVIEW_APP_DOCKER_BUILD_SECRETS }}
45
46
  DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }}
@@ -21,4 +21,5 @@ jobs:
21
21
  staging_app_branch_default: "__STAGING_BRANCH_DEFAULT__"
22
22
  secrets:
23
23
  CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
24
+ DOCKER_BUILD_SECRETS: ${{ secrets.DOCKER_BUILD_SECRETS }}
24
25
  DOCKER_BUILD_SSH_KEY: ${{ secrets.DOCKER_BUILD_SSH_KEY }}
@@ -46,27 +46,93 @@ concurrency:
46
46
  cancel-in-progress: false
47
47
 
48
48
  jobs:
49
+ preview-promotion:
50
+ if: github.event.inputs.confirm_promotion == 'promote'
51
+ runs-on: ubuntu-latest
52
+ timeout-minutes: 10
53
+ # No production Environment or production token: this finishes before approval.
54
+ outputs:
55
+ primary_workload: ${{ steps.preview.outputs.primary_workload }}
56
+ staging_image: ${{ steps.preview.outputs.staging_image }}
57
+ production_commit: ${{ steps.preview.outputs.production_commit }}
58
+ steps:
59
+ - name: Checkout repository
60
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
61
+ with:
62
+ persist-credentials: false
63
+
64
+ - name: Checkout control-plane-flow actions
65
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
66
+ with:
67
+ repository: shakacode/control-plane-flow
68
+ ref: __CPFLOW_GITHUB_ACTIONS_REF__
69
+ path: .cpflow
70
+ persist-credentials: false
71
+
72
+ - name: Normalize staging org
73
+ id: staging-org
74
+ env:
75
+ STAGING_ORG: ${{ vars.CPLN_ORG_STAGING }}
76
+ shell: bash
77
+ run: |
78
+ set -euo pipefail
79
+ org="${STAGING_ORG#"${STAGING_ORG%%[![:space:]]*}"}"
80
+ org="${org%"${org##*[![:space:]]}"}"
81
+ if ! [[ "$org" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
82
+ echo "::error::CPLN_ORG_STAGING must be a valid Control Plane org name."
83
+ exit 1
84
+ fi
85
+ echo "org=$org" >> "$GITHUB_OUTPUT"
86
+
87
+ - name: Setup staging environment
88
+ uses: ./.github/actions/cpflow-setup-environment
89
+ with:
90
+ token: ${{ secrets.CPLN_TOKEN_STAGING }}
91
+ org: ${{ steps.staging-org.outputs.org }}
92
+ working_directory: .cpflow
93
+ cpln_cli_version: ${{ vars.CPLN_CLI_VERSION }}
94
+ cpflow_version: ${{ vars.CPFLOW_VERSION }}
95
+ # The setup action validates CPFLOW_VERSION against this full workflow ref.
96
+ control_plane_flow_ref: shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@__CPFLOW_GITHUB_ACTIONS_REF__
97
+
98
+ - name: Preview commits before production approval
99
+ id: preview
100
+ uses: ./.github/actions/cpflow-preview-promotion
101
+ env:
102
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103
+ GH_REPO: ${{ github.repository }}
104
+ CPLN_TOKEN: ${{ secrets.CPLN_TOKEN_STAGING }}
105
+ with:
106
+ staging_app_name: ${{ vars.STAGING_APP_NAME }}
107
+ staging_org: ${{ steps.staging-org.outputs.org }}
108
+ primary_workload: ${{ vars.PRIMARY_WORKLOAD }}
109
+
49
110
  promote-to-production:
111
+ needs: preview-promotion
50
112
  if: github.event.inputs.confirm_promotion == 'promote'
51
113
  runs-on: ubuntu-latest
52
114
  # This normal caller-repo job declares the protected production Environment
53
115
  # directly, so GitHub exposes environment secrets in this job after the
54
116
  # environment gate. Do not move production promotion back behind a
55
117
  # cross-repo reusable workflow; environment secrets are not available there.
56
- environment: production
118
+ # The URL gives the deployment a "View deployment" link on GitHub.
119
+ environment:
120
+ name: production
121
+ url: ${{ steps.health-check.outputs.endpoint }}
57
122
  timeout-minutes: 45
58
123
  outputs:
59
124
  staging_app_name: ${{ steps.release-context.outputs.staging_app_name }}
60
125
  production_app_name: ${{ steps.release-context.outputs.production_app_name }}
126
+ staging_commit: ${{ steps.copy-image.outputs.staging_commit }}
61
127
 
62
128
  steps:
63
129
  - name: Checkout repository
64
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
130
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
65
131
  with:
66
132
  persist-credentials: false
67
133
 
68
134
  - name: Checkout control-plane-flow actions
69
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
135
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
70
136
  with:
71
137
  repository: shakacode/control-plane-flow
72
138
  ref: __CPFLOW_GITHUB_ACTIONS_REF__
@@ -90,7 +156,7 @@ jobs:
90
156
  fi
91
157
 
92
158
  - name: Validate required secrets and variables
93
- uses: ./.cpflow/.github/actions/cpflow-validate-config
159
+ uses: ./.github/actions/cpflow-validate-config
94
160
  # Pass secrets via env so the composite action checks indirect shell
95
161
  # variables instead of interpolating secret values into a run script.
96
162
  env:
@@ -171,7 +237,7 @@ jobs:
171
237
  } >> "$GITHUB_OUTPUT"
172
238
 
173
239
  - name: Setup production environment
174
- uses: ./.cpflow/.github/actions/cpflow-setup-environment
240
+ uses: ./.github/actions/cpflow-setup-environment
175
241
  with:
176
242
  token: ${{ secrets.CPLN_TOKEN_PRODUCTION }}
177
243
  org: ${{ steps.cpln-orgs.outputs.production }}
@@ -237,7 +303,7 @@ jobs:
237
303
 
238
304
  - name: Detect release phase support
239
305
  id: release-phase
240
- uses: ./.cpflow/.github/actions/cpflow-detect-release-phase
306
+ uses: ./.github/actions/cpflow-detect-release-phase
241
307
  with:
242
308
  app_name: ${{ vars.PRODUCTION_APP_NAME }}
243
309
 
@@ -440,8 +506,35 @@ jobs:
440
506
 
441
507
  echo "image=${staging_image}" >> "$GITHUB_OUTPUT"
442
508
 
509
+ - name: Verify approved promotion snapshot
510
+ id: verify-preview
511
+ env:
512
+ PREVIEW_WORKLOAD: ${{ needs.preview-promotion.outputs.primary_workload }}
513
+ CURRENT_PRIMARY_WORKLOAD: ${{ steps.workloads.outputs.primary }}
514
+ PREVIEW_IMAGE: ${{ needs.preview-promotion.outputs.staging_image }}
515
+ PREVIEW_PRODUCTION_COMMIT: ${{ needs.preview-promotion.outputs.production_commit }}
516
+ CURRENT_STAGING_IMAGE: ${{ steps.staging-image.outputs.image }}
517
+ CURRENT_PRODUCTION_IMAGE: ${{ steps.capture-current.outputs.current_image }}
518
+ shell: bash
519
+ run: |
520
+ set -euo pipefail
521
+ if [[ -z "$PREVIEW_WORKLOAD" || "$PREVIEW_WORKLOAD" != "$CURRENT_PRIMARY_WORKLOAD" ]]; then
522
+ echo "::error::The production primary workload differs from the preview. Set PRIMARY_WORKLOAD as a repository variable shared by both jobs and rerun."
523
+ exit 1
524
+ fi
525
+ if [[ -z "$PREVIEW_IMAGE" || "$CURRENT_STAGING_IMAGE" != "$PREVIEW_IMAGE" ]]; then
526
+ echo "::error::Staging changed since the preview. Rerun promotion and review the new comparison."
527
+ exit 1
528
+ fi
529
+ production_tag="${CURRENT_PRODUCTION_IMAGE%%@*}"
530
+ production_commit="${production_tag##*_}"
531
+ if [[ -n "$PREVIEW_PRODUCTION_COMMIT" && "$production_commit" != "$PREVIEW_PRODUCTION_COMMIT" ]]; then
532
+ echo "::error::Live production does not match its recorded release. Reconcile the production release record, then rerun promotion."
533
+ exit 1
534
+ fi
535
+
443
536
  - name: Set up Docker Buildx
444
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
537
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
445
538
 
446
539
  - name: Copy image from staging
447
540
  id: copy-image
@@ -476,22 +569,26 @@ jobs:
476
569
  exit 1
477
570
  fi
478
571
 
479
- if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
572
+ staging_image_without_digest="${staging_image%%@*}"
573
+ if ! CPLN_TOKEN="${CPLN_TOKEN_STAGING}" cpln image get "${staging_image_without_digest}" --org "${CPLN_ORG_STAGING}" -o json >/dev/null; then
480
574
  echo "::error::Staging image '${STAGING_IMAGE}' was not found in org '${CPLN_ORG_STAGING}'; aborting promotion."
481
575
  exit 1
482
576
  fi
483
577
 
484
578
  staging_tag=""
485
- if [[ "${staging_image}" == *@* ]]; then
486
- staging_tag="${staging_image##*@}"
487
- elif [[ "${staging_image}" == *:* ]]; then
488
- staging_tag="${staging_image##*:}"
579
+ if [[ "${staging_image_without_digest}" == *:* ]]; then
580
+ staging_tag="${staging_image_without_digest##*:}"
489
581
  fi
490
582
  staging_commit=""
491
583
  if [[ "${staging_tag}" == *_* ]]; then
492
584
  staging_commit="${staging_tag##*_}"
493
585
  else
494
- echo "::warning::Staging image '${staging_image}' did not include a '_<commit>' suffix; production image tag will omit the commit suffix."
586
+ echo "::error::Staging image '${staging_image}' does not include the commit suffix required for a traceable production release."
587
+ exit 1
588
+ fi
589
+ if ! [[ "${staging_commit}" =~ ^[0-9a-f]{40}$ ]]; then
590
+ echo "::error::Staging image '${staging_image}' has an invalid commit suffix; expected a full lowercase Git commit SHA."
591
+ exit 1
495
592
  fi
496
593
 
497
594
  # The workflow-level concurrency group serializes this sequence so two
@@ -566,8 +663,10 @@ jobs:
566
663
  fi
567
664
 
568
665
  echo "image=${production_image}" >> "$GITHUB_OUTPUT"
666
+ echo "staging_commit=${staging_commit}" >> "$GITHUB_OUTPUT"
569
667
 
570
668
  - name: Deploy image to production
669
+ id: deploy-production
571
670
  env:
572
671
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
573
672
  CPLN_ORG_PRODUCTION: ${{ steps.cpln-orgs.outputs.production }}
@@ -576,6 +675,16 @@ jobs:
576
675
  run: |
577
676
  set -euo pipefail
578
677
 
678
+ deployment_marker_dir="$(mktemp -d)"
679
+ export CPFLOW_DEPLOYMENT_STARTED_FILE="${deployment_marker_dir}/started"
680
+ record_deployment_state() {
681
+ if [[ -f "${CPFLOW_DEPLOYMENT_STARTED_FILE}" ]]; then
682
+ echo "deployment_started=true" >> "${GITHUB_OUTPUT}"
683
+ fi
684
+ rm -rf "${deployment_marker_dir}"
685
+ }
686
+ trap record_deployment_state EXIT
687
+
579
688
  deploy_args=(-a "${PRODUCTION_APP_NAME}")
580
689
  if [[ -n "${RELEASE_PHASE_FLAG}" ]]; then
581
690
  deploy_args+=("${RELEASE_PHASE_FLAG}")
@@ -589,7 +698,7 @@ jobs:
589
698
 
590
699
  - name: Wait for deployment health
591
700
  id: health-check
592
- uses: ./.cpflow/.github/actions/cpflow-wait-for-health
701
+ uses: ./.github/actions/cpflow-wait-for-health
593
702
  with:
594
703
  workload_name: ${{ steps.workloads.outputs.primary }}
595
704
  app_name: ${{ vars.PRODUCTION_APP_NAME }}
@@ -599,7 +708,11 @@ jobs:
599
708
  accepted_statuses: ${{ env.HEALTH_CHECK_ACCEPTED_STATUSES }}
600
709
 
601
710
  - name: Roll back on failure
602
- if: failure() && steps.capture-current.outcome == 'success'
711
+ if: >-
712
+ failure() &&
713
+ steps.deploy-production.outputs.deployment_started == 'true' &&
714
+ steps.capture-current.outputs.rollback_state != '' &&
715
+ steps.capture-current.outputs.rollback_state != '{}'
603
716
  env:
604
717
  ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }}
605
718
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
@@ -661,7 +774,11 @@ jobs:
661
774
  fi
662
775
 
663
776
  - name: Wait for rollback readiness
664
- if: failure() && steps.capture-current.outcome == 'success'
777
+ if: >-
778
+ failure() &&
779
+ steps.deploy-production.outputs.deployment_started == 'true' &&
780
+ steps.capture-current.outputs.rollback_state != '' &&
781
+ steps.capture-current.outputs.rollback_state != '{}'
665
782
  env:
666
783
  ROLLBACK_STATE: ${{ steps.capture-current.outputs.rollback_state }}
667
784
  PRODUCTION_APP_NAME: ${{ vars.PRODUCTION_APP_NAME }}
@@ -770,6 +887,7 @@ jobs:
770
887
  GITHUB_RUN_ID: ${{ github.run_id }}
771
888
  STAGING_APP_NAME: ${{ needs.promote-to-production.outputs.staging_app_name }}
772
889
  PRODUCTION_APP_NAME: ${{ needs.promote-to-production.outputs.production_app_name }}
890
+ STAGING_COMMIT: ${{ needs.promote-to-production.outputs.staging_commit }}
773
891
  shell: bash
774
892
  run: |
775
893
  set -euo pipefail
@@ -779,5 +897,6 @@ jobs:
779
897
  release_tag="production-${release_date}-${timestamp}-${GITHUB_RUN_ID}"
780
898
 
781
899
  gh release create "${release_tag}" \
900
+ --target "${STAGING_COMMIT}" \
782
901
  --title "Production Release ${release_date} ${timestamp}" \
783
902
  --notes "Promoted ${STAGING_APP_NAME} to ${PRODUCTION_APP_NAME} on ${release_date} at ${timestamp}."
@@ -2,19 +2,28 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "pathname"
5
+ require "optparse"
5
6
 
6
7
  USAGE = <<~USAGE
7
- Usage: bin/pin-cpflow-github-ref [--allow-moving-ref] <control-plane-flow-ref>
8
+ Usage: bin/pin-cpflow-github-ref [--version TAG] [--allow-moving-ref] <control-plane-flow-ref>
8
9
 
9
10
  Use a release tag for normal operation, e.g. v5.0.0.
10
11
  Use a full 40-character commit SHA for temporary unreleased upstream testing.
12
+ --version TAG supplies the reviewed v-prefixed version/tag for SHA comments
13
+ (required for a SHA; no default). Verify the tag/version against upstream first.
14
+ This helper does not resolve tags over the network or verify tag-to-SHA identity.
11
15
  This updates generated reusable-workflow `uses:` refs plus the production
12
16
  workflow's pinned control-plane-flow checkout and setup validation ref.
13
17
  Regenerate from the cpflow gem when templates changed.
14
18
  Use --allow-moving-ref only for short-lived local branch/ref experiments.
19
+ Use -h or --help to show this help.
15
20
  USAGE
16
21
 
17
- ALLOWED_OPTIONS = ["--allow-moving-ref"].freeze
22
+ if ARGV.include?("--help") || ARGV.include?("-h")
23
+ puts USAGE
24
+ exit 0
25
+ end
26
+
18
27
  FULL_COMMIT_SHA = /\A[0-9a-f]{40}\z/i
19
28
  RELEASE_TAG = /\Av\d+\.\d+\.\d+(?:[-.][0-9A-Za-z][0-9A-Za-z.-]*)?\z/
20
29
  PRODUCTION_WORKFLOW_REF = "shakacode/control-plane-flow/.github/workflows/" \
@@ -25,14 +34,19 @@ CPFLOW_CHECKOUT_REF_PATTERN = %r{
25
34
  ^\s+repository:\s+shakacode/control-plane-flow\s*\n
26
35
  (?:(?!^\s*-\s+name:).)*?
27
36
  ^\s+ref:\s+)
28
- [^\s]+
37
+ [^\s\#]+ [\t\ ]* (?:\#[^\n]*)?
29
38
  }mx
30
39
 
31
- options, positional = ARGV.partition { |arg| arg.start_with?("--") }
32
- unknown_options = options - ALLOWED_OPTIONS
33
-
34
- unless unknown_options.empty?
35
- warn "Unknown option(s): #{unknown_options.join(', ')}"
40
+ version = nil
41
+ allow_moving_ref = false
42
+ parser = OptionParser.new do |options|
43
+ options.on("--version TAG") { |value| version = value }
44
+ options.on("--allow-moving-ref") { allow_moving_ref = true }
45
+ end
46
+ begin
47
+ positional = parser.parse(ARGV)
48
+ rescue OptionParser::ParseError => e
49
+ warn e.message
36
50
  warn USAGE
37
51
  exit 1
38
52
  end
@@ -43,13 +57,21 @@ unless positional.length == 1
43
57
  end
44
58
 
45
59
  ref = positional.first
46
- allow_moving_ref = options.include?("--allow-moving-ref")
47
60
 
48
61
  unless ref.match?(%r{\A[0-9A-Za-z._/-]+\z})
49
62
  warn "Ref contains unsupported characters: #{ref.inspect}"
50
63
  exit 1
51
64
  end
52
65
 
66
+ if (version && !version.match?(RELEASE_TAG)) || (ref.match?(FULL_COMMIT_SHA) && !version)
67
+ warn "A SHA pin requires --version TAG with a reviewed v-prefixed version/tag (for example v6.0.0.rc.0)."
68
+ warn USAGE
69
+ exit 1
70
+ end
71
+
72
+ ref = ref.downcase if ref.match?(FULL_COMMIT_SHA)
73
+ annotated_ref = ref.match?(FULL_COMMIT_SHA) ? "#{ref} # #{version}" : ref
74
+
53
75
  unless ref.match?(FULL_COMMIT_SHA) || ref.match?(RELEASE_TAG) || allow_moving_ref
54
76
  warn "Ref must be a full 40-character commit SHA or a v-prefixed release tag: #{ref.inspect}"
55
77
  warn "Use --allow-moving-ref only for a short-lived branch/ref experiment."
@@ -68,11 +90,13 @@ changed = []
68
90
  workflow_paths.each do |path|
69
91
  text = File.read(path)
70
92
  production_setup_ref_pattern =
71
- /(control_plane_flow_ref:\s+#{Regexp.escape(PRODUCTION_WORKFLOW_REF)}@)[^\s]+/
93
+ /(control_plane_flow_ref:\s+#{Regexp.escape(PRODUCTION_WORKFLOW_REF)}@)[^\s#]+[\t ]*(?:#[^\n]*)?/
94
+ workflow_ref_pattern =
95
+ %r{(uses:\s+shakacode/control-plane-flow/\.github/workflows/[^@\s]+@)[^\s#]+[\t ]*(?:#[^\n]*)?}
72
96
  updated = text
73
- .gsub(%r{(uses:\s+shakacode/control-plane-flow/\.github/workflows/[^@\s]+@)[^\s]+}, "\\1#{ref}")
74
- .gsub(production_setup_ref_pattern, "\\1#{ref}")
75
- .gsub(CPFLOW_CHECKOUT_REF_PATTERN, "\\1#{ref}")
97
+ .gsub(workflow_ref_pattern) { "#{Regexp.last_match(1)}#{annotated_ref}" }
98
+ .gsub(production_setup_ref_pattern) { "#{Regexp.last_match(1)}#{annotated_ref}" }
99
+ .gsub(CPFLOW_CHECKOUT_REF_PATTERN) { "#{Regexp.last_match(1)}#{annotated_ref}" }
76
100
 
77
101
  next if updated == text
78
102
 
@@ -16,10 +16,70 @@ echo "==> parse generated GitHub Actions YAML"
16
16
  ruby <<'RUBY'
17
17
  require "yaml"
18
18
 
19
- Dir[".github/actions/**/action.yml", ".github/workflows/*.yml"].sort.each do |path|
20
- YAML.load_file(path, aliases: true)
19
+ documents = Dir[
20
+ ".github/actions/**/action.yml",
21
+ ".github/actions/**/action.yaml",
22
+ ".github/workflows/*.yml",
23
+ ".github/workflows/*.yaml"
24
+ ].sort.to_h do |path|
25
+ [path, YAML.load_file(path, aliases: true)]
26
+ end
27
+
28
+ documents.each_key do |path|
21
29
  puts "parsed #{path}"
22
30
  end
31
+
32
+ generated_local_action_pattern = %r{\A\./(?<directory>\.github/actions/cpflow-[a-z0-9]+(?:-[a-z0-9]+)*)\z}
33
+ local_action_references = []
34
+ invalid_local_action_references = []
35
+ visited_container_ids = {}
36
+ walk = lambda do |node, source_path|
37
+ if node.is_a?(Hash) || node.is_a?(Array)
38
+ next if visited_container_ids.key?(node.object_id)
39
+
40
+ visited_container_ids[node.object_id] = true
41
+ end
42
+
43
+ case node
44
+ when Hash
45
+ uses = node["uses"]
46
+ if uses.is_a?(String) && uses.start_with?("./.github/actions/cpflow-")
47
+ match = uses.match(generated_local_action_pattern)
48
+ if match
49
+ local_action_references << [source_path, match[:directory]]
50
+ else
51
+ invalid_local_action_references <<
52
+ "#{source_path} has invalid generated local action reference #{uses}"
53
+ end
54
+ end
55
+ node.each_value { |value| walk.call(value, source_path) }
56
+ when Array
57
+ node.each { |value| walk.call(value, source_path) }
58
+ end
59
+ end
60
+ documents.each { |path, document| walk.call(document, path) }
61
+
62
+ abort invalid_local_action_references.uniq.join("\n") unless invalid_local_action_references.empty?
63
+
64
+ missing_actions = local_action_references.uniq.filter_map do |source_path, action_directory|
65
+ directory_is_local = begin
66
+ File.lstat(action_directory).directory?
67
+ rescue Errno::ENOENT
68
+ false
69
+ end
70
+ descriptor_is_local = %w[action.yml action.yaml].any? do |filename|
71
+ begin
72
+ File.lstat(File.join(action_directory, filename)).file?
73
+ rescue Errno::ENOENT
74
+ false
75
+ end
76
+ end
77
+
78
+ "#{source_path} references missing local action #{action_directory}" unless directory_is_local && descriptor_is_local
79
+ end
80
+
81
+ abort missing_actions.join("\n") unless missing_actions.empty?
82
+ puts "all referenced local actions have checked-in descriptors"
23
83
  RUBY
24
84
 
25
85
  echo "==> check composite action input descriptions"
@@ -45,7 +105,7 @@ require "yaml"
45
105
  CONTROL_PLANE_FLOW_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/[^@\s]+@([^\s]+)\z}
46
106
  PROMOTE_WORKFLOW = %r{\Ashakacode/control-plane-flow/\.github/workflows/cpflow-promote-staging-to-production\.yml@([^\s]+)\z}
47
107
  EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT = "shakacode/control-plane-flow/.github/workflows/cpflow-promote-staging-to-production.yml@vX.Y.Z"
48
- EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd"
108
+ EXPECTED_CPFLOW_CHECKOUT_ACTION = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"
49
109
  EXPECTED_CPFLOW_CHECKOUT_REPOSITORY = "shakacode/control-plane-flow"
50
110
 
51
111
  refs = Hash.new { |hash, key| hash[key] = [] }
@@ -82,60 +142,65 @@ Dir[".github/workflows/cpflow-*.yml"].sort.each do |path|
82
142
  end
83
143
 
84
144
  promote_path = ".github/workflows/cpflow-promote-staging-to-production.yml"
85
- promote_doc = YAML.load_file(promote_path, aliases: true)
86
- promote_job = promote_doc.fetch("jobs", {})["promote-to-production"]
145
+ if File.exist?(promote_path)
146
+ promote_doc = YAML.load_file(promote_path, aliases: true)
147
+ promote_job = promote_doc.fetch("jobs", {})["promote-to-production"]
87
148
 
88
- unless promote_job
89
- abort "#{promote_path}:promote-to-production job is missing"
90
- end
149
+ unless promote_job
150
+ abort "#{promote_path}:promote-to-production job is missing"
151
+ end
91
152
 
92
- if promote_job.key?("uses")
93
- abort "#{promote_path}:promote-to-production must run as a normal caller-repo job, not a reusable workflow, so GitHub can expose production environment secrets"
94
- end
153
+ if promote_job.key?("uses")
154
+ abort "#{promote_path}:promote-to-production must run as a normal caller-repo job, not a reusable workflow, so GitHub can expose production environment secrets"
155
+ end
95
156
 
96
- unless promote_job["environment"].to_s == "production"
97
- abort "#{promote_path}:promote-to-production must declare environment: production"
98
- end
157
+ # Older generated workflows use the string form; current ones add a deployment URL.
158
+ promote_environment = promote_job["environment"]
159
+ promote_environment = promote_environment["name"] if promote_environment.is_a?(Hash)
160
+ unless promote_environment.to_s == "production"
161
+ abort "#{promote_path}:promote-to-production must declare environment: production"
162
+ end
99
163
 
100
- checkout_step = Array(promote_job["steps"]).find { |step| step["name"] == "Checkout control-plane-flow actions" }
164
+ checkout_step = Array(promote_job["steps"]).find { |step| step["name"] == "Checkout control-plane-flow actions" }
101
165
 
102
- unless checkout_step
103
- abort "#{promote_path}:promote-to-production must include a Checkout control-plane-flow actions step"
104
- end
166
+ unless checkout_step
167
+ abort "#{promote_path}:promote-to-production must include a Checkout control-plane-flow actions step"
168
+ end
105
169
 
106
- unless checkout_step["uses"] == EXPECTED_CPFLOW_CHECKOUT_ACTION
107
- abort "#{promote_path}:promote-to-production must use #{EXPECTED_CPFLOW_CHECKOUT_ACTION} for the Checkout control-plane-flow actions step"
108
- end
170
+ unless checkout_step["uses"] == EXPECTED_CPFLOW_CHECKOUT_ACTION
171
+ abort "#{promote_path}:promote-to-production must use #{EXPECTED_CPFLOW_CHECKOUT_ACTION} for the Checkout control-plane-flow actions step"
172
+ end
109
173
 
110
- checkout_with = checkout_step.fetch("with", {})
111
- checkout_repository = checkout_with["repository"]
112
- checkout_ref = checkout_with["ref"]
174
+ checkout_with = checkout_step.fetch("with", {})
175
+ checkout_repository = checkout_with["repository"]
176
+ checkout_ref = checkout_with["ref"]
113
177
 
114
- unless checkout_repository == EXPECTED_CPFLOW_CHECKOUT_REPOSITORY
115
- abort "#{promote_path}:promote-to-production must check out #{EXPECTED_CPFLOW_CHECKOUT_REPOSITORY}"
116
- end
178
+ unless checkout_repository == EXPECTED_CPFLOW_CHECKOUT_REPOSITORY
179
+ abort "#{promote_path}:promote-to-production must check out #{EXPECTED_CPFLOW_CHECKOUT_REPOSITORY}"
180
+ end
117
181
 
118
- if checkout_ref.to_s.strip.empty?
119
- abort "#{promote_path}:promote-to-production must pin the Checkout control-plane-flow actions step"
120
- end
182
+ if checkout_ref.to_s.strip.empty?
183
+ abort "#{promote_path}:promote-to-production must pin the Checkout control-plane-flow actions step"
184
+ end
121
185
 
122
- refs[checkout_ref] << "#{promote_path}:promote-to-production"
186
+ refs[checkout_ref] << "#{promote_path}:promote-to-production"
123
187
 
124
- setup_step = Array(promote_job["steps"]).find { |step| step["name"] == "Setup production environment" }
188
+ setup_step = Array(promote_job["steps"]).find { |step| step["name"] == "Setup production environment" }
125
189
 
126
- unless setup_step
127
- abort "#{promote_path}:promote-to-production must include a Setup production environment step"
128
- end
190
+ unless setup_step
191
+ abort "#{promote_path}:promote-to-production must include a Setup production environment step"
192
+ end
129
193
 
130
- setup_ref = setup_step.fetch("with", {})["control_plane_flow_ref"]
131
- setup_match = setup_ref.to_s.match(PROMOTE_WORKFLOW)
194
+ setup_ref = setup_step.fetch("with", {})["control_plane_flow_ref"]
195
+ setup_match = setup_ref.to_s.match(PROMOTE_WORKFLOW)
132
196
 
133
- unless setup_match
134
- abort "#{promote_path}:promote-to-production must pass a pinned production control_plane_flow_ref to setup, " \
135
- "for example #{EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT}"
136
- end
197
+ unless setup_match
198
+ abort "#{promote_path}:promote-to-production must pass a pinned production control_plane_flow_ref to setup, " \
199
+ "for example #{EXPECTED_PROMOTE_WORKFLOW_REF_FORMAT}"
200
+ end
137
201
 
138
- refs[setup_match[1]] << "#{promote_path}:promote-to-production setup"
202
+ refs[setup_match[1]] << "#{promote_path}:promote-to-production setup"
203
+ end
139
204
 
140
205
  if refs.empty?
141
206
  puts "no upstream cpflow reusable workflow refs found"
@@ -150,7 +215,21 @@ end
150
215
  RUBY
151
216
 
152
217
  echo "==> actionlint"
153
- actionlint \
154
- -ignore "SC2129" \
155
- -ignore 'property "workflow_(ref|sha|repository|file_path)" is not defined in object type' \
156
- .github/workflows/cpflow-*.yml
218
+ shopt -s nullglob
219
+ cpflow_workflows=(.github/workflows/cpflow-*.yml)
220
+ if [[ ${#cpflow_workflows[@]} -gt 0 ]]; then
221
+ actionlint \
222
+ -ignore "SC2129" \
223
+ -ignore 'property "workflow_(ref|sha|repository|file_path)" is not defined in object type' \
224
+ "${cpflow_workflows[@]}"
225
+ fi
226
+
227
+ custom_validator="bin/test-cpflow-github-flow-custom"
228
+ if [[ -e "$custom_validator" || -L "$custom_validator" ]]; then
229
+ if [[ ! -f "$custom_validator" || ! -x "$custom_validator" ]]; then
230
+ echo "Custom validator must be an executable file: $custom_validator" >&2
231
+ exit 1
232
+ fi
233
+ echo "==> downstream cpflow GitHub flow checks"
234
+ "$ROOT/$custom_validator" "${cpflow_cmd[@]}"
235
+ fi