Skip to content

Commit 8876b38

Browse files
authored
ci: harden release workflow inputs and tag detection (#2430)
Cross-port of review feedback from dunglas/mercure#1246, applied to the changes that this workflow shares structurally with Mercure's. ## Summary - **Validate the `version` input inside `release.yaml`.** The semver regex in `release.sh` only protects operators using the script; a UI-triggered dispatch could otherwise propagate `latest`, `1.2`, or any string containing `/` straight into `go get @v...`, `sed`, and tag refs. - **Replace the `(HTTP 404)` stderr grep** with a `git/matching-refs/tags/...` lookup that returns an empty array for absent tags. Distinguishing "tag missing" from rate-limit / 5xx / auth failures no longer depends on the exact wording of `gh`'s error messages. The split-state guard now also fires symmetrically: a mismatched `caddy/v<version>` on the resume path is caught before any writes. - **Build the release tree from `git diff --name-only HEAD`** so the commit captures every file the PGO refresh or `go mod tidy` step touches, rather than the previously hardcoded three paths. - **Restore the three-way behind/ahead/diverged diagnostic in `release.sh`** so the operator knows whether to `git pull`, `git push`, or reconcile a divergence.
1 parent d50ce61 commit 8876b38

2 files changed

Lines changed: 182 additions & 91 deletions

File tree

.github/workflows/release.yaml

Lines changed: 163 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
name: Release
2-
# Cuts a FrankenPHP release end-to-end: refreshes the PGO profile, bumps the
3-
# Caddy module's frankenphp dependency, commits the result as
4-
# github-actions[bot], tags v<version> and caddy/v<version>, drafts a GitHub
5-
# release, dispatches the downstream binary builds, and opens a Homebrew
6-
# formula bump PR. Dispatched by release.sh.
7-
#
8-
# The workflow is idempotent: re-dispatching after a partial failure (flaky
9-
# test, network blip, registry hiccup) detects which steps already completed
10-
# and skips them, so the release can be resumed without manual cleanup.
2+
# Refreshes PGO, bumps caddy/go.mod, commits as github-actions[bot],
3+
# tags v<version> and caddy/v<version>, drafts the GitHub release,
4+
# dispatches the binary build workflows, and bumps the Homebrew formula.
5+
# Idempotent: a re-dispatch after a partial failure resumes by tag.
116
on:
127
workflow_dispatch:
138
inputs:
@@ -18,7 +13,10 @@ on:
1813
type: string
1914
permissions: {}
2015
concurrency:
21-
group: ${{ github.workflow }}
16+
# Per-version: different versions race safely (the API parent_sha
17+
# check rejects a stale main HEAD update); same-version dispatches
18+
# serialize so resume logic isn't blocked by a pending approval.
19+
group: ${{ github.workflow }}-${{ inputs.version }}
2220
cancel-in-progress: false
2321
jobs:
2422
release:
@@ -31,14 +29,21 @@ jobs:
3129
LIBRARY_PATH: ${{ github.workspace }}/watcher/target/lib
3230
BENCH_SEC: "30"
3331
steps:
34-
- name: Refuse non-main dispatch
35-
# workflow_dispatch can target any ref; reject anything but main so a
36-
# mis-dispatched run fails loudly instead of being silently skipped.
32+
- name: Validate inputs
33+
# Reject non-main refs and non-semver versions before they reach
34+
# go get / sed / tag refs. https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
35+
env:
36+
VERSION: ${{ inputs.version }}
3737
run: |
38+
set -euo pipefail
3839
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
3940
echo "::error::release.yaml must be dispatched against refs/heads/main, got ${GITHUB_REF}"
4041
exit 1
4142
fi
43+
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
44+
echo "::error::Invalid version: '${VERSION}' (must be SemVer, no v prefix)"
45+
exit 1
46+
fi
4247
- uses: actions/create-github-app-token@v3
4348
id: release-app-token
4449
with:
@@ -53,8 +58,7 @@ jobs:
5358
id: classify
5459
env:
5560
VERSION: ${{ inputs.version }}
56-
# Pre-release versions (those carrying a "-" suffix per SemVer) must
57-
# not be marked --latest nor bump the stable Homebrew formula.
61+
# Pre-releases (SemVer "-" suffix) must not bump --latest or Homebrew.
5862
run: |
5963
if [[ "${VERSION}" == *-* ]]; then
6064
echo "prerelease=true" >> "${GITHUB_OUTPUT}"
@@ -66,45 +70,88 @@ jobs:
6670
env:
6771
GH_TOKEN: ${{ steps.release-app-token.outputs.token }}
6872
VERSION: ${{ inputs.version }}
69-
# Tag existence is the source of truth for "release in progress":
70-
# main HEAD may have moved past the release commit (a follow-up fix
71-
# merged on top), so the commit-message check on HEAD is too narrow.
72-
# If v<version> exists, resume from the commit it points at;
73-
# otherwise it's a fresh attempt and tags must not exist.
73+
# Tag existence is the resume signal — main HEAD may have moved
74+
# past the release commit, so a HEAD message check is too narrow.
7475
run: |
7576
set -euo pipefail
76-
err=$(mktemp)
77-
trap 'rm -f "${err}"' EXIT
78-
# Capture stderr so we can distinguish a real 404 (tag absent → fresh
79-
# attempt) from any other failure (rate limit, 5xx, auth) which must
80-
# not be silently treated as "tag missing".
81-
if ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/v${VERSION}" 2>"${err}"); then
82-
sha=$(jq -r .object.sha <<<"${ref}")
83-
type=$(jq -r .object.type <<<"${ref}")
77+
# matching-refs returns [] (HTTP 200) for absent tags; real
78+
# failures still trip set -e.
79+
lookup_tag() {
80+
gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/$1" \
81+
--jq ".[] | select(.ref == \"refs/tags/$1\") | {sha: .object.sha, type: .object.type}"
82+
}
83+
resolve_commit() {
84+
local entry="$1"
85+
local sha type
86+
sha=$(jq -r .sha <<<"${entry}")
87+
type=$(jq -r .type <<<"${entry}")
8488
if [[ "${type}" == "tag" ]]; then
85-
sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" -q .object.sha)
89+
gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" -q .object.sha
90+
else
91+
printf '%s\n' "${sha}"
92+
fi
93+
}
94+
# The grep below deliberately omits the `require` keyword so
95+
# it matches both `require ( ... )` block layout (where the
96+
# entry is indented) and single-line `require x v...` layout.
97+
verify_release_content() {
98+
local ref="$1"
99+
if ! git show "${ref}:caddy/go.mod" 2>/dev/null \
100+
| grep -qE "(^|[[:space:]])github\\.com/dunglas/frankenphp v${VERSION//./\\.}([[:space:]]|\$)"; then
101+
echo "${ref}: caddy/go.mod does not require frankenphp v${VERSION}" >&2
102+
return 1
86103
fi
87-
# Refuse to resume against a tag that isn't reachable from main:
88-
# protects against an orphan tag created on a side branch.
104+
local size
105+
size=$(git cat-file -s "${ref}:caddy/frankenphp/default.pgo" 2>/dev/null || echo 0)
106+
if [[ "${size}" -lt 1024 ]]; then
107+
echo "${ref}: PGO profile missing or suspiciously small (${size} bytes)" >&2
108+
return 1
109+
fi
110+
}
111+
main_entry=$(lookup_tag "v${VERSION}")
112+
caddy_entry=$(lookup_tag "caddy/v${VERSION}")
113+
if [[ -n "${main_entry}" ]]; then
114+
sha=$(resolve_commit "${main_entry}")
115+
# Reject orphan tags created on a side branch.
89116
if ! git merge-base --is-ancestor "${sha}" HEAD; then
90117
echo "::error::Tag v${VERSION} (${sha}) is not reachable from main; refusing to resume."
91118
exit 1
92119
fi
120+
# Catch a mismatched caddy/v${VERSION} before any writes.
121+
if [[ -n "${caddy_entry}" ]]; then
122+
caddy_sha=$(resolve_commit "${caddy_entry}")
123+
if [[ "${caddy_sha}" != "${sha}" ]]; then
124+
echo "::error::caddy/v${VERSION} (${caddy_sha}) does not match v${VERSION} (${sha})."
125+
exit 1
126+
fi
127+
fi
128+
git fetch --quiet origin "refs/tags/v${VERSION}:refs/tags/v${VERSION}"
129+
if ! verify_release_content "v${VERSION}"; then
130+
echo "::error::v${VERSION} (${sha}) does not match expected release content."
131+
exit 1
132+
fi
93133
echo "Resuming: v${VERSION} exists at ${sha}"
94134
{
95135
echo "resume=true"
96136
echo "release_commit=${sha}"
97137
} >> "${GITHUB_OUTPUT}"
98-
elif grep -qF "(HTTP 404)" "${err}"; then
99-
echo "resume=false" >> "${GITHUB_OUTPUT}"
100-
if gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/caddy/v${VERSION}" --silent 2>/dev/null; then
138+
elif verify_release_content HEAD 2>/dev/null; then
139+
if [[ -n "${caddy_entry}" ]]; then
101140
echo "::error::caddy/v${VERSION} exists but v${VERSION} does not; refusing to release into a split state."
102141
exit 1
103142
fi
143+
sha=$(git rev-parse HEAD)
144+
echo "Resuming: main HEAD (${sha}) already matches v${VERSION}; tags will be created."
145+
{
146+
echo "resume=true"
147+
echo "release_commit=${sha}"
148+
} >> "${GITHUB_OUTPUT}"
104149
else
105-
echo "::error::GitHub API call for tag v${VERSION} failed:"
106-
cat "${err}" >&2
107-
exit 1
150+
if [[ -n "${caddy_entry}" ]]; then
151+
echo "::error::caddy/v${VERSION} exists but v${VERSION} does not; refusing to release into a split state."
152+
exit 1
153+
fi
154+
echo "resume=false" >> "${GITHUB_OUTPUT}"
108155
fi
109156
- if: steps.state.outputs.resume != 'true'
110157
uses: ./.github/actions/setup-go
@@ -124,8 +171,7 @@ jobs:
124171
run: ./profiles/build-pgo.sh
125172
- if: steps.state.outputs.resume != 'true'
126173
name: Sanity-check PGO profile
127-
# Catch the degenerate case where wrk silently failed to drive load
128-
# and we ended up shipping a near-empty profile.
174+
# Guard against wrk silently failing and producing a near-empty profile.
129175
run: |
130176
size=$(wc -c <caddy/frankenphp/default.pgo)
131177
echo "PGO profile: ${size} bytes"
@@ -142,8 +188,8 @@ jobs:
142188
go get "github.com/dunglas/frankenphp@v${VERSION}"
143189
go mod tidy
144190
- name: Commit and tag via GitHub API
145-
# API-created commits/tags are signed server-side with GitHub's key
146-
# and show as "Verified" under the dunglas-release[bot] identity.
191+
# API-created commits/tags are signed server-side and show as
192+
# Verified under dunglas-release[bot].
147193
env:
148194
GH_TOKEN: ${{ steps.release-app-token.outputs.token }}
149195
REPO: ${{ github.repository }}
@@ -157,10 +203,8 @@ jobs:
157203
commit_sha="${RELEASE_COMMIT}"
158204
echo "Reusing existing release commit ${commit_sha}"
159205
else
160-
# Stage the base64 in a temp file and feed it to jq via
161-
# --rawfile: passing big blobs (the PGO profile is ~2 MB encoded)
162-
# through --arg exceeds ARG_MAX on the runner. Subshell body +
163-
# EXIT trap clean up the tmpfile even if base64/jq/gh aborts.
206+
# Use --rawfile: the PGO blob (~2 MB encoded) exceeds ARG_MAX
207+
# via --arg.
164208
make_blob() (
165209
local tmp
166210
tmp=$(mktemp)
@@ -171,28 +215,71 @@ jobs:
171215
| gh api "repos/${REPO}/git/blobs" --input - -q .sha
172216
)
173217
218+
# Concurrency is per-version, so a different version could
219+
# land on main while this run is in flight. Abort rather than
220+
# overlay our locally-bumped files on top of unseen commits.
221+
checkout_sha=$(git rev-parse HEAD)
174222
parent_sha=$(gh api "repos/${REPO}/git/refs/heads/main" -q .object.sha)
223+
if [[ "${checkout_sha}" != "${parent_sha}" ]]; then
224+
echo "::error::main advanced from ${checkout_sha} to ${parent_sha} during the run; refusing to overlay locally-modified files on a newer base_tree."
225+
exit 1
226+
fi
175227
base_tree=$(gh api "repos/${REPO}/git/commits/${parent_sha}" -q .tree.sha)
176228
177-
pgo_sha=$(make_blob caddy/frankenphp/default.pgo)
178-
gomod_sha=$(make_blob caddy/go.mod)
179-
gosum_sha=$(make_blob caddy/go.sum)
229+
# Capture every touched file (modifications, additions,
230+
# deletions) so transitive go.sum or PGO side effects aren't
231+
# dropped from the release commit. --no-renames decomposes
232+
# renames into add+delete so both halves land in the tree
233+
# mutation.
234+
mapfile -t modified < <(git diff --no-renames --name-only --diff-filter=ACM HEAD)
235+
mapfile -t deleted < <(git diff --no-renames --name-only --diff-filter=D HEAD)
236+
mapfile -t untracked < <(git ls-files --others --exclude-standard)
237+
if [[ ${#modified[@]} -eq 0 && ${#deleted[@]} -eq 0 && ${#untracked[@]} -eq 0 ]]; then
238+
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."
239+
exit 1
240+
fi
241+
present=("${modified[@]}" "${untracked[@]}")
242+
[[ ${#present[@]} -gt 0 ]] && printf 'Including (added/modified): %s\n' "${present[@]}"
243+
[[ ${#deleted[@]} -gt 0 ]] && printf 'Including (deleted): %s\n' "${deleted[@]}"
244+
245+
# Preserve the existing file mode (executable bit) when
246+
# modifying tracked files; default to 100644 for new files
247+
# unless the path is executable on disk.
248+
mode_for() {
249+
local path="$1" mode
250+
mode=$(git ls-tree HEAD -- "$path" | awk '{print $1; exit}')
251+
if [[ -n "$mode" ]]; then
252+
printf '%s\n' "$mode"
253+
elif [[ -x "$path" ]]; then
254+
printf '100755\n'
255+
else
256+
printf '100644\n'
257+
fi
258+
}
259+
260+
tree_entries=$(
261+
{
262+
for path in "${modified[@]}" "${untracked[@]}"; do
263+
sha=$(make_blob "${path}")
264+
jq -nc --arg path "${path}" --arg sha "${sha}" --arg mode "$(mode_for "${path}")" \
265+
'{path: $path, mode: $mode, type: "blob", sha: $sha}'
266+
done
267+
for path in "${deleted[@]}"; do
268+
# Deletions need a valid mode but it's purely formal —
269+
# the entry only removes the path from the tree.
270+
jq -nc --arg path "${path}" \
271+
'{path: $path, mode: "100644", type: "blob", sha: null}'
272+
done
273+
} | jq -sc .
274+
)
180275
181276
tree_sha=$(jq -nc \
182277
--arg base_tree "$base_tree" \
183-
--arg pgo "$pgo_sha" \
184-
--arg gomod "$gomod_sha" \
185-
--arg gosum "$gosum_sha" \
186-
'{
187-
base_tree: $base_tree,
188-
tree: [
189-
{path: "caddy/frankenphp/default.pgo", mode: "100644", type: "blob", sha: $pgo},
190-
{path: "caddy/go.mod", mode: "100644", type: "blob", sha: $gomod},
191-
{path: "caddy/go.sum", mode: "100644", type: "blob", sha: $gosum}
192-
]
193-
}' | gh api "repos/${REPO}/git/trees" --input - -q .sha)
278+
--argjson entries "${tree_entries}" \
279+
'{base_tree: $base_tree, tree: $entries}' \
280+
| gh api "repos/${REPO}/git/trees" --input - -q .sha)
194281
195-
# [skip ci] keeps push-triggered workflows from firing on top of
282+
# [skip ci] avoids push-triggered workflows firing alongside
196283
# the explicit downstream dispatches below.
197284
commit_sha=$(jq -nc \
198285
--arg message "chore: prepare release ${VERSION} [skip ci]" \
@@ -204,15 +291,19 @@ jobs:
204291
gh api "repos/${REPO}/git/refs/heads/main" -X PATCH -f sha="$commit_sha" --silent
205292
fi
206293
207-
# Idempotent tag creation: if the tag already exists and points at
208-
# the release commit, leave it alone; if it points elsewhere, fail.
294+
# Idempotent: skip if tag already points at the release commit,
295+
# fail if it points elsewhere. matching-refs distinguishes
296+
# "tag absent" (HTTP 200, empty array) from real failures, which
297+
# still trip set -e.
209298
create_tag() {
210299
local tag="$1"
211300
local existing
212-
if existing=$(gh api "repos/${REPO}/git/refs/tags/${tag}" 2>/dev/null); then
301+
existing=$(gh api "repos/${REPO}/git/matching-refs/tags/${tag}" \
302+
--jq ".[] | select(.ref == \"refs/tags/${tag}\") | {sha: .object.sha, type: .object.type}")
303+
if [[ -n "${existing}" ]]; then
213304
local obj_sha obj_type
214-
obj_sha=$(jq -r .object.sha <<<"${existing}")
215-
obj_type=$(jq -r .object.type <<<"${existing}")
305+
obj_sha=$(jq -r .sha <<<"${existing}")
306+
obj_type=$(jq -r .type <<<"${existing}")
216307
if [[ "${obj_type}" == "tag" ]]; then
217308
obj_sha=$(gh api "repos/${REPO}/git/tags/${obj_sha}" -q .object.sha)
218309
fi
@@ -236,15 +327,12 @@ jobs:
236327
create_tag "v${VERSION}"
237328
create_tag "caddy/v${VERSION}"
238329
239-
# Pull the new commit + tags so the release-draft step's git
240-
# describe can resolve v${VERSION}^.
330+
# So the release-draft step's `git describe v${VERSION}^` resolves.
241331
git fetch origin main --tags
242332
- name: Draft GitHub release
243-
# `gh release create` validates the tag through GraphQL, which can
244-
# lag minutes behind the Git Data API we just used to create the
245-
# tag — leading to "no matches found" or `untagged-*` placeholder
246-
# releases. Use the REST releases endpoints directly: they see the
247-
# tag immediately and behave deterministically.
333+
# `gh release create` goes through GraphQL which can lag minutes
334+
# behind the Git Data API and yield "no matches" or `untagged-*`
335+
# placeholder releases; the REST releases endpoint is consistent.
248336
env:
249337
GH_TOKEN: ${{ steps.release-app-token.outputs.token }}
250338
REPO: ${{ github.repository }}
@@ -270,11 +358,9 @@ jobs:
270358
fi
271359
gh api "repos/${REPO}/releases" "${create_args[@]}" --silent
272360
- name: Trigger downstream release builds
273-
# GITHUB_TOKEN-driven API writes don't trigger workflows that listen
274-
# on tag/push events, so dispatch each downstream explicitly. Keep
275-
# going on partial failure so the operator only needs to re-run the
276-
# specific dispatches that didn't go through. Re-dispatch on resume
277-
# is harmless: it just queues another build run.
361+
# GITHUB_TOKEN tag writes don't fire push triggers, so dispatch
362+
# each downstream explicitly. Keep going on partial failure;
363+
# re-dispatch on resume just queues another idempotent build.
278364
env:
279365
GH_TOKEN: ${{ steps.release-app-token.outputs.token }}
280366
REPO: ${{ github.repository }}

0 commit comments

Comments
 (0)