cpflow 5.2.0 → 6.0.0.rc.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 +26 -2
- data/.agents/bin/README.md +16 -4
- 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/.coderabbit.yaml +13 -0
- data/.github/actions/cpflow-delete-control-plane-app/action.yml +2 -1
- data/.github/actions/cpflow-setup-environment/action.yml +9 -7
- data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
- data/.github/dependabot.yml +10 -0
- data/.github/pull_request_template.md +18 -0
- data/.github/workflows/check_cpln_links.yml +6 -1
- data/.github/workflows/claude-code-review.yml +5 -2
- data/.github/workflows/claude.yml +97 -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 +638 -43
- data/.github/workflows/cpflow-deploy-review-app.yml +665 -36
- data/.github/workflows/cpflow-deploy-staging.yml +16 -10
- data/.github/workflows/cpflow-help-command.yml +3 -3
- data/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
- data/.github/workflows/cpflow-review-app-help.yml +6 -14
- data/.github/workflows/rspec-shared.yml +34 -26
- data/.github/workflows/rspec-specific.yml +10 -2
- data/.github/workflows/rspec.yml +71 -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 +6 -49
- data/CHANGELOG.md +56 -1
- data/CONTRIBUTING.md +48 -3
- data/Gemfile.lock +1 -1
- data/README.md +6 -1
- data/cpflow.gemspec +3 -15
- data/docs/ai-github-flow-prompt.md +2 -2
- data/docs/ci-automation.md +216 -99
- data/docs/commands.md +36 -9
- data/docs/rds-private-networking.md +12 -12
- data/docs/releasing.md +15 -4
- data/docs/secrets-and-env-values.md +8 -0
- data/docs/tips.md +18 -0
- data/examples/controlplane.yml +5 -0
- data/lib/command/ai_github_flow_prompt.rb +1 -1
- data/lib/command/apply_template.rb +104 -2
- data/lib/command/base.rb +52 -3
- data/lib/command/cleanup_stale_apps.rb +28 -4
- data/lib/command/copy_image_from_upstream.rb +3 -0
- data/lib/command/deploy_image.rb +54 -3
- data/lib/command/generate_github_actions.rb +36 -12
- data/lib/command/run.rb +357 -44
- data/lib/command/setup_app.rb +10 -5
- data/lib/command/update_github_actions.rb +7 -6
- data/lib/core/controlplane.rb +179 -79
- data/lib/core/controlplane_api.rb +8 -0
- data/lib/core/controlplane_api_direct.rb +257 -63
- data/lib/core/shell.rb +39 -7
- data/lib/core/timed_command.rb +111 -0
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +1 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +24 -10
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +10 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +9 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +7 -7
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +63 -3
- data/lib/patches/hash.rb +2 -2
- data/rakelib/create_release.rake +14 -14
- data/script/check_shell_scripts +66 -0
- metadata +12 -18
|
@@ -4,6 +4,10 @@ run-name: "Deploy Review App - PR #${{ github.event.pull_request.number || githu
|
|
|
4
4
|
|
|
5
5
|
on:
|
|
6
6
|
workflow_call:
|
|
7
|
+
outputs:
|
|
8
|
+
image_built:
|
|
9
|
+
description: Whether this run successfully built the application image.
|
|
10
|
+
value: ${{ jobs.deploy.outputs.image_built }}
|
|
7
11
|
secrets:
|
|
8
12
|
CPLN_TOKEN_STAGING:
|
|
9
13
|
description: Control Plane token for the staging org that owns review apps.
|
|
@@ -13,44 +17,580 @@ on:
|
|
|
13
17
|
required: false
|
|
14
18
|
|
|
15
19
|
permissions:
|
|
20
|
+
actions: write
|
|
16
21
|
contents: read
|
|
17
22
|
deployments: write
|
|
18
23
|
issues: write
|
|
19
24
|
pull-requests: write
|
|
20
25
|
|
|
21
|
-
concurrency:
|
|
22
|
-
group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
23
|
-
# Match the delete workflow: a cancelled `cpflow deploy-image` mid-rollout can leave the
|
|
24
|
-
# review app in a partially-deployed state (workload update in progress, rollout not
|
|
25
|
-
# settled). Let an in-flight deploy finish before the next push starts a new run.
|
|
26
|
-
cancel-in-progress: false
|
|
27
|
-
|
|
28
26
|
env:
|
|
29
27
|
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
30
28
|
PRIMARY_WORKLOAD: ${{ vars.PRIMARY_WORKLOAD }}
|
|
31
29
|
|
|
32
30
|
jobs:
|
|
31
|
+
authorize-comment-command:
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
timeout-minutes: 5
|
|
34
|
+
permissions:
|
|
35
|
+
actions: read
|
|
36
|
+
# PR issue comments use the Issues API path but require PR write access.
|
|
37
|
+
pull-requests: write
|
|
38
|
+
outputs:
|
|
39
|
+
allowed: ${{ steps.record.outputs.accepted || steps.intent.outputs.reuse }}
|
|
40
|
+
steps:
|
|
41
|
+
- name: Resolve comment author repository permission
|
|
42
|
+
if: github.event_name == 'issue_comment'
|
|
43
|
+
id: permission
|
|
44
|
+
shell: bash
|
|
45
|
+
env:
|
|
46
|
+
GH_TOKEN: ${{ github.token }}
|
|
47
|
+
GH_REPO: ${{ github.repository }}
|
|
48
|
+
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
|
49
|
+
run: |
|
|
50
|
+
set -euo pipefail
|
|
51
|
+
|
|
52
|
+
printf '%s\n' 'allowed=false' >> "$GITHUB_OUTPUT"
|
|
53
|
+
permission="$(gh api --method GET "repos/${GH_REPO}/collaborators/${COMMENT_AUTHOR}/permission" --jq '.permission')"
|
|
54
|
+
|
|
55
|
+
if [[ -z "${permission}" ]]; then
|
|
56
|
+
echo "GitHub returned an empty repository permission." >&2
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
case "${permission}" in
|
|
61
|
+
write|maintain|admin)
|
|
62
|
+
printf '%s\n' 'allowed=true' >> "$GITHUB_OUTPUT"
|
|
63
|
+
;;
|
|
64
|
+
read|triage|none)
|
|
65
|
+
echo "Comment author repository permission does not allow review-app commands: ${permission}"
|
|
66
|
+
;;
|
|
67
|
+
*)
|
|
68
|
+
echo "Unexpected repository permission: ${permission}" >&2
|
|
69
|
+
exit 1
|
|
70
|
+
;;
|
|
71
|
+
esac
|
|
72
|
+
|
|
73
|
+
- name: Allow authenticated non-comment trigger
|
|
74
|
+
if: github.event_name != 'issue_comment'
|
|
75
|
+
id: non-comment
|
|
76
|
+
shell: bash
|
|
77
|
+
run: echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
78
|
+
|
|
79
|
+
# Persist a durable accepted-event marker before queue admission. The
|
|
80
|
+
# original comment may later be edited/deleted, and authorization jobs can
|
|
81
|
+
# finish out of event order, so the marker carries the workflow run's own
|
|
82
|
+
# creation time and id rather than relying on marker creation order.
|
|
83
|
+
- name: Prepare accepted review app intent
|
|
84
|
+
id: intent
|
|
85
|
+
if: |
|
|
86
|
+
(steps.permission.outputs.allowed == 'true' || steps.non-comment.outputs.allowed == 'true') &&
|
|
87
|
+
((github.event_name == 'issue_comment' &&
|
|
88
|
+
github.event.issue.pull_request &&
|
|
89
|
+
contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body)) ||
|
|
90
|
+
(github.event_name == 'pull_request' &&
|
|
91
|
+
contains(fromJson('["opened","synchronize","reopened"]'), github.event.action)) ||
|
|
92
|
+
github.event_name == 'workflow_dispatch')
|
|
93
|
+
shell: bash
|
|
94
|
+
env:
|
|
95
|
+
GH_TOKEN: ${{ github.token }}
|
|
96
|
+
GH_REPO: ${{ github.repository }}
|
|
97
|
+
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
98
|
+
OPERATION: deploy
|
|
99
|
+
EVENT_NAME: ${{ github.event_name }}
|
|
100
|
+
SOURCE_ACTOR: ${{ github.event.comment.user.login || github.actor }}
|
|
101
|
+
RUN_ID: ${{ github.run_id }}
|
|
102
|
+
RECONCILE_INTENT_RUN_ID: ${{ github.event.inputs.reconcile_intent_run_id }}
|
|
103
|
+
run: |
|
|
104
|
+
set -euo pipefail
|
|
105
|
+
|
|
106
|
+
printf '%s\n' 'record=false' 'reuse=false' >> "$GITHUB_OUTPUT"
|
|
107
|
+
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ || ! "${RUN_ID}" =~ ^[0-9]+$ ]]; then
|
|
108
|
+
echo "Invalid pull request or workflow run id while recording review-app intent." >&2
|
|
109
|
+
exit 1
|
|
110
|
+
fi
|
|
111
|
+
case "${OPERATION}" in
|
|
112
|
+
deploy|delete) ;;
|
|
113
|
+
*) echo "Invalid review-app operation: ${OPERATION}" >&2; exit 1 ;;
|
|
114
|
+
esac
|
|
115
|
+
case "${EVENT_NAME}" in
|
|
116
|
+
issue_comment|workflow_dispatch|pull_request|pull_request_target) ;;
|
|
117
|
+
*) echo "Invalid review-app event: ${EVENT_NAME}" >&2; exit 1 ;;
|
|
118
|
+
esac
|
|
119
|
+
if [[ -z "${SOURCE_ACTOR}" ]]; then
|
|
120
|
+
echo "Review-app intent is missing its source actor." >&2
|
|
121
|
+
exit 1
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
pull_request_json="$(
|
|
125
|
+
gh api --method GET "repos/${GH_REPO}/pulls/${PR_NUMBER}"
|
|
126
|
+
)"
|
|
127
|
+
head_repository="$(jq -r '.head.repo.full_name // empty' <<< "${pull_request_json}")"
|
|
128
|
+
pull_request_state="$(jq -r '.state // empty' <<< "${pull_request_json}")"
|
|
129
|
+
case "${pull_request_state}" in
|
|
130
|
+
open) ;;
|
|
131
|
+
closed)
|
|
132
|
+
echo "Refusing review-app deploy intent because PR #${PR_NUMBER} is closed."
|
|
133
|
+
exit 0
|
|
134
|
+
;;
|
|
135
|
+
"")
|
|
136
|
+
echo "GitHub returned an empty pull request state while preparing review-app deploy intent." >&2
|
|
137
|
+
exit 1
|
|
138
|
+
;;
|
|
139
|
+
*)
|
|
140
|
+
echo "Unexpected pull request state while preparing review-app deploy intent: ${pull_request_state}" >&2
|
|
141
|
+
exit 1
|
|
142
|
+
;;
|
|
143
|
+
esac
|
|
144
|
+
if [[ -z "${head_repository}" ]]; then
|
|
145
|
+
echo "GitHub returned an empty head repository while preparing review-app deploy intent." >&2
|
|
146
|
+
exit 1
|
|
147
|
+
fi
|
|
148
|
+
if [[ "${head_repository}" != "${GH_REPO}" ]]; then
|
|
149
|
+
echo "Review app deploys are skipped for fork pull requests."
|
|
150
|
+
exit 0
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
if [[ -n "${RECONCILE_INTENT_RUN_ID}" ]]; then
|
|
154
|
+
if [[ ! "${RECONCILE_INTENT_RUN_ID}" =~ ^[0-9]+$ ]]; then
|
|
155
|
+
echo "Invalid internal review-app sequencing token." >&2
|
|
156
|
+
exit 1
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
handoff=""
|
|
160
|
+
for _attempt in {1..10}; do
|
|
161
|
+
comments_json="$(
|
|
162
|
+
gh api --paginate --slurp --method GET \
|
|
163
|
+
"repos/${GH_REPO}/issues/${PR_NUMBER}/comments?per_page=100"
|
|
164
|
+
)"
|
|
165
|
+
handoff="$(
|
|
166
|
+
jq -c \
|
|
167
|
+
--argjson pr "${PR_NUMBER}" \
|
|
168
|
+
--arg operation "${OPERATION}" \
|
|
169
|
+
--argjson intent_run_id "${RECONCILE_INTENT_RUN_ID}" \
|
|
170
|
+
--argjson dispatch_run_id "${RUN_ID}" '
|
|
171
|
+
[
|
|
172
|
+
.[][]?
|
|
173
|
+
| select(.user.login == "github-actions[bot]")
|
|
174
|
+
| .body
|
|
175
|
+
| select(type == "string")
|
|
176
|
+
| select(startswith("<!-- cpflow-review-app-redispatch-v1 ") and endswith(" -->"))
|
|
177
|
+
| ltrimstr("<!-- cpflow-review-app-redispatch-v1 ")
|
|
178
|
+
| rtrimstr(" -->")
|
|
179
|
+
| fromjson?
|
|
180
|
+
| select(
|
|
181
|
+
.pr == $pr and
|
|
182
|
+
.operation == $operation and
|
|
183
|
+
.intent_run_id == $intent_run_id and
|
|
184
|
+
.dispatch_run_id == $dispatch_run_id
|
|
185
|
+
)
|
|
186
|
+
| select(
|
|
187
|
+
(.source_run_id | type) == "number" and
|
|
188
|
+
.source_run_id > 0 and
|
|
189
|
+
(.source_run_id | floor) == .source_run_id
|
|
190
|
+
)
|
|
191
|
+
| select(
|
|
192
|
+
(.created_at | type) == "string" and
|
|
193
|
+
(.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))
|
|
194
|
+
)
|
|
195
|
+
]
|
|
196
|
+
| if length == 1 then .[0] else empty end
|
|
197
|
+
' <<< "${comments_json}"
|
|
198
|
+
)"
|
|
199
|
+
[[ -n "${handoff}" ]] && break
|
|
200
|
+
sleep 1
|
|
201
|
+
done
|
|
202
|
+
if [[ -z "${handoff}" ]]; then
|
|
203
|
+
echo "Internal review-app sequencing token is not bound to this workflow run." >&2
|
|
204
|
+
exit 1
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
source_run_id="$(jq -r '.source_run_id' <<< "${handoff}")"
|
|
208
|
+
source_created_at="$(jq -r '.created_at' <<< "${handoff}")"
|
|
209
|
+
case "${OPERATION}" in
|
|
210
|
+
deploy)
|
|
211
|
+
source_operation="delete"
|
|
212
|
+
expected_run_name="Delete Review App - PR #${PR_NUMBER}"
|
|
213
|
+
expected_job_name="delete-review-app / delete-review-app"
|
|
214
|
+
;;
|
|
215
|
+
delete)
|
|
216
|
+
source_operation="deploy"
|
|
217
|
+
expected_run_name="Deploy Review App - PR #${PR_NUMBER}"
|
|
218
|
+
expected_job_name="deploy / deploy"
|
|
219
|
+
;;
|
|
220
|
+
esac
|
|
221
|
+
expected_workflow_path=".github/workflows/cpflow-${source_operation}-review-app.yml"
|
|
222
|
+
source_run_json="$(
|
|
223
|
+
gh api --method GET "repos/${GH_REPO}/actions/runs/${source_run_id}"
|
|
224
|
+
)"
|
|
225
|
+
if ! jq -e \
|
|
226
|
+
--argjson run_id "${source_run_id}" \
|
|
227
|
+
--arg created_at "${source_created_at}" \
|
|
228
|
+
--arg path "${expected_workflow_path}" \
|
|
229
|
+
--arg title "${expected_run_name}" \
|
|
230
|
+
'(.id == $run_id) and
|
|
231
|
+
(.created_at == $created_at) and
|
|
232
|
+
(.path == $path) and
|
|
233
|
+
(.display_title == $title)' \
|
|
234
|
+
<<< "${source_run_json}" >/dev/null
|
|
235
|
+
then
|
|
236
|
+
echo "Internal review-app handoff does not match its source workflow run." >&2
|
|
237
|
+
exit 1
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
handoff_verified=false
|
|
241
|
+
for _attempt in {1..10}; do
|
|
242
|
+
source_jobs_json="$(
|
|
243
|
+
gh api --paginate --slurp --method GET \
|
|
244
|
+
"repos/${GH_REPO}/actions/runs/${source_run_id}/jobs?filter=all&per_page=100"
|
|
245
|
+
)"
|
|
246
|
+
if jq -e --arg job_name "${expected_job_name}" '
|
|
247
|
+
[
|
|
248
|
+
.[]?.jobs[]?
|
|
249
|
+
| select(.name == $job_name)
|
|
250
|
+
| .steps[]?
|
|
251
|
+
| select(.name == "Dispatch latest accepted review app intent" and .conclusion == "success")
|
|
252
|
+
]
|
|
253
|
+
| length > 0
|
|
254
|
+
' <<< "${source_jobs_json}" >/dev/null
|
|
255
|
+
then
|
|
256
|
+
handoff_verified=true
|
|
257
|
+
break
|
|
258
|
+
fi
|
|
259
|
+
sleep 1
|
|
260
|
+
done
|
|
261
|
+
if [[ "${handoff_verified}" != "true" ]]; then
|
|
262
|
+
echo "Internal review-app handoff was not issued by the expected successful redispatch step." >&2
|
|
263
|
+
exit 1
|
|
264
|
+
fi
|
|
265
|
+
|
|
266
|
+
printf '%s\n' 'reuse=true' >> "$GITHUB_OUTPUT"
|
|
267
|
+
exit 0
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
run_created_at="$(
|
|
271
|
+
gh api --method GET "repos/${GH_REPO}/actions/runs/${RUN_ID}" --jq '.created_at'
|
|
272
|
+
)"
|
|
273
|
+
if [[ ! "${run_created_at}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then
|
|
274
|
+
echo "GitHub returned an invalid workflow run creation time." >&2
|
|
275
|
+
exit 1
|
|
276
|
+
fi
|
|
277
|
+
|
|
278
|
+
intent_json="$(
|
|
279
|
+
jq -cn \
|
|
280
|
+
--argjson pr "${PR_NUMBER}" \
|
|
281
|
+
--arg operation "${OPERATION}" \
|
|
282
|
+
--arg event "${EVENT_NAME}" \
|
|
283
|
+
--arg actor "${SOURCE_ACTOR}" \
|
|
284
|
+
--argjson run_id "${RUN_ID}" \
|
|
285
|
+
--arg created_at "${run_created_at}" \
|
|
286
|
+
'{pr: $pr, operation: $operation, event: $event, actor: $actor, run_id: $run_id, created_at: $created_at}'
|
|
287
|
+
)"
|
|
288
|
+
intent_body="<!-- cpflow-review-app-intent-v1 ${intent_json} -->"
|
|
289
|
+
printf '%s\n' "body=${intent_body}" 'record=true' >> "$GITHUB_OUTPUT"
|
|
290
|
+
|
|
291
|
+
- name: Record accepted review app intent
|
|
292
|
+
if: steps.intent.outputs.record == 'true'
|
|
293
|
+
id: record
|
|
294
|
+
shell: bash
|
|
295
|
+
env:
|
|
296
|
+
GH_TOKEN: ${{ github.token }}
|
|
297
|
+
GH_REPO: ${{ github.repository }}
|
|
298
|
+
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
299
|
+
INTENT_BODY: ${{ steps.intent.outputs.body }}
|
|
300
|
+
run: |
|
|
301
|
+
set -euo pipefail
|
|
302
|
+
|
|
303
|
+
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]] ||
|
|
304
|
+
[[ ! "${INTENT_BODY}" =~ ^\<\!--\ cpflow-review-app-intent-v1\ \{.*\}\ --\>$ ]]; then
|
|
305
|
+
echo "Invalid prepared review-app intent." >&2
|
|
306
|
+
exit 1
|
|
307
|
+
fi
|
|
308
|
+
gh api --silent --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
|
309
|
+
-f "body=${INTENT_BODY}"
|
|
310
|
+
printf '%s\n' 'accepted=true' >> "$GITHUB_OUTPUT"
|
|
311
|
+
|
|
33
312
|
deploy:
|
|
313
|
+
needs: authorize-comment-command
|
|
34
314
|
# Skip synchronize/opened events from fork PRs at the job level — they cannot access
|
|
35
315
|
# repository secrets anyway, so running any steps just burns billable minutes. Users
|
|
36
|
-
# can still
|
|
37
|
-
#
|
|
316
|
+
# can still request a fork PR deploy via `+review-app-deploy` (gated above by
|
|
317
|
+
# repository permission) or workflow_dispatch, but the source guard below prevents it.
|
|
38
318
|
if: |
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
github.
|
|
44
|
-
|
|
45
|
-
|
|
319
|
+
needs.authorize-comment-command.outputs.allowed == 'true' &&
|
|
320
|
+
((github.event_name == 'pull_request' &&
|
|
321
|
+
github.event.pull_request.head.repo.full_name == github.repository) ||
|
|
322
|
+
github.event_name == 'workflow_dispatch' ||
|
|
323
|
+
(github.event_name == 'issue_comment' &&
|
|
324
|
+
github.event.issue.pull_request &&
|
|
325
|
+
contains(fromJson('["+review-app-deploy","+review-app-deploy\n","+review-app-deploy\r\n"]'), github.event.comment.body)))
|
|
46
326
|
runs-on: ubuntu-latest
|
|
47
327
|
timeout-minutes: 45
|
|
328
|
+
outputs:
|
|
329
|
+
image_built: ${{ steps.build-image.outcome == 'success' }}
|
|
330
|
+
concurrency:
|
|
331
|
+
group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
332
|
+
# Enter the shared deploy/delete queue only after comment authorization. A denied
|
|
333
|
+
# public comment must not displace a legitimate pending review-app operation.
|
|
334
|
+
# Let an in-flight deploy finish because cancellation can leave a partial rollout.
|
|
335
|
+
cancel-in-progress: false
|
|
48
336
|
|
|
49
337
|
steps:
|
|
338
|
+
# GitHub keeps only one pending member of a concurrency group and may replace
|
|
339
|
+
# it without FIFO ordering. Reconcile the newest authenticated accepted intent so
|
|
340
|
+
# whichever job survives applies (or redispatches) the requested final state.
|
|
341
|
+
- name: Reconcile latest accepted review app intent
|
|
342
|
+
id: intent
|
|
343
|
+
shell: bash
|
|
344
|
+
env:
|
|
345
|
+
GH_TOKEN: ${{ github.token }}
|
|
346
|
+
GH_REPO: ${{ github.repository }}
|
|
347
|
+
CURRENT_OPERATION: deploy
|
|
348
|
+
run: |
|
|
349
|
+
set -euo pipefail
|
|
350
|
+
|
|
351
|
+
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then
|
|
352
|
+
echo "Invalid pull request id while reconciling review-app intent." >&2
|
|
353
|
+
exit 1
|
|
354
|
+
fi
|
|
355
|
+
case "${CURRENT_OPERATION}" in
|
|
356
|
+
deploy|delete) ;;
|
|
357
|
+
*) echo "Invalid current review-app operation: ${CURRENT_OPERATION}" >&2; exit 1 ;;
|
|
358
|
+
esac
|
|
359
|
+
|
|
360
|
+
comments_json="$(
|
|
361
|
+
gh api --paginate --slurp --method GET \
|
|
362
|
+
"repos/${GH_REPO}/issues/${PR_NUMBER}/comments?per_page=100"
|
|
363
|
+
)"
|
|
364
|
+
intents_json="$(
|
|
365
|
+
jq -c --argjson pr "${PR_NUMBER}" '
|
|
366
|
+
[
|
|
367
|
+
.[][]?
|
|
368
|
+
| select(.user.login == "github-actions[bot]")
|
|
369
|
+
| .body
|
|
370
|
+
| select(type == "string")
|
|
371
|
+
| select(startswith("<!-- cpflow-review-app-intent-v1 ") and endswith(" -->"))
|
|
372
|
+
| ltrimstr("<!-- cpflow-review-app-intent-v1 ")
|
|
373
|
+
| rtrimstr(" -->")
|
|
374
|
+
| fromjson?
|
|
375
|
+
| select(.pr == $pr)
|
|
376
|
+
| select(
|
|
377
|
+
(.operation == "deploy" and
|
|
378
|
+
(.event == "issue_comment" or
|
|
379
|
+
.event == "workflow_dispatch" or
|
|
380
|
+
.event == "pull_request")) or
|
|
381
|
+
(.operation == "delete" and
|
|
382
|
+
(.event == "issue_comment" or
|
|
383
|
+
.event == "workflow_dispatch" or
|
|
384
|
+
.event == "pull_request_target"))
|
|
385
|
+
)
|
|
386
|
+
| select((.actor | type) == "string" and (.actor | length) > 0)
|
|
387
|
+
| select((.run_id | type) == "number" and .run_id > 0 and (.run_id | floor) == .run_id)
|
|
388
|
+
| select(
|
|
389
|
+
(.created_at | type) == "string" and
|
|
390
|
+
(.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))
|
|
391
|
+
)
|
|
392
|
+
]
|
|
393
|
+
| sort_by([.created_at, .run_id])
|
|
394
|
+
| reverse
|
|
395
|
+
' <<< "${comments_json}"
|
|
396
|
+
)"
|
|
397
|
+
latest_intent="$(jq -c '.[0] // empty' <<< "${intents_json}")"
|
|
398
|
+
if [[ -z "${latest_intent}" ]]; then
|
|
399
|
+
echo "No accepted review-app intent exists for PR #${PR_NUMBER}." >&2
|
|
400
|
+
exit 1
|
|
401
|
+
fi
|
|
402
|
+
|
|
403
|
+
latest_operation="$(jq -r '.operation' <<< "${latest_intent}")"
|
|
404
|
+
latest_event="$(jq -r '.event' <<< "${latest_intent}")"
|
|
405
|
+
latest_actor="$(jq -r '.actor' <<< "${latest_intent}")"
|
|
406
|
+
latest_run_id="$(jq -r '.run_id' <<< "${latest_intent}")"
|
|
407
|
+
latest_created_at="$(jq -r '.created_at' <<< "${latest_intent}")"
|
|
408
|
+
expected_workflow_path=".github/workflows/cpflow-${latest_operation}-review-app.yml"
|
|
409
|
+
case "${latest_operation}" in
|
|
410
|
+
deploy) expected_run_name="Deploy Review App - PR #${PR_NUMBER}" ;;
|
|
411
|
+
delete) expected_run_name="Delete Review App - PR #${PR_NUMBER}" ;;
|
|
412
|
+
esac
|
|
413
|
+
source_run_json="$(
|
|
414
|
+
gh api --method GET "repos/${GH_REPO}/actions/runs/${latest_run_id}"
|
|
415
|
+
)"
|
|
416
|
+
if ! jq -e \
|
|
417
|
+
--argjson run_id "${latest_run_id}" \
|
|
418
|
+
--arg event "${latest_event}" \
|
|
419
|
+
--arg actor "${latest_actor}" \
|
|
420
|
+
--arg created_at "${latest_created_at}" \
|
|
421
|
+
--arg path "${expected_workflow_path}" \
|
|
422
|
+
--arg title "${expected_run_name}" \
|
|
423
|
+
--arg repo "${GH_REPO}" \
|
|
424
|
+
--argjson pr "${PR_NUMBER}" \
|
|
425
|
+
'(.id == $run_id) and
|
|
426
|
+
(.event == $event) and
|
|
427
|
+
(.actor.login == $actor) and
|
|
428
|
+
(.created_at == $created_at) and
|
|
429
|
+
(.path == $path) and
|
|
430
|
+
(.display_title == $title) and
|
|
431
|
+
($event != "pull_request" or
|
|
432
|
+
(([.pull_requests[]?.number] | index($pr)) != null and
|
|
433
|
+
.head_repository.full_name == $repo))' \
|
|
434
|
+
<<< "${source_run_json}" >/dev/null
|
|
435
|
+
then
|
|
436
|
+
echo "Latest review-app intent does not match its originating workflow run." >&2
|
|
437
|
+
exit 1
|
|
438
|
+
fi
|
|
439
|
+
source_jobs_json="$(
|
|
440
|
+
gh api --paginate --slurp --method GET \
|
|
441
|
+
"repos/${GH_REPO}/actions/runs/${latest_run_id}/jobs?filter=all&per_page=100"
|
|
442
|
+
)"
|
|
443
|
+
if ! jq -e '
|
|
444
|
+
[
|
|
445
|
+
.[]?.jobs[]?
|
|
446
|
+
| select(
|
|
447
|
+
.name == "authorize-comment-command" or
|
|
448
|
+
(.name | endswith(" / authorize-comment-command"))
|
|
449
|
+
)
|
|
450
|
+
| .steps[]?
|
|
451
|
+
| select(.name == "Record accepted review app intent" and .conclusion == "success")
|
|
452
|
+
]
|
|
453
|
+
| length > 0
|
|
454
|
+
' <<< "${source_jobs_json}" >/dev/null
|
|
455
|
+
then
|
|
456
|
+
echo "Latest review-app intent was not emitted by a successful recording step." >&2
|
|
457
|
+
exit 1
|
|
458
|
+
fi
|
|
459
|
+
if [[ "${latest_event}" == "issue_comment" || "${latest_event}" == "workflow_dispatch" ]]; then
|
|
460
|
+
permission="$(
|
|
461
|
+
gh api --method GET \
|
|
462
|
+
"repos/${GH_REPO}/collaborators/${latest_actor}/permission" \
|
|
463
|
+
--jq '.permission'
|
|
464
|
+
)"
|
|
465
|
+
case "${permission}" in
|
|
466
|
+
write|maintain|admin) ;;
|
|
467
|
+
read|triage|none)
|
|
468
|
+
echo "Latest review-app intent actor no longer has permission to run it: ${permission}." >&2
|
|
469
|
+
exit 1
|
|
470
|
+
;;
|
|
471
|
+
"")
|
|
472
|
+
echo "GitHub returned an empty permission for the latest review-app intent actor." >&2
|
|
473
|
+
exit 1
|
|
474
|
+
;;
|
|
475
|
+
*)
|
|
476
|
+
echo "Unexpected repository permission for the latest review-app intent actor: ${permission}" >&2
|
|
477
|
+
exit 1
|
|
478
|
+
;;
|
|
479
|
+
esac
|
|
480
|
+
fi
|
|
481
|
+
|
|
482
|
+
if [[ "${latest_operation}" != "${CURRENT_OPERATION}" ]]; then
|
|
483
|
+
printf '%s\n' \
|
|
484
|
+
'redispatch=true' \
|
|
485
|
+
"operation=${latest_operation}" \
|
|
486
|
+
"run_id=${latest_run_id}" >> "$GITHUB_OUTPUT"
|
|
487
|
+
echo "Latest accepted ${latest_operation} intent requires a canonical redispatch."
|
|
488
|
+
exit 0
|
|
489
|
+
fi
|
|
490
|
+
|
|
491
|
+
printf '%s\n' \
|
|
492
|
+
'redispatch=false' \
|
|
493
|
+
"operation=${latest_operation}" \
|
|
494
|
+
"run_id=${latest_run_id}" \
|
|
495
|
+
"event=${latest_event}" >> "$GITHUB_OUTPUT"
|
|
496
|
+
echo "Applying latest accepted ${latest_operation} intent from workflow run ${latest_run_id}."
|
|
497
|
+
|
|
498
|
+
- name: Dispatch latest accepted review app intent
|
|
499
|
+
id: redispatch
|
|
500
|
+
if: steps.intent.outputs.redispatch == 'true'
|
|
501
|
+
shell: bash
|
|
502
|
+
env:
|
|
503
|
+
GH_TOKEN: ${{ github.token }}
|
|
504
|
+
GH_REPO: ${{ github.repository }}
|
|
505
|
+
SOURCE_OPERATION: deploy
|
|
506
|
+
TARGET_OPERATION: ${{ steps.intent.outputs.operation }}
|
|
507
|
+
INTENT_RUN_ID: ${{ steps.intent.outputs.run_id }}
|
|
508
|
+
run: |
|
|
509
|
+
set -euo pipefail
|
|
510
|
+
|
|
511
|
+
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ || ! "${INTENT_RUN_ID}" =~ ^[0-9]+$ ||
|
|
512
|
+
! "${GITHUB_RUN_ID}" =~ ^[0-9]+$ ]]; then
|
|
513
|
+
echo "Invalid run identity while redispatching review-app intent." >&2
|
|
514
|
+
exit 1
|
|
515
|
+
fi
|
|
516
|
+
case "${SOURCE_OPERATION}:${TARGET_OPERATION}" in
|
|
517
|
+
deploy:delete|delete:deploy) ;;
|
|
518
|
+
*) echo "Invalid review-app redispatch operation pair." >&2; exit 1 ;;
|
|
519
|
+
esac
|
|
520
|
+
|
|
521
|
+
default_branch="$(gh api --method GET "repos/${GH_REPO}" --jq '.default_branch')"
|
|
522
|
+
if [[ -z "${default_branch}" ]]; then
|
|
523
|
+
echo "GitHub returned an empty default branch while redispatching review-app intent." >&2
|
|
524
|
+
exit 1
|
|
525
|
+
fi
|
|
526
|
+
workflow_file="cpflow-${TARGET_OPERATION}-review-app.yml"
|
|
527
|
+
dispatch_json="$(
|
|
528
|
+
gh api --method POST \
|
|
529
|
+
-H "X-GitHub-Api-Version: 2026-03-10" \
|
|
530
|
+
"repos/${GH_REPO}/actions/workflows/${workflow_file}/dispatches" \
|
|
531
|
+
-f "ref=${default_branch}" \
|
|
532
|
+
-f "inputs[pr_number]=${PR_NUMBER}" \
|
|
533
|
+
-f "inputs[reconcile_intent_run_id]=${INTENT_RUN_ID}"
|
|
534
|
+
)"
|
|
535
|
+
dispatched_run_id="$(jq -r '.workflow_run_id // empty' <<< "${dispatch_json}")"
|
|
536
|
+
if [[ ! "${dispatched_run_id}" =~ ^[0-9]+$ ]]; then
|
|
537
|
+
echo "GitHub did not return a valid workflow run id for the review-app redispatch." >&2
|
|
538
|
+
exit 1
|
|
539
|
+
fi
|
|
540
|
+
source_created_at="$(
|
|
541
|
+
gh api --method GET "repos/${GH_REPO}/actions/runs/${GITHUB_RUN_ID}" --jq '.created_at'
|
|
542
|
+
)"
|
|
543
|
+
if [[ ! "${source_created_at}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then
|
|
544
|
+
echo "GitHub returned an invalid source run creation time for the review-app redispatch." >&2
|
|
545
|
+
exit 1
|
|
546
|
+
fi
|
|
547
|
+
handoff_json="$(
|
|
548
|
+
jq -cn \
|
|
549
|
+
--argjson pr "${PR_NUMBER}" \
|
|
550
|
+
--arg operation "${TARGET_OPERATION}" \
|
|
551
|
+
--argjson intent_run_id "${INTENT_RUN_ID}" \
|
|
552
|
+
--argjson dispatch_run_id "${dispatched_run_id}" \
|
|
553
|
+
--argjson source_run_id "${GITHUB_RUN_ID}" \
|
|
554
|
+
--arg created_at "${source_created_at}" \
|
|
555
|
+
'{pr: $pr, operation: $operation, intent_run_id: $intent_run_id,
|
|
556
|
+
dispatch_run_id: $dispatch_run_id, source_run_id: $source_run_id,
|
|
557
|
+
created_at: $created_at}'
|
|
558
|
+
)"
|
|
559
|
+
handoff_body="<!-- cpflow-review-app-redispatch-v1 ${handoff_json} -->"
|
|
560
|
+
gh api --silent --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
|
561
|
+
-f "body=${handoff_body}"
|
|
562
|
+
echo "Redispatched ${TARGET_OPERATION} for latest accepted review-app intent run ${INTENT_RUN_ID}."
|
|
563
|
+
|
|
564
|
+
- name: Stop superseded review app operation
|
|
565
|
+
if: steps.intent.outputs.redispatch == 'true'
|
|
566
|
+
shell: bash
|
|
567
|
+
env:
|
|
568
|
+
TARGET_OPERATION: ${{ steps.intent.outputs.operation }}
|
|
569
|
+
INTENT_RUN_ID: ${{ steps.intent.outputs.run_id }}
|
|
570
|
+
run: |
|
|
571
|
+
echo "Stopped superseded operation after redispatching ${TARGET_OPERATION} intent ${INTENT_RUN_ID}." >&2
|
|
572
|
+
exit 1
|
|
573
|
+
|
|
574
|
+
# Re-read live PR state after intent reconciliation so a close-triggered
|
|
575
|
+
# delete that ran first cannot be followed by a stale deploy recreation.
|
|
576
|
+
- name: Verify pull request is still open
|
|
577
|
+
shell: bash
|
|
578
|
+
env:
|
|
579
|
+
GH_TOKEN: ${{ github.token }}
|
|
580
|
+
GH_REPO: ${{ github.repository }}
|
|
581
|
+
run: |
|
|
582
|
+
set -euo pipefail
|
|
583
|
+
|
|
584
|
+
pr_state="$(gh api --method GET "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq '.state')"
|
|
585
|
+
if [[ "${pr_state}" != "open" ]]; then
|
|
586
|
+
echo "Refusing stale review-app deploy because PR #${PR_NUMBER} is ${pr_state:-unknown}." >&2
|
|
587
|
+
exit 1
|
|
588
|
+
fi
|
|
589
|
+
|
|
50
590
|
- name: React to deploy command
|
|
51
591
|
if: github.event_name == 'issue_comment'
|
|
52
592
|
continue-on-error: true
|
|
53
|
-
uses: actions/github-script@
|
|
593
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
54
594
|
with:
|
|
55
595
|
script: |
|
|
56
596
|
try {
|
|
@@ -68,8 +608,15 @@ jobs:
|
|
|
68
608
|
}
|
|
69
609
|
}
|
|
70
610
|
|
|
611
|
+
# No explicit ref: checkout's default resolves to this event's recorded commit
|
|
612
|
+
# (GITHUB_SHA), which is trusted base-repository code and is exempt from checkout v7's fork-PR guard.
|
|
613
|
+
- name: Checkout generated cpflow actions
|
|
614
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
615
|
+
with:
|
|
616
|
+
persist-credentials: false
|
|
617
|
+
|
|
71
618
|
- name: Checkout control-plane-flow actions
|
|
72
|
-
uses: actions/checkout@
|
|
619
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
73
620
|
with:
|
|
74
621
|
repository: ${{ job.workflow_repository }}
|
|
75
622
|
ref: ${{ job.workflow_sha }}
|
|
@@ -78,7 +625,7 @@ jobs:
|
|
|
78
625
|
|
|
79
626
|
- name: Validate required secrets and variables
|
|
80
627
|
id: config
|
|
81
|
-
uses: ./.
|
|
628
|
+
uses: ./.github/actions/cpflow-validate-config
|
|
82
629
|
env:
|
|
83
630
|
CPLN_TOKEN_STAGING: ${{ secrets.CPLN_TOKEN_STAGING }}
|
|
84
631
|
with:
|
|
@@ -151,7 +698,9 @@ jobs:
|
|
|
151
698
|
exit 0
|
|
152
699
|
fi
|
|
153
700
|
|
|
154
|
-
|
|
701
|
+
# pull_request_target is in this workflow's event allowlist, so it needs the same
|
|
702
|
+
# fork skip as pull_request instead of falling through to the workflow_dispatch error.
|
|
703
|
+
if [[ "${EVENT_NAME}" == "pull_request" || "${EVENT_NAME}" == "pull_request_target" ]]; then
|
|
155
704
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
|
156
705
|
{
|
|
157
706
|
echo "Review app deploys are skipped for fork pull requests."
|
|
@@ -174,7 +723,7 @@ jobs:
|
|
|
174
723
|
|
|
175
724
|
- name: Checkout PR commit
|
|
176
725
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
|
|
177
|
-
uses: actions/checkout@
|
|
726
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
178
727
|
with:
|
|
179
728
|
ref: ${{ env.PR_SHA }}
|
|
180
729
|
path: app
|
|
@@ -190,7 +739,7 @@ jobs:
|
|
|
190
739
|
- name: Resolve review app config
|
|
191
740
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
|
|
192
741
|
id: review-config
|
|
193
|
-
uses: ./.
|
|
742
|
+
uses: ./.github/actions/cpflow-resolve-review-config
|
|
194
743
|
with:
|
|
195
744
|
working_directory: app
|
|
196
745
|
configured_cpln_org_staging: ${{ vars.CPLN_ORG_STAGING }}
|
|
@@ -199,7 +748,7 @@ jobs:
|
|
|
199
748
|
|
|
200
749
|
- name: Setup environment
|
|
201
750
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
|
|
202
|
-
uses: ./.
|
|
751
|
+
uses: ./.github/actions/cpflow-setup-environment
|
|
203
752
|
with:
|
|
204
753
|
token: ${{ secrets.CPLN_TOKEN_STAGING }}
|
|
205
754
|
org: ${{ steps.review-config.outputs.cpln_org }}
|
|
@@ -211,7 +760,7 @@ jobs:
|
|
|
211
760
|
- name: Detect release phase support
|
|
212
761
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true'
|
|
213
762
|
id: release-phase
|
|
214
|
-
uses: ./.
|
|
763
|
+
uses: ./.github/actions/cpflow-detect-release-phase
|
|
215
764
|
with:
|
|
216
765
|
app_name: ${{ steps.review-config.outputs.app_name }}
|
|
217
766
|
working_directory: app
|
|
@@ -253,27 +802,40 @@ jobs:
|
|
|
253
802
|
esac
|
|
254
803
|
|
|
255
804
|
- name: Skip auto deploy until a review app is created
|
|
256
|
-
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' &&
|
|
805
|
+
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && steps.intent.outputs.event == 'pull_request'
|
|
257
806
|
shell: bash
|
|
258
807
|
run: |
|
|
259
808
|
{
|
|
809
|
+
echo "## Docker image not built"
|
|
810
|
+
echo
|
|
811
|
+
echo "This successful review-app check did not build or deploy an image."
|
|
812
|
+
echo "Outcome: \`image_built=false\`."
|
|
813
|
+
echo
|
|
260
814
|
echo "Review app ${APP_NAME} does not exist yet."
|
|
261
815
|
echo "Create it with +review-app-deploy as the PR comment body."
|
|
262
816
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
263
817
|
|
|
264
818
|
- name: Setup review app if it does not exist yet
|
|
265
819
|
id: setup-review-app
|
|
266
|
-
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' &&
|
|
820
|
+
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists != 'true' && steps.intent.outputs.event != 'pull_request'
|
|
267
821
|
working-directory: app
|
|
268
822
|
shell: bash
|
|
269
823
|
run: |
|
|
270
824
|
set -euo pipefail
|
|
271
825
|
cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}"
|
|
272
826
|
|
|
827
|
+
- name: Refresh existing review app templates
|
|
828
|
+
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && steps.check-app.outputs.exists == 'true'
|
|
829
|
+
working-directory: app
|
|
830
|
+
shell: bash
|
|
831
|
+
run: |
|
|
832
|
+
set -euo pipefail
|
|
833
|
+
cpflow setup-app -a "${APP_NAME}" --org "${CPLN_ORG}" --refresh-templates
|
|
834
|
+
|
|
273
835
|
- name: Create initial PR comment
|
|
274
836
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
|
|
275
837
|
id: create-comment
|
|
276
|
-
uses: actions/github-script@
|
|
838
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
277
839
|
with:
|
|
278
840
|
script: |
|
|
279
841
|
const body = [
|
|
@@ -292,7 +854,7 @@ jobs:
|
|
|
292
854
|
|
|
293
855
|
- name: Set deployment links
|
|
294
856
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
|
|
295
|
-
uses: actions/github-script@
|
|
857
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
296
858
|
with:
|
|
297
859
|
script: |
|
|
298
860
|
const workflowUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
@@ -305,7 +867,7 @@ jobs:
|
|
|
305
867
|
- name: Initialize GitHub deployment
|
|
306
868
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
|
|
307
869
|
id: init-deployment
|
|
308
|
-
uses: actions/github-script@
|
|
870
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
309
871
|
with:
|
|
310
872
|
script: |
|
|
311
873
|
const deployment = await github.rest.repos.createDeployment({
|
|
@@ -313,6 +875,8 @@ jobs:
|
|
|
313
875
|
repo: context.repo.repo,
|
|
314
876
|
ref: process.env.PR_SHA,
|
|
315
877
|
environment: `review/${process.env.APP_NAME}`,
|
|
878
|
+
transient_environment: true,
|
|
879
|
+
production_environment: false,
|
|
316
880
|
auto_merge: false,
|
|
317
881
|
required_contexts: [], // intentional: review apps deploy regardless of required status checks
|
|
318
882
|
description: `Control Plane review app for PR #${process.env.PR_NUMBER}`
|
|
@@ -330,7 +894,7 @@ jobs:
|
|
|
330
894
|
|
|
331
895
|
- name: Update PR comment with build status
|
|
332
896
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
|
|
333
|
-
uses: actions/github-script@
|
|
897
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
334
898
|
env:
|
|
335
899
|
COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
|
|
336
900
|
with:
|
|
@@ -356,8 +920,9 @@ jobs:
|
|
|
356
920
|
});
|
|
357
921
|
|
|
358
922
|
- name: Build Docker image
|
|
923
|
+
id: build-image
|
|
359
924
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
|
|
360
|
-
uses: ./.
|
|
925
|
+
uses: ./.github/actions/cpflow-build-docker-image
|
|
361
926
|
with:
|
|
362
927
|
app_name: ${{ steps.review-config.outputs.app_name }}
|
|
363
928
|
org: ${{ steps.review-config.outputs.cpln_org }}
|
|
@@ -370,7 +935,7 @@ jobs:
|
|
|
370
935
|
|
|
371
936
|
- name: Update PR comment with deploy status
|
|
372
937
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
|
|
373
|
-
uses: actions/github-script@
|
|
938
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
374
939
|
env:
|
|
375
940
|
COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
|
|
376
941
|
DEPLOYING_ICON_URL: ${{ vars.REVIEW_APP_DEPLOYING_ICON_URL }}
|
|
@@ -442,7 +1007,7 @@ jobs:
|
|
|
442
1007
|
- name: Wait for deployment health
|
|
443
1008
|
if: steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
|
|
444
1009
|
id: health-check
|
|
445
|
-
uses: ./.
|
|
1010
|
+
uses: ./.github/actions/cpflow-wait-for-health
|
|
446
1011
|
with:
|
|
447
1012
|
workload_name: ${{ env.PRIMARY_WORKLOAD || 'rails' }}
|
|
448
1013
|
app_name: ${{ steps.review-config.outputs.app_name }}
|
|
@@ -459,19 +1024,83 @@ jobs:
|
|
|
459
1024
|
id: workload
|
|
460
1025
|
working-directory: app
|
|
461
1026
|
shell: bash
|
|
1027
|
+
env:
|
|
1028
|
+
VERIFIED_WORKLOAD_ENDPOINT: ${{ steps.health-check.outputs.endpoint }}
|
|
462
1029
|
run: |
|
|
463
1030
|
set -euo pipefail
|
|
464
1031
|
workload_name="${PRIMARY_WORKLOAD:-rails}"
|
|
465
1032
|
workload_url="$(cpln workload get "${workload_name}" --gvc "${APP_NAME}" --org "${CPLN_ORG}" -o json | jq -r '.status.endpoint // empty')"
|
|
466
|
-
|
|
1033
|
+
app_url="${VERIFIED_WORKLOAD_ENDPOINT:-${workload_url}}"
|
|
1034
|
+
|
|
1035
|
+
if [[ -n "${workload_url}" ]]; then
|
|
1036
|
+
gvc_stderr="$(mktemp)"
|
|
1037
|
+
if ! gvc_json="$(cpln gvc get "${APP_NAME}" --org "${CPLN_ORG}" -o json 2>"${gvc_stderr}")"; then
|
|
1038
|
+
echo "::warning::Could not read GVC app_domain; falling back to verified workload endpoint."
|
|
1039
|
+
sed 's/^/cpln gvc get: /' "${gvc_stderr}" >&2
|
|
1040
|
+
gvc_json=""
|
|
1041
|
+
fi
|
|
1042
|
+
rm -f "${gvc_stderr}"
|
|
1043
|
+
|
|
1044
|
+
app_domain_template="$(
|
|
1045
|
+
jq -r '.spec.env[]? | select(.name == "app_domain") | .value // empty' <<< "${gvc_json}" | head -n 1
|
|
1046
|
+
)"
|
|
1047
|
+
|
|
1048
|
+
if [[ -n "${app_domain_template}" ]]; then
|
|
1049
|
+
derived_app_url="$(
|
|
1050
|
+
ruby -ruri -e '
|
|
1051
|
+
workload_name, workload_url, app_domain_template, app_name = ARGV
|
|
1052
|
+
begin
|
|
1053
|
+
uri = URI(workload_url)
|
|
1054
|
+
rescue URI::InvalidURIError
|
|
1055
|
+
exit
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
host_prefix = "#{workload_name}-"
|
|
1059
|
+
host_suffix = ".cpln.app"
|
|
1060
|
+
|
|
1061
|
+
unless uri.host&.start_with?(host_prefix) && uri.host&.end_with?(host_suffix)
|
|
1062
|
+
exit
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
raw_cpln_gvc_alias = uri.host.delete_prefix(host_prefix).delete_suffix(host_suffix)
|
|
1066
|
+
cpln_gvc_alias = raw_cpln_gvc_alias.split(".").first
|
|
1067
|
+
if cpln_gvc_alias.to_s.empty?
|
|
1068
|
+
exit
|
|
1069
|
+
end
|
|
1070
|
+
# Avoid a literal command-substitution token here because actionlint flags it inside single-quoted Ruby.
|
|
1071
|
+
cpln_gvc_alias_token = "$" + "(CPLN_GVC_ALIAS)"
|
|
1072
|
+
app_url = app_domain_template
|
|
1073
|
+
.gsub(cpln_gvc_alias_token, cpln_gvc_alias)
|
|
1074
|
+
.gsub("{{APP_NAME}}", app_name)
|
|
1075
|
+
|
|
1076
|
+
begin
|
|
1077
|
+
app_uri = URI(app_url)
|
|
1078
|
+
if app_uri.path.to_s.empty? && app_uri.query.nil? && app_uri.fragment.nil?
|
|
1079
|
+
app_url = "#{app_url}/"
|
|
1080
|
+
end
|
|
1081
|
+
rescue URI::InvalidURIError
|
|
1082
|
+
exit
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
puts app_url
|
|
1086
|
+
' "${workload_name}" "${workload_url}" "${app_domain_template}" "${APP_NAME}"
|
|
1087
|
+
)"
|
|
1088
|
+
app_url="${derived_app_url:-${app_url}}"
|
|
1089
|
+
fi
|
|
1090
|
+
fi
|
|
1091
|
+
|
|
1092
|
+
{
|
|
1093
|
+
echo "workload_url=${workload_url}"
|
|
1094
|
+
echo "app_url=${app_url}"
|
|
1095
|
+
} >> "$GITHUB_OUTPUT"
|
|
467
1096
|
|
|
468
1097
|
- name: Finalize deployment status
|
|
469
1098
|
if: always() && steps.config.outputs.ready == 'true' && steps.source.outputs.allowed == 'true' && (steps.check-app.outputs.exists == 'true' || steps.setup-review-app.outcome == 'success')
|
|
470
|
-
uses: actions/github-script@
|
|
1099
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
471
1100
|
env:
|
|
472
1101
|
COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
|
|
473
1102
|
DEPLOYMENT_ID: ${{ steps.init-deployment.outputs.result }}
|
|
474
|
-
APP_URL: ${{ steps.workload.outputs.
|
|
1103
|
+
APP_URL: ${{ steps.workload.outputs.app_url }}
|
|
475
1104
|
JOB_STATUS: ${{ job.status }}
|
|
476
1105
|
with:
|
|
477
1106
|
script: |
|