11name : 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.
116on :
127 workflow_dispatch :
138 inputs :
1813 type : string
1914permissions : {}
2015concurrency :
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
2321jobs :
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 :
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