-
Notifications
You must be signed in to change notification settings - Fork 460
390 lines (379 loc) · 17.7 KB
/
Copy pathrelease.yaml
File metadata and controls
390 lines (379 loc) · 17.7 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
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
name: Release
# Refreshes PGO, bumps caddy/go.mod, commits as github-actions[bot],
# tags v<version> and caddy/v<version>, drafts the GitHub release,
# dispatches the binary build workflows, and bumps the Homebrew formula.
# Idempotent: a re-dispatch after a partial failure resumes by tag.
on:
workflow_dispatch:
inputs:
#checkov:skip=CKV_GHA_7
version:
description: "Version to release (e.g. 1.5.0, no v prefix)"
required: true
type: string
permissions: {}
concurrency:
# Per-version: different versions race safely (the API parent_sha
# check rejects a stale main HEAD update); same-version dispatches
# serialize so resume logic isn't blocked by a pending approval.
group: ${{ github.workflow }}-${{ inputs.version }}
cancel-in-progress: false
jobs:
release:
name: Release ${{ inputs.version }}
runs-on: ubuntu-latest
environment: release
env:
GOTOOLCHAIN: local
GOFLAGS: "-tags=nobadger,nomysql,nopgx"
LIBRARY_PATH: ${{ github.workspace }}/watcher/target/lib
BENCH_SEC: "30"
steps:
- name: Validate inputs
# Reject non-main refs and non-semver versions before they reach
# go get / sed / tag refs. https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
echo "::error::release.yaml must be dispatched against refs/heads/main, got ${GITHUB_REF}"
exit 1
fi
if [[ ! ${VERSION} =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$ ]]; then
echo "::error::Invalid version: '${VERSION}' (must be SemVer, no v prefix)"
exit 1
fi
- uses: actions/create-github-app-token@v3
id: release-app-token
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
token: ${{ steps.release-app-token.outputs.token }}
- name: Classify release
id: classify
env:
VERSION: ${{ inputs.version }}
# Pre-releases (SemVer "-" suffix) must not bump --latest or Homebrew.
run: |
if [[ "${VERSION}" == *-* ]]; then
echo "prerelease=true" >> "${GITHUB_OUTPUT}"
else
echo "prerelease=false" >> "${GITHUB_OUTPUT}"
fi
- name: Determine release state
id: state
env:
GH_TOKEN: ${{ steps.release-app-token.outputs.token }}
VERSION: ${{ inputs.version }}
# Tag existence is the resume signal — main HEAD may have moved
# past the release commit, so a HEAD message check is too narrow.
run: |
set -euo pipefail
# matching-refs returns [] (HTTP 200) for absent tags; real
# failures still trip set -e.
lookup_tag() {
gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/$1" \
--jq ".[] | select(.ref == \"refs/tags/$1\") | {sha: .object.sha, type: .object.type}"
}
resolve_commit() {
local entry="$1"
local sha type
sha=$(jq -r .sha <<<"${entry}")
type=$(jq -r .type <<<"${entry}")
if [[ "${type}" == "tag" ]]; then
gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" -q .object.sha
else
printf '%s\n' "${sha}"
fi
}
# The grep below deliberately omits the `require` keyword so
# it matches both `require ( ... )` block layout (where the
# entry is indented) and single-line `require x v...` layout.
verify_release_content() {
local ref="$1"
if ! git show "${ref}:caddy/go.mod" 2>/dev/null \
| grep -qE "(^|[[:space:]])github\\.com/dunglas/frankenphp v${VERSION//./\\.}([[:space:]]|\$)"; then
echo "${ref}: caddy/go.mod does not require frankenphp v${VERSION}" >&2
return 1
fi
local size
size=$(git cat-file -s "${ref}:caddy/frankenphp/default.pgo" 2>/dev/null || echo 0)
if [[ "${size}" -lt 1024 ]]; then
echo "${ref}: PGO profile missing or suspiciously small (${size} bytes)" >&2
return 1
fi
}
main_entry=$(lookup_tag "v${VERSION}")
caddy_entry=$(lookup_tag "caddy/v${VERSION}")
if [[ -n "${main_entry}" ]]; then
sha=$(resolve_commit "${main_entry}")
# Reject orphan tags created on a side branch.
if ! git merge-base --is-ancestor "${sha}" HEAD; then
echo "::error::Tag v${VERSION} (${sha}) is not reachable from main; refusing to resume."
exit 1
fi
# Catch a mismatched caddy/v${VERSION} before any writes.
if [[ -n "${caddy_entry}" ]]; then
caddy_sha=$(resolve_commit "${caddy_entry}")
if [[ "${caddy_sha}" != "${sha}" ]]; then
echo "::error::caddy/v${VERSION} (${caddy_sha}) does not match v${VERSION} (${sha})."
exit 1
fi
fi
git fetch --quiet origin "refs/tags/v${VERSION}:refs/tags/v${VERSION}"
if ! verify_release_content "v${VERSION}"; then
echo "::error::v${VERSION} (${sha}) does not match expected release content."
exit 1
fi
echo "Resuming: v${VERSION} exists at ${sha}"
{
echo "resume=true"
echo "release_commit=${sha}"
} >> "${GITHUB_OUTPUT}"
elif verify_release_content HEAD 2>/dev/null; then
if [[ -n "${caddy_entry}" ]]; then
echo "::error::caddy/v${VERSION} exists but v${VERSION} does not; refusing to release into a split state."
exit 1
fi
sha=$(git rev-parse HEAD)
echo "Resuming: main HEAD (${sha}) already matches v${VERSION}; tags will be created."
{
echo "resume=true"
echo "release_commit=${sha}"
} >> "${GITHUB_OUTPUT}"
else
if [[ -n "${caddy_entry}" ]]; then
echo "::error::caddy/v${VERSION} exists but v${VERSION} does not; refusing to release into a split state."
exit 1
fi
echo "resume=false" >> "${GITHUB_OUTPUT}"
fi
- if: steps.state.outputs.resume != 'true'
uses: ./.github/actions/setup-go
- if: steps.state.outputs.resume != 'true'
uses: ./.github/actions/setup-php
- if: steps.state.outputs.resume != 'true'
name: Install e-dant/watcher
uses: ./.github/actions/watcher
- if: steps.state.outputs.resume != 'true'
name: Set CGO flags
run: echo "CGO_CFLAGS=-I${PWD}/watcher/target/include $(php-config --includes)" >> "${GITHUB_ENV}"
- if: steps.state.outputs.resume != 'true'
name: Install wrk
run: sudo apt-get update && sudo apt-get install -y wrk && sudo apt-get install --reinstall -y libbrotli-dev
- if: steps.state.outputs.resume != 'true'
name: Refresh PGO profile
run: ./profiles/build-pgo.sh
- if: steps.state.outputs.resume != 'true'
name: Sanity-check PGO profile
# Guard against wrk silently failing and producing a near-empty profile.
run: |
size=$(wc -c <caddy/frankenphp/default.pgo)
echo "PGO profile: ${size} bytes"
if [[ "${size}" -lt 1024 ]]; then
echo "::error::PGO profile is suspiciously small; aborting release."
exit 1
fi
- if: steps.state.outputs.resume != 'true'
name: Bump Caddy module
working-directory: caddy
env:
VERSION: ${{ inputs.version }}
run: |
go get "github.com/dunglas/frankenphp@v${VERSION}"
go mod tidy
- name: Commit and tag via GitHub API
# API-created commits/tags are signed server-side and show as
# Verified under dunglas-release[bot].
env:
GH_TOKEN: ${{ steps.release-app-token.outputs.token }}
REPO: ${{ github.repository }}
VERSION: ${{ inputs.version }}
RESUME: ${{ steps.state.outputs.resume }}
RELEASE_COMMIT: ${{ steps.state.outputs.release_commit }}
run: |
set -euo pipefail
if [[ "${RESUME}" == "true" ]]; then
commit_sha="${RELEASE_COMMIT}"
echo "Reusing existing release commit ${commit_sha}"
else
# Use --rawfile: the PGO blob (~2 MB encoded) exceeds ARG_MAX
# via --arg.
make_blob() (
local tmp
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
base64 -w0 <"$1" >"$tmp"
jq -nc --rawfile content "$tmp" \
'{content: $content, encoding: "base64"}' \
| gh api "repos/${REPO}/git/blobs" --input - -q .sha
)
# Concurrency is per-version, so a different version could
# land on main while this run is in flight. Abort rather than
# overlay our locally-bumped files on top of unseen commits.
checkout_sha=$(git rev-parse HEAD)
parent_sha=$(gh api "repos/${REPO}/git/refs/heads/main" -q .object.sha)
if [[ "${checkout_sha}" != "${parent_sha}" ]]; then
echo "::error::main advanced from ${checkout_sha} to ${parent_sha} during the run; refusing to overlay locally-modified files on a newer base_tree."
exit 1
fi
base_tree=$(gh api "repos/${REPO}/git/commits/${parent_sha}" -q .tree.sha)
# Capture every touched file (modifications, additions,
# deletions) so transitive go.sum or PGO side effects aren't
# dropped from the release commit. --no-renames decomposes
# renames into add+delete so both halves land in the tree
# mutation.
mapfile -t modified < <(git diff --no-renames --name-only --diff-filter=ACM HEAD)
mapfile -t deleted < <(git diff --no-renames --name-only --diff-filter=D HEAD)
mapfile -t untracked < <(git ls-files --others --exclude-standard)
if [[ ${#modified[@]} -eq 0 && ${#deleted[@]} -eq 0 && ${#untracked[@]} -eq 0 ]]; then
echo "::error::No file changes after PGO/bump. Is v${VERSION} already on main? Delete the local tags and pick a different version, or recreate the tags manually."
exit 1
fi
present=("${modified[@]}" "${untracked[@]}")
[[ ${#present[@]} -gt 0 ]] && printf 'Including (added/modified): %s\n' "${present[@]}"
[[ ${#deleted[@]} -gt 0 ]] && printf 'Including (deleted): %s\n' "${deleted[@]}"
# Preserve the existing file mode (executable bit) when
# modifying tracked files; default to 100644 for new files
# unless the path is executable on disk.
mode_for() {
local path="$1" mode
mode=$(git ls-tree HEAD -- "$path" | awk '{print $1; exit}')
if [[ -n "$mode" ]]; then
printf '%s\n' "$mode"
elif [[ -x "$path" ]]; then
printf '100755\n'
else
printf '100644\n'
fi
}
tree_entries=$(
{
for path in "${modified[@]}" "${untracked[@]}"; do
sha=$(make_blob "${path}")
jq -nc --arg path "${path}" --arg sha "${sha}" --arg mode "$(mode_for "${path}")" \
'{path: $path, mode: $mode, type: "blob", sha: $sha}'
done
for path in "${deleted[@]}"; do
# Deletions need a valid mode but it's purely formal —
# the entry only removes the path from the tree.
jq -nc --arg path "${path}" \
'{path: $path, mode: "100644", type: "blob", sha: null}'
done
} | jq -sc .
)
tree_sha=$(jq -nc \
--arg base_tree "$base_tree" \
--argjson entries "${tree_entries}" \
'{base_tree: $base_tree, tree: $entries}' \
| gh api "repos/${REPO}/git/trees" --input - -q .sha)
# [skip ci] avoids push-triggered workflows firing alongside
# the explicit downstream dispatches below.
commit_sha=$(jq -nc \
--arg message "chore: prepare release ${VERSION} [skip ci]" \
--arg tree "$tree_sha" \
--arg parent "$parent_sha" \
'{message: $message, tree: $tree, parents: [$parent]}' \
| gh api "repos/${REPO}/git/commits" --input - -q .sha)
gh api "repos/${REPO}/git/refs/heads/main" -X PATCH -f sha="$commit_sha" --silent
fi
# Idempotent: skip if tag already points at the release commit,
# fail if it points elsewhere. matching-refs distinguishes
# "tag absent" (HTTP 200, empty array) from real failures, which
# still trip set -e.
create_tag() {
local tag="$1"
local existing
existing=$(gh api "repos/${REPO}/git/matching-refs/tags/${tag}" \
--jq ".[] | select(.ref == \"refs/tags/${tag}\") | {sha: .object.sha, type: .object.type}")
if [[ -n "${existing}" ]]; then
local obj_sha obj_type
obj_sha=$(jq -r .sha <<<"${existing}")
obj_type=$(jq -r .type <<<"${existing}")
if [[ "${obj_type}" == "tag" ]]; then
obj_sha=$(gh api "repos/${REPO}/git/tags/${obj_sha}" -q .object.sha)
fi
if [[ "${obj_sha}" == "${commit_sha}" ]]; then
echo "Tag ${tag} already points at ${commit_sha}; skipping."
return 0
fi
echo "::error::Tag ${tag} exists at ${obj_sha}, expected ${commit_sha}"
return 1
fi
local tag_sha
tag_sha=$(jq -nc \
--arg tag "${tag}" \
--arg message "Version ${VERSION}" \
--arg object "${commit_sha}" \
'{tag: $tag, message: $message, object: $object, type: "commit"}' \
| gh api "repos/${REPO}/git/tags" --input - -q .sha)
gh api "repos/${REPO}/git/refs" -f ref="refs/tags/${tag}" -f sha="${tag_sha}" --silent
}
create_tag "v${VERSION}"
create_tag "caddy/v${VERSION}"
# So the release-draft step's `git describe v${VERSION}^` resolves.
git fetch origin main --tags
- name: Draft GitHub release
# `gh release create` goes through GraphQL which can lag minutes
# behind the Git Data API and yield "no matches" or `untagged-*`
# placeholder releases; the REST releases endpoint is consistent.
env:
GH_TOKEN: ${{ steps.release-app-token.outputs.token }}
REPO: ${{ github.repository }}
VERSION: ${{ inputs.version }}
PRERELEASE: ${{ steps.classify.outputs.prerelease }}
run: |
set -euo pipefail
if gh release view "v${VERSION}" --repo "${REPO}" >/dev/null 2>&1; then
echo "Release v${VERSION} already exists; skipping draft creation."
exit 0
fi
previous_tag=$(git describe --tags --abbrev=0 --match 'v*' --exclude "v${VERSION}" "v${VERSION}^" 2>/dev/null || true)
notes_args=(-X POST -f tag_name="v${VERSION}")
if [[ -n "${previous_tag}" ]]; then
notes_args+=(-f previous_tag_name="${previous_tag}")
fi
notes=$(gh api "repos/${REPO}/releases/generate-notes" "${notes_args[@]}" --jq .body)
create_args=(-X POST -f tag_name="v${VERSION}" -f name="v${VERSION}" -F draft=true -f body="${notes}")
if [[ "${PRERELEASE}" == "true" ]]; then
create_args+=(-F prerelease=true)
else
create_args+=(-f make_latest=true)
fi
gh api "repos/${REPO}/releases" "${create_args[@]}" --silent
- name: Trigger downstream release builds
# GITHUB_TOKEN tag writes don't fire push triggers, so dispatch
# each downstream explicitly. Keep going on partial failure;
# re-dispatch on resume just queues another idempotent build.
env:
GH_TOKEN: ${{ steps.release-app-token.outputs.token }}
REPO: ${{ github.repository }}
VERSION: ${{ inputs.version }}
run: |
failed=()
for wf in static.yaml docker.yaml windows.yaml; do
if ! gh workflow run "${wf}" --repo "${REPO}" --ref "v${VERSION}" -f version="${VERSION}"; then
failed+=("${wf}")
fi
done
if [[ ${#failed[@]} -gt 0 ]]; then
echo "::warning::Failed to dispatch: ${failed[*]}. Re-run those workflows manually."
fi
- name: Bump Homebrew formula
if: steps.classify.outputs.prerelease != 'true'
uses: mislav/bump-homebrew-formula-action@v4
with:
formula-name: frankenphp
homebrew-tap: dunglas/homebrew-frankenphp
tag-name: v${{ inputs.version }}
env:
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TOKEN }}
# The action reads the source repo (release tag, tarball checksum) with
# GITHUB_TOKEN; without it, it falls back to COMMITTER_TOKEN, whose
# fine-grained PAT only sees the tap and gets 403 on this repo.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}