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.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +16 -25
- data/.agents/bin/README.md +19 -9
- data/.agents/bin/docs +1 -1
- data/.agents/bin/lint +1 -1
- data/.agents/bin/setup +1 -1
- data/.agents/bin/test +1 -1
- data/.agents/bin/validate +2 -1
- data/.agents/legacy-workflow-policy.yml +11 -0
- data/.agents/shaka.md +20 -0
- data/.agents/trusted-actions.yml +12 -0
- data/.coderabbit.yaml +14 -0
- data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
- data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
- data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
- data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
- data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
- data/.github/actions/cpflow-setup-environment/action.yml +8 -6
- data/.github/dependabot.yml +10 -0
- data/.github/workflows/check_cpln_links.yml +6 -1
- data/.github/workflows/claude-code-review.yml +5 -2
- data/.github/workflows/claude.yml +6 -4
- data/.github/workflows/coderabbit-lifecycle-review.yml +29 -0
- data/.github/workflows/command_docs.yml +7 -2
- data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +12 -5
- data/.github/workflows/cpflow-delete-review-app.yml +18 -11
- data/.github/workflows/cpflow-deploy-review-app.yml +38 -17
- data/.github/workflows/cpflow-deploy-staging.yml +28 -11
- data/.github/workflows/cpflow-help-command.yml +3 -3
- data/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
- data/.github/workflows/cpflow-review-app-help.yml +1 -1
- data/.github/workflows/rspec-shared.yml +34 -26
- data/.github/workflows/rspec-specific.yml +10 -2
- data/.github/workflows/rspec.yml +95 -4
- data/.github/workflows/rubocop.yml +9 -2
- data/.github/workflows/trigger-docs-site.yml +2 -0
- data/.rubocop.yml +6 -1
- data/AGENTS.md +18 -3
- data/CHANGELOG.md +39 -1
- data/CONTRIBUTING.md +39 -2
- data/Gemfile.lock +1 -1
- data/README.md +10 -2
- data/cpflow.gemspec +3 -15
- data/docs/ai-github-flow-prompt.md +3 -3
- data/docs/ci-automation.md +284 -68
- data/docs/commands.md +38 -15
- data/docs/postgres-review-apps.md +90 -0
- data/docs/postgres.md +233 -375
- data/docs/rds-private-networking.md +12 -12
- data/docs/releasing.md +15 -4
- data/docs/secrets-and-env-values.md +42 -0
- data/docs/sidebars.ts +1 -0
- data/examples/controlplane.yml +5 -0
- data/lib/command/ai_github_flow_prompt.rb +2 -2
- data/lib/command/apply_template.rb +36 -4
- data/lib/command/base.rb +56 -3
- data/lib/command/cleanup_stale_apps.rb +28 -4
- data/lib/command/copy_image_from_upstream.rb +3 -0
- data/lib/command/delete.rb +174 -4
- data/lib/command/deploy_image.rb +44 -1
- data/lib/command/generate.rb +247 -3
- data/lib/command/generate_github_actions.rb +43 -13
- data/lib/command/run.rb +224 -34
- data/lib/command/setup_app.rb +92 -10
- data/lib/command/update_github_actions.rb +82 -43
- data/lib/core/config.rb +73 -0
- data/lib/core/controlplane.rb +167 -80
- data/lib/core/controlplane_api.rb +28 -2
- data/lib/core/controlplane_api_direct.rb +17 -0
- data/lib/core/disposable_postgres_helpers.rb +167 -0
- data/lib/core/doctor_service.rb +7 -0
- data/lib/core/github_flow_readiness/checks.rb +2 -1
- data/lib/core/github_flow_readiness_service.rb +4 -0
- data/lib/core/repo_introspection.rb +112 -2
- data/lib/core/shell.rb +35 -10
- data/lib/core/timed_command.rb +111 -0
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +1 -1
- data/lib/generator_templates/.dockerignore +16 -0
- data/lib/generator_templates/Dockerfile +9 -5
- data/lib/generator_templates/entrypoint.sh +2 -0
- data/lib/generator_templates/templates/postgres.yml +9 -0
- data/lib/generator_templates_sqlite/controlplane.yml +2 -1
- data/lib/generator_templates_sqlite/release_script.sh +1 -1
- data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +135 -16
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +125 -46
- data/lib/patches/hash.rb +2 -2
- data/rakelib/create_release.rake +14 -14
- data/script/check_shell_scripts +66 -0
- metadata +18 -17
|
@@ -132,21 +132,30 @@ production org, using production-only secrets and values.
|
|
|
132
132
|
|
|
133
133
|
## Version Locking
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
`__CPFLOW_GITHUB_ACTIONS_REF__`.
|
|
137
|
-
helper
|
|
138
|
-
|
|
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.
|
|
144
|
-
release
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
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 }}
|
|
@@ -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
|
-
|
|
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@
|
|
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@
|
|
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: ./.
|
|
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: ./.
|
|
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: ./.
|
|
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@
|
|
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
|
-
|
|
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 [[ "${
|
|
486
|
-
staging_tag="${
|
|
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 "::
|
|
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: ./.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
74
|
-
.gsub(production_setup_ref_pattern
|
|
75
|
-
.gsub(CPFLOW_CHECKOUT_REF_PATTERN
|
|
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[
|
|
20
|
-
|
|
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@
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|