-
-
Notifications
You must be signed in to change notification settings - Fork 76
279 lines (269 loc) · 14 KB
/
Copy pathreview-app.yml
File metadata and controls
279 lines (269 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
name: Review App
# Deploys / destroys per-PR review apps via the shared kamal-deploy.yml reusable
# workflow (build + kamal command) to the shared review host. This file owns only
# the PR lifecycle around it: resolving the trigger, labelling, surfacing the
# deployment, and PR failure comments. See docs/review-apps.md for host setup.
#
# Triggers:
# - workflow_dispatch : operator clicks "Run workflow" in Actions UI, OR
# ci.yml dispatches a deploy on push when the PR has
# the 'review-app' label (ci.yml's `dispatch` job)
# - pull_request closed : auto-destroy, whether or not the label is set
#
# There is deliberately NO `pull_request: synchronize` trigger — label-gating it
# leaves skipped check runs on every push to every unlabeled PR. ci.yml's
# `dispatch` job gates on the label instead.
#
# Fork PRs never auto-deploy (their pushes don't run ci.yml in this repo); a
# maintainer reviews the diff, then deploys via workflow_dispatch.
on:
workflow_dispatch:
inputs:
pr_number:
description: "Pull request number"
required: true
type: string
action:
description: "deploy or destroy"
required: true
type: choice
default: deploy
options: [deploy, destroy]
pull_request:
types: [closed]
permissions:
contents: read
packages: write # push to GHCR (reusable build)
pull-requests: write # add/remove labels and comment
deployments: write # create the PR-head deployment that surfaces the link
env:
REVIEW_LABEL: review-app
# Marks the `report` job's failure comment so it can be found again. Each comment
# also carries the failing commit's SHA (`<!-- sha:… -->`), so `report` edits in
# place only when the SAME commit fails again. The comment is hidden (minimized as
# OUTDATED), never deleted, once it's stale — either the deploy succeeds (`post`)
# or a newer commit is deployed (`report` hides the prior commit's comment).
FAILURE_COMMENT_MARKER: "<!-- review-app-failure -->"
jobs:
resolve:
name: Resolve PR
# Every closed PR runs destroy, label or not — the deploy path's `--add-label`
# is best-effort, so an app can be live with no label on it (docs/review-apps.md).
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.set.outputs.pr_number }}
head_sha: ${{ steps.set.outputs.head_sha }}
action: ${{ steps.set.outputs.action }}
proceed: ${{ steps.set.outputs.proceed }}
pr_title: ${{ steps.set.outputs.pr_title }}
steps:
- name: Resolve PR + action from the trigger
id: set
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
INPUT_PR: ${{ github.event.inputs.pr_number }}
INPUT_ACTION: ${{ github.event.inputs.action }}
PR_NUMBER_EVENT: ${{ github.event.pull_request.number }}
PR_HEAD_SHA_EVENT: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REPO_EVENT: ${{ github.event.pull_request.head.repo.full_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
pr="$INPUT_PR"
action="$INPUT_ACTION"
head_sha=$(gh pr view "$pr" --repo "$REPO" --json headRefOid -q .headRefOid)
title=$(gh pr view "$pr" --repo "$REPO" --json title -q .title)
proceed=true
else
pr="$PR_NUMBER_EVENT"
head_sha="$PR_HEAD_SHA_EVENT"
title="" # destroy doesn't render the banner, so no title needed
action="destroy" # `closed` is the only pull_request trigger
if [[ "$PR_HEAD_REPO_EVENT" != "$REPO" ]]; then
echo "PR #$pr is from a fork ($PR_HEAD_REPO_EVENT); skipping auto-trigger."
proceed=false
else
proceed=true
fi
fi
echo "pr_number=$pr" >> "$GITHUB_OUTPUT"
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
echo "action=$action" >> "$GITHUB_OUTPUT"
echo "proceed=$proceed" >> "$GITHUB_OUTPUT"
# Heredoc form — a PR title is arbitrary text (may contain `=`, `#`, …).
{
echo "pr_title<<__PR_TITLE_EOF__"
echo "$title"
echo "__PR_TITLE_EOF__"
} >> "$GITHUB_OUTPUT"
# Label on deploy *attempt*, not success: the label marks intent and arms
# auto-redeploy (ci.yml's dispatch job keys off it) — after a failed deploy,
# pushing a fix re-dispatches without another manual run. Create the label
# first (`gh pr edit --add-label` errors if it's missing); both are idempotent.
- name: Add review-app label
if: steps.set.outputs.proceed == 'true' && steps.set.outputs.action == 'deploy'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh label create "$REVIEW_LABEL" --repo "$GITHUB_REPOSITORY" \
--color 0E8A16 --description "Per-PR Kamal review app is deployed" 2>/dev/null || true
gh pr edit "${{ steps.set.outputs.pr_number }}" --repo "$GITHUB_REPOSITORY" --add-label "$REVIEW_LABEL" || true
# Build (deploy only) + run the kamal command, via the shared reusable workflow.
# Deploy checks out the PR head; destroy checks out the default branch — it tears
# down by PR number and needs none of the PR's code, and a PR predating a
# bin/kamal_review change would otherwise fail on its stale checkout.
op:
name: ${{ needs.resolve.outputs.action == 'destroy' && 'Destroy' || 'Deploy' }} ${{ needs.resolve.outputs.pr_number }}
needs: resolve
if: needs.resolve.outputs.proceed == 'true'
uses: ./.github/workflows/kamal-deploy.yml
secrets: inherit
with:
action: ${{ needs.resolve.outputs.action }}
ref: ${{ needs.resolve.outputs.action == 'deploy' && needs.resolve.outputs.head_sha || github.event.repository.default_branch }}
command: ${{ needs.resolve.outputs.action == 'deploy' && format('bin/kamal_review deploy --app {0}', needs.resolve.outputs.pr_number) || format('bin/kamal_review destroy --app {0}', needs.resolve.outputs.pr_number) }}
image_tag: pr-${{ needs.resolve.outputs.pr_number }}-${{ needs.resolve.outputs.head_sha }}
service_label: bike-index-pr-${{ needs.resolve.outputs.pr_number }}
sprockets_cache_prefix: review-app-sprockets
pr_title: ${{ needs.resolve.outputs.pr_title }}
concurrency_prefix: review-app-${{ needs.resolve.outputs.pr_number }}
environment_name: review-app
environment_url: ${{ needs.resolve.outputs.action == 'deploy' && format('https://pr-{0}.review.bikeindex.org', needs.resolve.outputs.pr_number) || '' }}
# PR-side follow-ups the generic reusable workflow can't own.
post:
name: Post-op PR updates
needs: [resolve, op]
if: ${{ !cancelled() && needs.resolve.outputs.proceed == 'true' }}
runs-on: ubuntu-latest
steps:
# The reusable `run` job's environment attaches the deployment to this run's
# commit, which surfaces the PR link only when that commit is the PR head —
# true for ci.yml auto-dispatches, but NOT a manual dispatch from another
# branch. Create a deployment on the PR head so the link appears regardless.
- name: Link deployment to PR head
if: needs.resolve.outputs.action == 'deploy' && needs.op.result == 'success' && github.sha != needs.resolve.outputs.head_sha
uses: actions/github-script@v9
env:
HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
DEPLOY_URL: https://pr-${{ needs.resolve.outputs.pr_number }}.review.bikeindex.org
with:
script: |
const {data: deployment} = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: process.env.HEAD_SHA,
environment: "review-app",
auto_merge: false,
required_contexts: [],
transient_environment: true,
description: "Per-PR Kamal review app",
})
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: "success",
environment_url: process.env.DEPLOY_URL,
})
# A successful deploy makes the `report` job's failure comment stale. Hide it
# (minimize as OUTDATED) rather than delete — the record stays on the PR.
- name: Hide stale deploy-failure comment
if: needs.resolve.outputs.action == 'deploy' && needs.op.result == 'success'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
--jq ".[] | select(.body | startswith(\"$FAILURE_COMMENT_MARKER\")) | .node_id" \
| while read -r nid; do
[ -n "$nid" ] || continue
echo "Hiding stale failure comment $nid"
gh api graphql -f query='mutation($id: ID!) { minimizeComment(input: {classifier: OUTDATED, subjectId: $id}) { minimizedComment { isMinimized } } }' -f id="$nid" || true
done
# Gated on op success: a failed teardown leaves the app/db/volumes behind,
# so keep the label (the app still exists) and the images needed to retry.
- name: Remove review-app label
if: needs.resolve.outputs.action == 'destroy' && needs.op.result == 'success'
env:
GH_TOKEN: ${{ github.token }}
run: gh pr edit "${{ needs.resolve.outputs.pr_number }}" --repo "$GITHUB_REPOSITORY" --remove-label "$REVIEW_LABEL" || true
# Each push built a `pr-<N>-<sha>` image; delete every version for this PR so
# closed PRs don't accumulate images in GHCR. Matches on the trailing dash so
# `pr-12-` never catches `pr-123-`. Best-effort.
# Skipped for an unlabeled close: the listing below paginates every version of
# the whole package, and only a PR that deployed — which is what labels it —
# has anything to delete. A deploy whose `--add-label` failed leaks its images,
# which is what the untagged-version prune in docs/review-apps.md is for.
- name: Delete PR images from GHCR
if: >-
needs.resolve.outputs.action == 'destroy' && needs.op.result == 'success' &&
(github.event_name == 'workflow_dispatch' ||
contains(github.event.pull_request.labels.*.name, 'review-app'))
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
run: |
set -euo pipefail
owner="${GITHUB_REPOSITORY%%/*}"
package="${GITHUB_REPOSITORY##*/}"
prefix="pr-${PR_NUMBER}-"
ids=$(gh api --paginate \
"/orgs/${owner}/packages/container/${package}/versions" \
--jq "[.[] | select(any(.metadata.container.tags[]; startswith(\"${prefix}\"))) | .id] | .[]")
if [[ -z "$ids" ]]; then
echo "No GHCR image versions tagged ${prefix}* to delete."
exit 0
fi
for id in $ids; do
echo "Deleting GHCR image version $id"
gh api --method DELETE \
"/orgs/${owner}/packages/container/${package}/versions/${id}" || true
done
# Failed builds/deploys are invisible on the PR otherwise: these runs are
# workflow_dispatch-triggered, so their check runs never appear in the PR's
# check rollup. Comment the failure instead — edited in place when the SAME commit
# fails again (matched via the marker + SHA), and hidden (never deleted) once stale:
# by `post` on the next successful deploy, or here when a newer commit fails.
report:
name: Report failure on PR
needs: [resolve, op, post]
if: >-
!cancelled() && needs.resolve.outputs.proceed == 'true' &&
contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
steps:
- name: Comment failure on PR
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
ACTION: ${{ needs.resolve.outputs.action }}
HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
sha_marker="<!-- sha:$HEAD_SHA -->"
body="$FAILURE_COMMENT_MARKER $sha_marker
🚨 **Review app ${ACTION} failed** for \`${HEAD_SHA:0:7}\` — [run logs](${RUN_URL})
Push a fix to retry automatically, or re-run the Review App workflow manually."
# Edit in place only when THIS commit failed before; a comment for an older
# commit is left untouched here and hidden below.
cid=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
--jq "[.[] | select(.body | startswith(\"$FAILURE_COMMENT_MARKER\")) | select(.body | contains(\"$sha_marker\")) | .id][0] // empty" | head -n1)
if [ -n "$cid" ]; then
echo "Updating existing failure comment $cid"
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$cid" -f body="$body"
else
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "$body"
fi
# A newer commit is being deployed, so hide (never delete) any failure
# comment left over from an earlier commit.
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
--jq ".[] | select(.body | startswith(\"$FAILURE_COMMENT_MARKER\")) | select(.body | contains(\"$sha_marker\") | not) | .node_id" \
| while read -r nid; do
[ -n "$nid" ] || continue
echo "Hiding stale failure comment $nid"
gh api graphql -f query='mutation($id: ID!) { minimizeComment(input: {classifier: OUTDATED, subjectId: $id}) { minimizedComment { isMinimized } } }' -f id="$nid" || true
done