cpflow 5.1.1 → 5.3.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 -0
- data/.agents/bin/README.md +20 -0
- data/.agents/bin/docs +5 -0
- data/.agents/bin/lint +5 -0
- data/.agents/bin/setup +5 -0
- data/.agents/bin/test +5 -0
- data/.agents/bin/validate +5 -0
- data/.agents/trusted-github-actors.yml +32 -0
- data/.agents/workflows/ai-rollout-e2e-test.md +166 -0
- data/.github/actions/cpflow-setup-environment/action.yml +1 -1
- data/.github/actions/cpflow-wait-for-health/action.yml +87 -15
- data/.github/pull_request_template.md +18 -0
- data/.github/workflows/claude-code-review.yml +2 -0
- data/.github/workflows/claude.yml +94 -1
- data/.github/workflows/cpflow-delete-review-app.yml +621 -33
- data/.github/workflows/cpflow-deploy-review-app.yml +656 -21
- data/.github/workflows/cpflow-review-app-help.yml +5 -13
- data/.github/workflows/rspec-shared.yml +10 -3
- data/.github/workflows/rspec-specific.yml +1 -0
- data/.github/workflows/rspec.yml +58 -1
- data/AGENTS.md +14 -0
- data/CHANGELOG.md +54 -1
- data/CLAUDE.md +3 -0
- data/CONTRIBUTING.md +15 -3
- data/Gemfile.lock +1 -1
- data/README.md +21 -7
- data/docs/ai-github-flow-prompt.md +18 -16
- data/docs/ci-automation.md +239 -27
- data/docs/commands.md +30 -2
- data/docs/grafana-opentelemetry.md +699 -0
- data/docs/secrets-and-env-values.md +37 -2
- data/docs/sidebars.ts +70 -0
- data/docs/telemetry/application-instrumentation.md +161 -0
- data/docs/telemetry/collector.md +297 -0
- data/docs/telemetry/index.md +152 -0
- data/docs/telemetry/pipelines.md +98 -0
- data/docs/telemetry/review-apps.md +55 -0
- data/docs/telemetry/troubleshooting.md +92 -0
- data/docs/terraform/example/.controlplane/controlplane.yml +0 -1
- data/docs/terraform/overview.md +11 -0
- data/docs/tips.md +475 -28
- data/examples/controlplane.yml +2 -0
- data/lib/command/ai_github_flow_prompt.rb +2 -2
- data/lib/command/apply_template.rb +104 -2
- data/lib/command/base.rb +69 -5
- data/lib/command/deploy_image.rb +93 -7
- data/lib/command/promote_app_from_upstream.rb +1 -0
- data/lib/command/ps_wait.rb +2 -10
- data/lib/command/run.rb +133 -10
- data/lib/command/setup_app.rb +10 -5
- data/lib/core/config.rb +94 -0
- data/lib/core/controlplane.rb +38 -5
- data/lib/core/controlplane_api.rb +8 -0
- data/lib/core/controlplane_api_direct.rb +257 -63
- data/lib/core/doctor_service.rb +44 -3
- data/lib/core/shell.rb +9 -2
- data/lib/core/template_parser.rb +43 -9
- data/lib/cpflow/version.rb +1 -1
- data/lib/generator_templates/controlplane.yml +1 -2
- data/lib/github_flow_templates/.github/cpflow-help.md +34 -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
- metadata +22 -2
|
@@ -8,32 +8,540 @@ on:
|
|
|
8
8
|
required: true
|
|
9
9
|
|
|
10
10
|
permissions:
|
|
11
|
+
actions: write
|
|
11
12
|
contents: read
|
|
13
|
+
deployments: write
|
|
12
14
|
issues: write
|
|
13
15
|
pull-requests: write
|
|
14
16
|
|
|
15
|
-
concurrency:
|
|
16
|
-
group: cpflow-delete-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
17
|
-
# Deletions must not cancel each other mid-flight — a cancelled `cpln` delete can leave
|
|
18
|
-
# partial state behind. Let the in-progress deletion finish before the next run starts.
|
|
19
|
-
cancel-in-progress: false
|
|
20
|
-
|
|
21
17
|
env:
|
|
22
18
|
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
23
19
|
|
|
24
20
|
jobs:
|
|
21
|
+
authorize-comment-command:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
timeout-minutes: 5
|
|
24
|
+
permissions:
|
|
25
|
+
actions: read
|
|
26
|
+
# PR issue comments use the Issues API path but require PR write access.
|
|
27
|
+
pull-requests: write
|
|
28
|
+
outputs:
|
|
29
|
+
allowed: ${{ steps.record.outputs.accepted || steps.intent.outputs.reuse }}
|
|
30
|
+
steps:
|
|
31
|
+
- name: Resolve comment author repository permission
|
|
32
|
+
if: github.event_name == 'issue_comment'
|
|
33
|
+
id: permission
|
|
34
|
+
shell: bash
|
|
35
|
+
env:
|
|
36
|
+
GH_TOKEN: ${{ github.token }}
|
|
37
|
+
GH_REPO: ${{ github.repository }}
|
|
38
|
+
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
|
39
|
+
run: |
|
|
40
|
+
set -euo pipefail
|
|
41
|
+
|
|
42
|
+
printf '%s\n' 'allowed=false' >> "$GITHUB_OUTPUT"
|
|
43
|
+
permission="$(gh api --method GET "repos/${GH_REPO}/collaborators/${COMMENT_AUTHOR}/permission" --jq '.permission')"
|
|
44
|
+
|
|
45
|
+
if [[ -z "${permission}" ]]; then
|
|
46
|
+
echo "GitHub returned an empty repository permission." >&2
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
case "${permission}" in
|
|
51
|
+
write|maintain|admin)
|
|
52
|
+
printf '%s\n' 'allowed=true' >> "$GITHUB_OUTPUT"
|
|
53
|
+
;;
|
|
54
|
+
read|triage|none)
|
|
55
|
+
echo "Comment author repository permission does not allow review-app commands: ${permission}"
|
|
56
|
+
;;
|
|
57
|
+
*)
|
|
58
|
+
echo "Unexpected repository permission: ${permission}" >&2
|
|
59
|
+
exit 1
|
|
60
|
+
;;
|
|
61
|
+
esac
|
|
62
|
+
|
|
63
|
+
- name: Allow authenticated non-comment trigger
|
|
64
|
+
if: github.event_name != 'issue_comment'
|
|
65
|
+
id: non-comment
|
|
66
|
+
shell: bash
|
|
67
|
+
run: echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
68
|
+
|
|
69
|
+
# Persist a durable accepted-event marker before queue admission. The
|
|
70
|
+
# original comment may later be edited/deleted, and authorization jobs can
|
|
71
|
+
# finish out of event order, so the marker carries the workflow run's own
|
|
72
|
+
# creation time and id rather than relying on marker creation order.
|
|
73
|
+
- name: Prepare accepted review app intent
|
|
74
|
+
id: intent
|
|
75
|
+
if: |
|
|
76
|
+
(steps.permission.outputs.allowed == 'true' || steps.non-comment.outputs.allowed == 'true') &&
|
|
77
|
+
((github.event_name == 'issue_comment' &&
|
|
78
|
+
github.event.issue.pull_request &&
|
|
79
|
+
contains(fromJson('["+review-app-delete","+review-app-delete\n","+review-app-delete\r\n"]'), github.event.comment.body)) ||
|
|
80
|
+
(github.event_name == 'pull_request_target' && github.event.action == 'closed') ||
|
|
81
|
+
github.event_name == 'workflow_dispatch')
|
|
82
|
+
shell: bash
|
|
83
|
+
env:
|
|
84
|
+
GH_TOKEN: ${{ github.token }}
|
|
85
|
+
GH_REPO: ${{ github.repository }}
|
|
86
|
+
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
87
|
+
OPERATION: delete
|
|
88
|
+
EVENT_NAME: ${{ github.event_name }}
|
|
89
|
+
SOURCE_ACTOR: ${{ github.event.comment.user.login || github.actor }}
|
|
90
|
+
RUN_ID: ${{ github.run_id }}
|
|
91
|
+
RECONCILE_INTENT_RUN_ID: ${{ github.event.inputs.reconcile_intent_run_id }}
|
|
92
|
+
run: |
|
|
93
|
+
set -euo pipefail
|
|
94
|
+
|
|
95
|
+
printf '%s\n' 'record=false' 'reuse=false' >> "$GITHUB_OUTPUT"
|
|
96
|
+
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ || ! "${RUN_ID}" =~ ^[0-9]+$ ]]; then
|
|
97
|
+
echo "Invalid pull request or workflow run id while recording review-app intent." >&2
|
|
98
|
+
exit 1
|
|
99
|
+
fi
|
|
100
|
+
case "${OPERATION}" in
|
|
101
|
+
deploy|delete) ;;
|
|
102
|
+
*) echo "Invalid review-app operation: ${OPERATION}" >&2; exit 1 ;;
|
|
103
|
+
esac
|
|
104
|
+
case "${EVENT_NAME}" in
|
|
105
|
+
issue_comment|workflow_dispatch|pull_request|pull_request_target) ;;
|
|
106
|
+
*) echo "Invalid review-app event: ${EVENT_NAME}" >&2; exit 1 ;;
|
|
107
|
+
esac
|
|
108
|
+
if [[ -z "${SOURCE_ACTOR}" ]]; then
|
|
109
|
+
echo "Review-app intent is missing its source actor." >&2
|
|
110
|
+
exit 1
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
if [[ -n "${RECONCILE_INTENT_RUN_ID}" ]]; then
|
|
114
|
+
if [[ ! "${RECONCILE_INTENT_RUN_ID}" =~ ^[0-9]+$ ]]; then
|
|
115
|
+
echo "Invalid internal review-app sequencing token." >&2
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
handoff=""
|
|
120
|
+
for _attempt in {1..10}; do
|
|
121
|
+
comments_json="$(
|
|
122
|
+
gh api --paginate --slurp --method GET \
|
|
123
|
+
"repos/${GH_REPO}/issues/${PR_NUMBER}/comments?per_page=100"
|
|
124
|
+
)"
|
|
125
|
+
handoff="$(
|
|
126
|
+
jq -c \
|
|
127
|
+
--argjson pr "${PR_NUMBER}" \
|
|
128
|
+
--arg operation "${OPERATION}" \
|
|
129
|
+
--argjson intent_run_id "${RECONCILE_INTENT_RUN_ID}" \
|
|
130
|
+
--argjson dispatch_run_id "${RUN_ID}" '
|
|
131
|
+
[
|
|
132
|
+
.[][]?
|
|
133
|
+
| select(.user.login == "github-actions[bot]")
|
|
134
|
+
| .body
|
|
135
|
+
| select(type == "string")
|
|
136
|
+
| select(startswith("<!-- cpflow-review-app-redispatch-v1 ") and endswith(" -->"))
|
|
137
|
+
| ltrimstr("<!-- cpflow-review-app-redispatch-v1 ")
|
|
138
|
+
| rtrimstr(" -->")
|
|
139
|
+
| fromjson?
|
|
140
|
+
| select(
|
|
141
|
+
.pr == $pr and
|
|
142
|
+
.operation == $operation and
|
|
143
|
+
.intent_run_id == $intent_run_id and
|
|
144
|
+
.dispatch_run_id == $dispatch_run_id
|
|
145
|
+
)
|
|
146
|
+
| select(
|
|
147
|
+
(.source_run_id | type) == "number" and
|
|
148
|
+
.source_run_id > 0 and
|
|
149
|
+
(.source_run_id | floor) == .source_run_id
|
|
150
|
+
)
|
|
151
|
+
| select(
|
|
152
|
+
(.created_at | type) == "string" and
|
|
153
|
+
(.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))
|
|
154
|
+
)
|
|
155
|
+
]
|
|
156
|
+
| if length == 1 then .[0] else empty end
|
|
157
|
+
' <<< "${comments_json}"
|
|
158
|
+
)"
|
|
159
|
+
[[ -n "${handoff}" ]] && break
|
|
160
|
+
sleep 1
|
|
161
|
+
done
|
|
162
|
+
if [[ -z "${handoff}" ]]; then
|
|
163
|
+
echo "Internal review-app sequencing token is not bound to this workflow run." >&2
|
|
164
|
+
exit 1
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
source_run_id="$(jq -r '.source_run_id' <<< "${handoff}")"
|
|
168
|
+
source_created_at="$(jq -r '.created_at' <<< "${handoff}")"
|
|
169
|
+
case "${OPERATION}" in
|
|
170
|
+
deploy)
|
|
171
|
+
source_operation="delete"
|
|
172
|
+
expected_run_name="Delete Review App - PR #${PR_NUMBER}"
|
|
173
|
+
expected_job_name="delete-review-app / delete-review-app"
|
|
174
|
+
;;
|
|
175
|
+
delete)
|
|
176
|
+
source_operation="deploy"
|
|
177
|
+
expected_run_name="Deploy Review App - PR #${PR_NUMBER}"
|
|
178
|
+
expected_job_name="deploy / deploy"
|
|
179
|
+
;;
|
|
180
|
+
esac
|
|
181
|
+
expected_workflow_path=".github/workflows/cpflow-${source_operation}-review-app.yml"
|
|
182
|
+
source_run_json="$(
|
|
183
|
+
gh api --method GET "repos/${GH_REPO}/actions/runs/${source_run_id}"
|
|
184
|
+
)"
|
|
185
|
+
if ! jq -e \
|
|
186
|
+
--argjson run_id "${source_run_id}" \
|
|
187
|
+
--arg created_at "${source_created_at}" \
|
|
188
|
+
--arg path "${expected_workflow_path}" \
|
|
189
|
+
--arg title "${expected_run_name}" \
|
|
190
|
+
'(.id == $run_id) and
|
|
191
|
+
(.created_at == $created_at) and
|
|
192
|
+
(.path == $path) and
|
|
193
|
+
(.display_title == $title)' \
|
|
194
|
+
<<< "${source_run_json}" >/dev/null
|
|
195
|
+
then
|
|
196
|
+
echo "Internal review-app handoff does not match its source workflow run." >&2
|
|
197
|
+
exit 1
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
handoff_verified=false
|
|
201
|
+
for _attempt in {1..10}; do
|
|
202
|
+
source_jobs_json="$(
|
|
203
|
+
gh api --paginate --slurp --method GET \
|
|
204
|
+
"repos/${GH_REPO}/actions/runs/${source_run_id}/jobs?filter=all&per_page=100"
|
|
205
|
+
)"
|
|
206
|
+
if jq -e --arg job_name "${expected_job_name}" '
|
|
207
|
+
[
|
|
208
|
+
.[]?.jobs[]?
|
|
209
|
+
| select(.name == $job_name)
|
|
210
|
+
| .steps[]?
|
|
211
|
+
| select(.name == "Dispatch latest accepted review app intent" and .conclusion == "success")
|
|
212
|
+
]
|
|
213
|
+
| length > 0
|
|
214
|
+
' <<< "${source_jobs_json}" >/dev/null
|
|
215
|
+
then
|
|
216
|
+
handoff_verified=true
|
|
217
|
+
break
|
|
218
|
+
fi
|
|
219
|
+
sleep 1
|
|
220
|
+
done
|
|
221
|
+
if [[ "${handoff_verified}" != "true" ]]; then
|
|
222
|
+
echo "Internal review-app handoff was not issued by the expected successful redispatch step." >&2
|
|
223
|
+
exit 1
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
printf '%s\n' 'reuse=true' >> "$GITHUB_OUTPUT"
|
|
227
|
+
exit 0
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
run_created_at="$(
|
|
231
|
+
gh api --method GET "repos/${GH_REPO}/actions/runs/${RUN_ID}" --jq '.created_at'
|
|
232
|
+
)"
|
|
233
|
+
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
|
|
234
|
+
echo "GitHub returned an invalid workflow run creation time." >&2
|
|
235
|
+
exit 1
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
intent_json="$(
|
|
239
|
+
jq -cn \
|
|
240
|
+
--argjson pr "${PR_NUMBER}" \
|
|
241
|
+
--arg operation "${OPERATION}" \
|
|
242
|
+
--arg event "${EVENT_NAME}" \
|
|
243
|
+
--arg actor "${SOURCE_ACTOR}" \
|
|
244
|
+
--argjson run_id "${RUN_ID}" \
|
|
245
|
+
--arg created_at "${run_created_at}" \
|
|
246
|
+
'{pr: $pr, operation: $operation, event: $event, actor: $actor, run_id: $run_id, created_at: $created_at}'
|
|
247
|
+
)"
|
|
248
|
+
intent_body="<!-- cpflow-review-app-intent-v1 ${intent_json} -->"
|
|
249
|
+
printf '%s\n' "body=${intent_body}" 'record=true' >> "$GITHUB_OUTPUT"
|
|
250
|
+
|
|
251
|
+
- name: Record accepted review app intent
|
|
252
|
+
if: steps.intent.outputs.record == 'true'
|
|
253
|
+
id: record
|
|
254
|
+
shell: bash
|
|
255
|
+
env:
|
|
256
|
+
GH_TOKEN: ${{ github.token }}
|
|
257
|
+
GH_REPO: ${{ github.repository }}
|
|
258
|
+
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
259
|
+
INTENT_BODY: ${{ steps.intent.outputs.body }}
|
|
260
|
+
run: |
|
|
261
|
+
set -euo pipefail
|
|
262
|
+
|
|
263
|
+
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]] ||
|
|
264
|
+
[[ ! "${INTENT_BODY}" =~ ^\<\!--\ cpflow-review-app-intent-v1\ \{.*\}\ --\>$ ]]; then
|
|
265
|
+
echo "Invalid prepared review-app intent." >&2
|
|
266
|
+
exit 1
|
|
267
|
+
fi
|
|
268
|
+
gh api --silent --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
|
269
|
+
-f "body=${INTENT_BODY}"
|
|
270
|
+
printf '%s\n' 'accepted=true' >> "$GITHUB_OUTPUT"
|
|
271
|
+
|
|
25
272
|
delete-review-app:
|
|
273
|
+
needs: authorize-comment-command
|
|
26
274
|
if: |
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
275
|
+
needs.authorize-comment-command.outputs.allowed == 'true' &&
|
|
276
|
+
((github.event_name == 'issue_comment' &&
|
|
277
|
+
github.event.issue.pull_request &&
|
|
278
|
+
contains(fromJson('["+review-app-delete","+review-app-delete\n","+review-app-delete\r\n"]'), github.event.comment.body)) ||
|
|
279
|
+
(github.event_name == 'pull_request_target' && github.event.action == 'closed') ||
|
|
280
|
+
github.event_name == 'workflow_dispatch')
|
|
33
281
|
runs-on: ubuntu-latest
|
|
34
282
|
timeout-minutes: 15
|
|
283
|
+
concurrency:
|
|
284
|
+
group: cpflow-review-app-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
|
285
|
+
# Enter the shared deploy/delete queue only after comment authorization. A denied
|
|
286
|
+
# public comment must not displace a legitimate pending review-app operation.
|
|
287
|
+
# Let an in-flight deploy finish before deletion so it cannot publish a later success.
|
|
288
|
+
cancel-in-progress: false
|
|
35
289
|
|
|
36
290
|
steps:
|
|
291
|
+
# GitHub keeps only one pending member of a concurrency group and may replace
|
|
292
|
+
# it without FIFO ordering. Reconcile the newest authenticated accepted intent so
|
|
293
|
+
# whichever job survives applies (or redispatches) the requested final state.
|
|
294
|
+
- name: Reconcile latest accepted review app intent
|
|
295
|
+
id: intent
|
|
296
|
+
shell: bash
|
|
297
|
+
env:
|
|
298
|
+
GH_TOKEN: ${{ github.token }}
|
|
299
|
+
GH_REPO: ${{ github.repository }}
|
|
300
|
+
CURRENT_OPERATION: delete
|
|
301
|
+
run: |
|
|
302
|
+
set -euo pipefail
|
|
303
|
+
|
|
304
|
+
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then
|
|
305
|
+
echo "Invalid pull request id while reconciling review-app intent." >&2
|
|
306
|
+
exit 1
|
|
307
|
+
fi
|
|
308
|
+
case "${CURRENT_OPERATION}" in
|
|
309
|
+
deploy|delete) ;;
|
|
310
|
+
*) echo "Invalid current review-app operation: ${CURRENT_OPERATION}" >&2; exit 1 ;;
|
|
311
|
+
esac
|
|
312
|
+
|
|
313
|
+
comments_json="$(
|
|
314
|
+
gh api --paginate --slurp --method GET \
|
|
315
|
+
"repos/${GH_REPO}/issues/${PR_NUMBER}/comments?per_page=100"
|
|
316
|
+
)"
|
|
317
|
+
intents_json="$(
|
|
318
|
+
jq -c --argjson pr "${PR_NUMBER}" '
|
|
319
|
+
[
|
|
320
|
+
.[][]?
|
|
321
|
+
| select(.user.login == "github-actions[bot]")
|
|
322
|
+
| .body
|
|
323
|
+
| select(type == "string")
|
|
324
|
+
| select(startswith("<!-- cpflow-review-app-intent-v1 ") and endswith(" -->"))
|
|
325
|
+
| ltrimstr("<!-- cpflow-review-app-intent-v1 ")
|
|
326
|
+
| rtrimstr(" -->")
|
|
327
|
+
| fromjson?
|
|
328
|
+
| select(.pr == $pr)
|
|
329
|
+
| select(
|
|
330
|
+
(.operation == "deploy" and
|
|
331
|
+
(.event == "issue_comment" or
|
|
332
|
+
.event == "workflow_dispatch" or
|
|
333
|
+
.event == "pull_request")) or
|
|
334
|
+
(.operation == "delete" and
|
|
335
|
+
(.event == "issue_comment" or
|
|
336
|
+
.event == "workflow_dispatch" or
|
|
337
|
+
.event == "pull_request_target"))
|
|
338
|
+
)
|
|
339
|
+
| select((.actor | type) == "string" and (.actor | length) > 0)
|
|
340
|
+
| select((.run_id | type) == "number" and .run_id > 0 and (.run_id | floor) == .run_id)
|
|
341
|
+
| select(
|
|
342
|
+
(.created_at | type) == "string" and
|
|
343
|
+
(.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))
|
|
344
|
+
)
|
|
345
|
+
]
|
|
346
|
+
| sort_by([.created_at, .run_id])
|
|
347
|
+
| reverse
|
|
348
|
+
' <<< "${comments_json}"
|
|
349
|
+
)"
|
|
350
|
+
latest_intent="$(jq -c '.[0] // empty' <<< "${intents_json}")"
|
|
351
|
+
if [[ -z "${latest_intent}" ]]; then
|
|
352
|
+
echo "No accepted review-app intent exists for PR #${PR_NUMBER}." >&2
|
|
353
|
+
exit 1
|
|
354
|
+
fi
|
|
355
|
+
|
|
356
|
+
latest_operation="$(jq -r '.operation' <<< "${latest_intent}")"
|
|
357
|
+
latest_event="$(jq -r '.event' <<< "${latest_intent}")"
|
|
358
|
+
latest_actor="$(jq -r '.actor' <<< "${latest_intent}")"
|
|
359
|
+
latest_run_id="$(jq -r '.run_id' <<< "${latest_intent}")"
|
|
360
|
+
latest_created_at="$(jq -r '.created_at' <<< "${latest_intent}")"
|
|
361
|
+
expected_workflow_path=".github/workflows/cpflow-${latest_operation}-review-app.yml"
|
|
362
|
+
case "${latest_operation}" in
|
|
363
|
+
deploy) expected_run_name="Deploy Review App - PR #${PR_NUMBER}" ;;
|
|
364
|
+
delete) expected_run_name="Delete Review App - PR #${PR_NUMBER}" ;;
|
|
365
|
+
esac
|
|
366
|
+
source_run_json="$(
|
|
367
|
+
gh api --method GET "repos/${GH_REPO}/actions/runs/${latest_run_id}"
|
|
368
|
+
)"
|
|
369
|
+
if ! jq -e \
|
|
370
|
+
--argjson run_id "${latest_run_id}" \
|
|
371
|
+
--arg event "${latest_event}" \
|
|
372
|
+
--arg actor "${latest_actor}" \
|
|
373
|
+
--arg created_at "${latest_created_at}" \
|
|
374
|
+
--arg path "${expected_workflow_path}" \
|
|
375
|
+
--arg title "${expected_run_name}" \
|
|
376
|
+
--arg repo "${GH_REPO}" \
|
|
377
|
+
--argjson pr "${PR_NUMBER}" \
|
|
378
|
+
'(.id == $run_id) and
|
|
379
|
+
(.event == $event) and
|
|
380
|
+
(.actor.login == $actor) and
|
|
381
|
+
(.created_at == $created_at) and
|
|
382
|
+
(.path == $path) and
|
|
383
|
+
(.display_title == $title) and
|
|
384
|
+
($event != "pull_request" or
|
|
385
|
+
(([.pull_requests[]?.number] | index($pr)) != null and
|
|
386
|
+
.head_repository.full_name == $repo))' \
|
|
387
|
+
<<< "${source_run_json}" >/dev/null
|
|
388
|
+
then
|
|
389
|
+
echo "Latest review-app intent does not match its originating workflow run." >&2
|
|
390
|
+
exit 1
|
|
391
|
+
fi
|
|
392
|
+
source_jobs_json="$(
|
|
393
|
+
gh api --paginate --slurp --method GET \
|
|
394
|
+
"repos/${GH_REPO}/actions/runs/${latest_run_id}/jobs?filter=all&per_page=100"
|
|
395
|
+
)"
|
|
396
|
+
if ! jq -e '
|
|
397
|
+
[
|
|
398
|
+
.[]?.jobs[]?
|
|
399
|
+
| select(
|
|
400
|
+
.name == "authorize-comment-command" or
|
|
401
|
+
(.name | endswith(" / authorize-comment-command"))
|
|
402
|
+
)
|
|
403
|
+
| .steps[]?
|
|
404
|
+
| select(.name == "Record accepted review app intent" and .conclusion == "success")
|
|
405
|
+
]
|
|
406
|
+
| length > 0
|
|
407
|
+
' <<< "${source_jobs_json}" >/dev/null
|
|
408
|
+
then
|
|
409
|
+
echo "Latest review-app intent was not emitted by a successful recording step." >&2
|
|
410
|
+
exit 1
|
|
411
|
+
fi
|
|
412
|
+
if [[ "${latest_event}" == "issue_comment" || "${latest_event}" == "workflow_dispatch" ]]; then
|
|
413
|
+
permission="$(
|
|
414
|
+
gh api --method GET \
|
|
415
|
+
"repos/${GH_REPO}/collaborators/${latest_actor}/permission" \
|
|
416
|
+
--jq '.permission'
|
|
417
|
+
)"
|
|
418
|
+
case "${permission}" in
|
|
419
|
+
write|maintain|admin) ;;
|
|
420
|
+
read|triage|none)
|
|
421
|
+
echo "Latest review-app intent actor no longer has permission to run it: ${permission}." >&2
|
|
422
|
+
exit 1
|
|
423
|
+
;;
|
|
424
|
+
"")
|
|
425
|
+
echo "GitHub returned an empty permission for the latest review-app intent actor." >&2
|
|
426
|
+
exit 1
|
|
427
|
+
;;
|
|
428
|
+
*)
|
|
429
|
+
echo "Unexpected repository permission for the latest review-app intent actor: ${permission}" >&2
|
|
430
|
+
exit 1
|
|
431
|
+
;;
|
|
432
|
+
esac
|
|
433
|
+
fi
|
|
434
|
+
|
|
435
|
+
if [[ "${latest_operation}" != "${CURRENT_OPERATION}" ]]; then
|
|
436
|
+
printf '%s\n' \
|
|
437
|
+
'redispatch=true' \
|
|
438
|
+
"operation=${latest_operation}" \
|
|
439
|
+
"run_id=${latest_run_id}" >> "$GITHUB_OUTPUT"
|
|
440
|
+
echo "Latest accepted ${latest_operation} intent requires a canonical redispatch."
|
|
441
|
+
exit 0
|
|
442
|
+
fi
|
|
443
|
+
|
|
444
|
+
printf '%s\n' \
|
|
445
|
+
'redispatch=false' \
|
|
446
|
+
"operation=${latest_operation}" \
|
|
447
|
+
"run_id=${latest_run_id}" \
|
|
448
|
+
"event=${latest_event}" >> "$GITHUB_OUTPUT"
|
|
449
|
+
echo "Applying latest accepted ${latest_operation} intent from workflow run ${latest_run_id}."
|
|
450
|
+
|
|
451
|
+
- name: Dispatch latest accepted review app intent
|
|
452
|
+
id: redispatch
|
|
453
|
+
if: steps.intent.outputs.redispatch == 'true'
|
|
454
|
+
shell: bash
|
|
455
|
+
env:
|
|
456
|
+
GH_TOKEN: ${{ github.token }}
|
|
457
|
+
GH_REPO: ${{ github.repository }}
|
|
458
|
+
SOURCE_OPERATION: delete
|
|
459
|
+
TARGET_OPERATION: ${{ steps.intent.outputs.operation }}
|
|
460
|
+
INTENT_RUN_ID: ${{ steps.intent.outputs.run_id }}
|
|
461
|
+
run: |
|
|
462
|
+
set -euo pipefail
|
|
463
|
+
|
|
464
|
+
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ || ! "${INTENT_RUN_ID}" =~ ^[0-9]+$ ||
|
|
465
|
+
! "${GITHUB_RUN_ID}" =~ ^[0-9]+$ ]]; then
|
|
466
|
+
echo "Invalid run identity while redispatching review-app intent." >&2
|
|
467
|
+
exit 1
|
|
468
|
+
fi
|
|
469
|
+
case "${SOURCE_OPERATION}:${TARGET_OPERATION}" in
|
|
470
|
+
deploy:delete|delete:deploy) ;;
|
|
471
|
+
*) echo "Invalid review-app redispatch operation pair." >&2; exit 1 ;;
|
|
472
|
+
esac
|
|
473
|
+
|
|
474
|
+
default_branch="$(gh api --method GET "repos/${GH_REPO}" --jq '.default_branch')"
|
|
475
|
+
if [[ -z "${default_branch}" ]]; then
|
|
476
|
+
echo "GitHub returned an empty default branch while redispatching review-app intent." >&2
|
|
477
|
+
exit 1
|
|
478
|
+
fi
|
|
479
|
+
workflow_file="cpflow-${TARGET_OPERATION}-review-app.yml"
|
|
480
|
+
dispatch_json="$(
|
|
481
|
+
gh api --method POST \
|
|
482
|
+
-H "X-GitHub-Api-Version: 2026-03-10" \
|
|
483
|
+
"repos/${GH_REPO}/actions/workflows/${workflow_file}/dispatches" \
|
|
484
|
+
-f "ref=${default_branch}" \
|
|
485
|
+
-f "inputs[pr_number]=${PR_NUMBER}" \
|
|
486
|
+
-f "inputs[reconcile_intent_run_id]=${INTENT_RUN_ID}"
|
|
487
|
+
)"
|
|
488
|
+
dispatched_run_id="$(jq -r '.workflow_run_id // empty' <<< "${dispatch_json}")"
|
|
489
|
+
if [[ ! "${dispatched_run_id}" =~ ^[0-9]+$ ]]; then
|
|
490
|
+
echo "GitHub did not return a valid workflow run id for the review-app redispatch." >&2
|
|
491
|
+
exit 1
|
|
492
|
+
fi
|
|
493
|
+
source_created_at="$(
|
|
494
|
+
gh api --method GET "repos/${GH_REPO}/actions/runs/${GITHUB_RUN_ID}" --jq '.created_at'
|
|
495
|
+
)"
|
|
496
|
+
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
|
|
497
|
+
echo "GitHub returned an invalid source run creation time for the review-app redispatch." >&2
|
|
498
|
+
exit 1
|
|
499
|
+
fi
|
|
500
|
+
handoff_json="$(
|
|
501
|
+
jq -cn \
|
|
502
|
+
--argjson pr "${PR_NUMBER}" \
|
|
503
|
+
--arg operation "${TARGET_OPERATION}" \
|
|
504
|
+
--argjson intent_run_id "${INTENT_RUN_ID}" \
|
|
505
|
+
--argjson dispatch_run_id "${dispatched_run_id}" \
|
|
506
|
+
--argjson source_run_id "${GITHUB_RUN_ID}" \
|
|
507
|
+
--arg created_at "${source_created_at}" \
|
|
508
|
+
'{pr: $pr, operation: $operation, intent_run_id: $intent_run_id,
|
|
509
|
+
dispatch_run_id: $dispatch_run_id, source_run_id: $source_run_id,
|
|
510
|
+
created_at: $created_at}'
|
|
511
|
+
)"
|
|
512
|
+
handoff_body="<!-- cpflow-review-app-redispatch-v1 ${handoff_json} -->"
|
|
513
|
+
gh api --silent --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
|
514
|
+
-f "body=${handoff_body}"
|
|
515
|
+
echo "Redispatched ${TARGET_OPERATION} for latest accepted review-app intent run ${INTENT_RUN_ID}."
|
|
516
|
+
|
|
517
|
+
- name: Stop superseded review app operation
|
|
518
|
+
if: steps.intent.outputs.redispatch == 'true'
|
|
519
|
+
shell: bash
|
|
520
|
+
env:
|
|
521
|
+
TARGET_OPERATION: ${{ steps.intent.outputs.operation }}
|
|
522
|
+
INTENT_RUN_ID: ${{ steps.intent.outputs.run_id }}
|
|
523
|
+
run: |
|
|
524
|
+
echo "Stopped superseded operation after redispatching ${TARGET_OPERATION} intent ${INTENT_RUN_ID}." >&2
|
|
525
|
+
exit 1
|
|
526
|
+
|
|
527
|
+
# Authorization runs outside the shared queue. For automatic close cleanup,
|
|
528
|
+
# re-read live PR state after this job acquires concurrency so a stale close
|
|
529
|
+
# event cannot delete an app that a later reopen deployment already restored.
|
|
530
|
+
- name: Verify automatic deletion still targets a closed pull request
|
|
531
|
+
if: steps.intent.outputs.event == 'pull_request_target'
|
|
532
|
+
shell: bash
|
|
533
|
+
env:
|
|
534
|
+
GH_TOKEN: ${{ github.token }}
|
|
535
|
+
GH_REPO: ${{ github.repository }}
|
|
536
|
+
run: |
|
|
537
|
+
set -euo pipefail
|
|
538
|
+
|
|
539
|
+
pr_state="$(gh api --method GET "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq '.state')"
|
|
540
|
+
if [[ "${pr_state}" != "closed" ]]; then
|
|
541
|
+
echo "Refusing stale automatic review-app deletion because PR #${PR_NUMBER} is ${pr_state:-unknown}." >&2
|
|
542
|
+
exit 1
|
|
543
|
+
fi
|
|
544
|
+
|
|
37
545
|
- name: React to delete command
|
|
38
546
|
if: github.event_name == 'issue_comment'
|
|
39
547
|
continue-on-error: true
|
|
@@ -134,6 +642,7 @@ jobs:
|
|
|
134
642
|
core.setOutput("comment-id", comment.data.id);
|
|
135
643
|
|
|
136
644
|
- name: Delete review app
|
|
645
|
+
id: delete-app
|
|
137
646
|
if: steps.config.outputs.ready == 'true'
|
|
138
647
|
uses: ./.cpflow/.github/actions/cpflow-delete-control-plane-app
|
|
139
648
|
with:
|
|
@@ -142,35 +651,107 @@ jobs:
|
|
|
142
651
|
review_app_prefix: ${{ steps.review-config.outputs.review_app_prefix }}
|
|
143
652
|
working_directory: app
|
|
144
653
|
|
|
145
|
-
|
|
146
|
-
|
|
654
|
+
- name: Deactivate GitHub review deployments
|
|
655
|
+
id: deactivate-deployments
|
|
656
|
+
if: steps.config.outputs.ready == 'true' && steps.delete-app.outcome == 'success'
|
|
657
|
+
continue-on-error: true
|
|
658
|
+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
|
|
659
|
+
env:
|
|
660
|
+
APP_NAME: ${{ steps.review-config.outputs.app_name }}
|
|
661
|
+
with:
|
|
662
|
+
script: |
|
|
663
|
+
const environment = `review/${process.env.APP_NAME}`;
|
|
664
|
+
const deployments = await github.paginate(github.rest.repos.listDeployments, {
|
|
665
|
+
owner: context.repo.owner,
|
|
666
|
+
repo: context.repo.repo,
|
|
667
|
+
environment,
|
|
668
|
+
per_page: 100
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
if (deployments.length === 0) {
|
|
672
|
+
core.info(`No GitHub deployments found for ${environment}.`);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let deactivatedCount = 0;
|
|
677
|
+
let alreadyInactiveCount = 0;
|
|
678
|
+
|
|
679
|
+
for (const deployment of deployments) {
|
|
680
|
+
const { data: statuses } = await github.rest.repos.listDeploymentStatuses({
|
|
681
|
+
owner: context.repo.owner,
|
|
682
|
+
repo: context.repo.repo,
|
|
683
|
+
deployment_id: deployment.id,
|
|
684
|
+
per_page: 1
|
|
685
|
+
});
|
|
686
|
+
const [latestStatus] = statuses;
|
|
687
|
+
|
|
688
|
+
if (latestStatus?.state === "inactive") {
|
|
689
|
+
alreadyInactiveCount += 1;
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
await github.rest.repos.createDeploymentStatus({
|
|
694
|
+
owner: context.repo.owner,
|
|
695
|
+
repo: context.repo.repo,
|
|
696
|
+
deployment_id: deployment.id,
|
|
697
|
+
state: "inactive",
|
|
698
|
+
environment,
|
|
699
|
+
description: "Review app deleted"
|
|
700
|
+
});
|
|
701
|
+
deactivatedCount += 1;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
core.info(
|
|
705
|
+
`Deactivated ${deactivatedCount} GitHub deployment(s) for ${environment}; ` +
|
|
706
|
+
`skipped ${alreadyInactiveCount} already inactive deployment(s).`
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
# Finalizer still runs after delete or deployment-cleanup failures, but only after
|
|
710
|
+
# config validation created the initial PR comment and workflow link env vars it updates.
|
|
147
711
|
- name: Finalize delete status
|
|
148
712
|
if: always() && steps.config.outputs.ready == 'true'
|
|
149
713
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
|
|
150
714
|
env:
|
|
715
|
+
APP_NAME: ${{ steps.review-config.outputs.app_name }}
|
|
151
716
|
COMMENT_ID: ${{ steps.create-comment.outputs.comment-id }}
|
|
152
|
-
|
|
717
|
+
DELETE_OUTCOME: ${{ steps.delete-app.outcome }}
|
|
718
|
+
DEACTIVATION_OUTCOME: ${{ steps.deactivate-deployments.outcome }}
|
|
153
719
|
with:
|
|
154
720
|
script: |
|
|
155
721
|
const commentId = Number(process.env.COMMENT_ID);
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
722
|
+
const deleteSucceeded = process.env.DELETE_OUTCOME === "success";
|
|
723
|
+
const deactivationSucceeded = process.env.DEACTIVATION_OUTCOME === "success";
|
|
724
|
+
let body;
|
|
725
|
+
|
|
726
|
+
if (!deleteSucceeded) {
|
|
727
|
+
body = [
|
|
728
|
+
"## ❌ Failed to Delete Review App",
|
|
729
|
+
"",
|
|
730
|
+
`_Failed to delete review app for PR #${process.env.PR_NUMBER}_`,
|
|
731
|
+
"",
|
|
732
|
+
`🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
|
|
733
|
+
`📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
|
|
734
|
+
].join("\n");
|
|
735
|
+
} else if (!deactivationSucceeded) {
|
|
736
|
+
body = [
|
|
737
|
+
"## ⚠️ Review App Deleted, GitHub Deployment Cleanup Failed",
|
|
738
|
+
"",
|
|
739
|
+
`_Review app for PR #${process.env.PR_NUMBER} was deleted from Control Plane, but GitHub deployments for \`review/${process.env.APP_NAME}\` could not be marked inactive_`,
|
|
740
|
+
"",
|
|
741
|
+
"Re-run this deletion workflow to retry the GitHub deployment cleanup.",
|
|
742
|
+
`🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
|
|
743
|
+
`📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
|
|
744
|
+
].join("\n");
|
|
745
|
+
} else {
|
|
746
|
+
body = [
|
|
747
|
+
"## ✅ Review App Deleted",
|
|
748
|
+
"",
|
|
749
|
+
`_Review app for PR #${process.env.PR_NUMBER} is deleted_`,
|
|
750
|
+
"",
|
|
751
|
+
`🎮 [Control Plane Console](${process.env.CONSOLE_URL})`,
|
|
752
|
+
`📋 [View Workflow Logs](${process.env.WORKFLOW_URL})`
|
|
753
|
+
].join("\n");
|
|
754
|
+
}
|
|
174
755
|
|
|
175
756
|
if (!Number.isFinite(commentId) || commentId <= 0) {
|
|
176
757
|
core.warning("Skipping delete status comment update because no comment id was created.");
|
|
@@ -183,3 +764,10 @@ jobs:
|
|
|
183
764
|
comment_id: commentId,
|
|
184
765
|
body
|
|
185
766
|
});
|
|
767
|
+
|
|
768
|
+
- name: Fail when GitHub deployment cleanup failed
|
|
769
|
+
if: steps.config.outputs.ready == 'true' && steps.delete-app.outcome == 'success' && steps.deactivate-deployments.outcome == 'failure'
|
|
770
|
+
shell: bash
|
|
771
|
+
run: |
|
|
772
|
+
echo "Review app was deleted, but GitHub deployments could not be marked inactive." >&2
|
|
773
|
+
exit 1
|