From 39c41bc6817ed69c0f60d1eaf7387a754b5bc7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 16 May 2026 14:12:34 +0200 Subject: [PATCH 1/6] ci: harden release workflow inputs and tag detection Cross-port of dunglas/mercure#1246 review feedback: - Validate the version input inside release.yaml as well, so a UI-triggered dispatch can't slip a non-semver string through. - Replace the fragile "(HTTP 404)" stderr grep with a matching-refs lookup that returns an empty array for absent tags, and extend the split-state guard so a mismatched caddy/v on the resume path is caught up-front instead of inside create_tag. - Build the release tree from `git diff --name-only HEAD` so anything the PGO refresh or `go mod tidy` step touches (beyond the previously hardcoded three paths) is captured. - Restore the three-way behind/ahead/diverged diagnostic in release.sh that the rewrite collapsed into a single equality check. --- .github/workflows/release.yaml | 101 ++++++++++++++++++++++----------- release.sh | 12 +++- 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 59989ee81f..b448a35f33 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,14 +34,23 @@ jobs: LIBRARY_PATH: ${{ github.workspace }}/watcher/target/lib BENCH_SEC: "30" steps: - - name: Refuse non-main dispatch - # workflow_dispatch can target any ref; reject anything but main so a - # mis-dispatched run fails loudly instead of being silently skipped. + - name: Validate inputs + # UI-triggered dispatches bypass release.sh's preflight regex, so + # re-validate the version string here. Any payload that would not + # produce a clean semver tag is rejected before it propagates into + # go get / sed / tag refs and breaks the release in mid-flight. + env: + VERSION: ${{ inputs.version }} + # Adapted from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string run: | 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/checkout@v6 with: fetch-depth: 0 @@ -70,38 +79,57 @@ jobs: # otherwise it's a fresh attempt and tags must not exist. run: | set -euo pipefail - err=$(mktemp) - trap 'rm -f "${err}"' EXIT - # Capture stderr so we can distinguish a real 404 (tag absent → fresh - # attempt) from any other failure (rate limit, 5xx, auth) which must - # not be silently treated as "tag missing". - if ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/v${VERSION}" 2>"${err}"); then - sha=$(jq -r .object.sha <<<"${ref}") - type=$(jq -r .object.type <<<"${ref}") + # matching-refs returns an empty array (still HTTP 200) when the ref + # is absent — no need to parse error wording to distinguish 404 + # from rate-limit/5xx/auth failures, which `gh` still surfaces as a + # non-zero exit through `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 - sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" -q .object.sha) + gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" -q .object.sha + else + printf '%s\n' "${sha}" fi + } + main_entry=$(lookup_tag "v${VERSION}") + caddy_entry=$(lookup_tag "caddy/v${VERSION}") + if [[ -n "${main_entry}" ]]; then + sha=$(resolve_commit "${main_entry}") # Refuse to resume against a tag that isn't reachable from main: # protects against an orphan tag 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 + # Symmetric split-state guard: the create_tag step further down + # also fails if caddy/v${VERSION} points to a different commit, + # but flagging the inconsistency here surfaces it before any + # writes happen. + 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 echo "Resuming: v${VERSION} exists at ${sha}" { echo "resume=true" echo "release_commit=${sha}" } >> "${GITHUB_OUTPUT}" - elif grep -qF "(HTTP 404)" "${err}"; then - echo "resume=false" >> "${GITHUB_OUTPUT}" - if gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/caddy/v${VERSION}" --silent 2>/dev/null; then + 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 - else - echo "::error::GitHub API call for tag v${VERSION} failed:" - cat "${err}" >&2 - exit 1 + echo "resume=false" >> "${GITHUB_OUTPUT}" fi - if: steps.state.outputs.resume != 'true' uses: ./.github/actions/setup-go @@ -171,23 +199,30 @@ jobs: parent_sha=$(gh api "repos/${REPO}/git/refs/heads/main" -q .object.sha) base_tree=$(gh api "repos/${REPO}/git/commits/${parent_sha}" -q .tree.sha) - pgo_sha=$(make_blob caddy/frankenphp/default.pgo) - gomod_sha=$(make_blob caddy/go.mod) - gosum_sha=$(make_blob caddy/go.sum) + # Collect every tracked file modified by the PGO/bump steps so + # that, e.g., a transitive update to a go.sum entry doesn't get + # silently dropped from the release commit. Matches the + # `git commit -a` behavior the script used to rely on. + mapfile -t changed < <(git diff --name-only HEAD) + if [[ ${#changed[@]} -eq 0 ]]; then + echo "::error::No file changes detected after PGO/bump steps." + exit 1 + fi + printf 'Including in release tree: %s\n' "${changed[@]}" + + tree_entries=$( + for path in "${changed[@]}"; do + sha=$(make_blob "${path}") + jq -nc --arg path "${path}" --arg sha "${sha}" \ + '{path: $path, mode: "100644", type: "blob", sha: $sha}' + done | jq -sc . + ) tree_sha=$(jq -nc \ --arg base_tree "$base_tree" \ - --arg pgo "$pgo_sha" \ - --arg gomod "$gomod_sha" \ - --arg gosum "$gosum_sha" \ - '{ - base_tree: $base_tree, - tree: [ - {path: "caddy/frankenphp/default.pgo", mode: "100644", type: "blob", sha: $pgo}, - {path: "caddy/go.mod", mode: "100644", type: "blob", sha: $gomod}, - {path: "caddy/go.sum", mode: "100644", type: "blob", sha: $gosum} - ] - }' | gh api "repos/${REPO}/git/trees" --input - -q .sha) + --argjson entries "${tree_entries}" \ + '{base_tree: $base_tree, tree: $entries}' \ + | gh api "repos/${REPO}/git/trees" --input - -q .sha) # [skip ci] keeps push-triggered workflows from firing on top of # the explicit downstream dispatches below. diff --git a/release.sh b/release.sh index 8ad715d3b8..eaed892c8d 100755 --- a/release.sh +++ b/release.sh @@ -37,8 +37,16 @@ if [[ -n "$(git status --porcelain)" ]]; then fi git fetch --quiet origin main -if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then - echo "Local main does not match origin/main. Pull/sync first; the workflow runs against origin/main." >&2 +local_head="$(git rev-parse HEAD)" +remote_head="$(git rev-parse origin/main)" +if [[ "$local_head" != "$remote_head" ]]; then + if git merge-base --is-ancestor HEAD origin/main; then + echo "Local main is behind origin/main. Pull first." >&2 + elif git merge-base --is-ancestor origin/main HEAD; then + echo "Local main is ahead of origin/main. Push your commits or reset to origin/main before releasing." >&2 + else + echo "Local main has diverged from origin/main. Reconcile with pull/rebase/reset before releasing." >&2 + fi exit 1 fi From 70f6cd66f02a27b9e59bd74951d5b0211a8f5cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 16 May 2026 14:23:47 +0200 Subject: [PATCH 2/6] ci: compact release-workflow comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim explanatory comments to one or two lines each and drop the operator-side semver regex from release.sh — release.yaml validates it. --- .github/workflows/release.yaml | 91 ++++++++++++---------------------- release.sh | 8 +-- 2 files changed, 32 insertions(+), 67 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b448a35f33..b7ecbdac11 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,13 +1,8 @@ name: Release -# Cuts a FrankenPHP release end-to-end: refreshes the PGO profile, bumps the -# Caddy module's frankenphp dependency, commits the result as -# github-actions[bot], tags v and caddy/v, drafts a GitHub -# release, dispatches the downstream binary builds, and opens a Homebrew -# formula bump PR. Dispatched by release.sh. -# -# The workflow is idempotent: re-dispatching after a partial failure (flaky -# test, network blip, registry hiccup) detects which steps already completed -# and skips them, so the release can be resumed without manual cleanup. +# Refreshes PGO, bumps caddy/go.mod, commits as github-actions[bot], +# tags v and caddy/v, 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: @@ -18,8 +13,7 @@ on: type: string permissions: contents: write - # Needed to dispatch the downstream binary build workflows from this run. - actions: write + actions: write # to dispatch downstream binary build workflows concurrency: group: ${{ github.workflow }} cancel-in-progress: false @@ -35,13 +29,10 @@ jobs: BENCH_SEC: "30" steps: - name: Validate inputs - # UI-triggered dispatches bypass release.sh's preflight regex, so - # re-validate the version string here. Any payload that would not - # produce a clean semver tag is rejected before it propagates into - # go get / sed / tag refs and breaks the release in mid-flight. + # 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 }} - # Adapted from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string run: | if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then echo "::error::release.yaml must be dispatched against refs/heads/main, got ${GITHUB_REF}" @@ -59,8 +50,7 @@ jobs: id: classify env: VERSION: ${{ inputs.version }} - # Pre-release versions (those carrying a "-" suffix per SemVer) must - # not be marked --latest nor bump the stable Homebrew formula. + # Pre-releases (SemVer "-" suffix) must not bump --latest or Homebrew. run: | if [[ "${VERSION}" == *-* ]]; then echo "prerelease=true" >> "${GITHUB_OUTPUT}" @@ -72,17 +62,12 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.version }} - # Tag existence is the source of truth for "release in progress": - # main HEAD may have moved past the release commit (a follow-up fix - # merged on top), so the commit-message check on HEAD is too narrow. - # If v exists, resume from the commit it points at; - # otherwise it's a fresh attempt and tags must not exist. + # 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 an empty array (still HTTP 200) when the ref - # is absent — no need to parse error wording to distinguish 404 - # from rate-limit/5xx/auth failures, which `gh` still surfaces as a - # non-zero exit through `set -e`. + # 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}" @@ -102,16 +87,12 @@ jobs: caddy_entry=$(lookup_tag "caddy/v${VERSION}") if [[ -n "${main_entry}" ]]; then sha=$(resolve_commit "${main_entry}") - # Refuse to resume against a tag that isn't reachable from main: - # protects against an orphan tag created on a side branch. + # 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 - # Symmetric split-state guard: the create_tag step further down - # also fails if caddy/v${VERSION} points to a different commit, - # but flagging the inconsistency here surfaces it before any - # writes happen. + # 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 @@ -149,8 +130,7 @@ jobs: run: ./profiles/build-pgo.sh - if: steps.state.outputs.resume != 'true' name: Sanity-check PGO profile - # Catch the degenerate case where wrk silently failed to drive load - # and we ended up shipping a near-empty profile. + # Guard against wrk silently failing and producing a near-empty profile. run: | size=$(wc -c &2 - exit 1 -fi - -# Cheap operator-side guards so the workflow dispatch matches local intent. +# Cheap operator-side guards; release.yaml re-validates the version. if [[ "$(git branch --show-current 2>/dev/null)" != "main" ]]; then echo "You must be on the main branch to dispatch a release." >&2 exit 1 From 4eb7b07294b1dccdfe545745989b1dc6ef27bfe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 16 May 2026 16:54:18 +0200 Subject: [PATCH 3/6] ci: address follow-up Copilot review Cross-port of dunglas/mercure#1246 follow-up: - Use matching-refs in create_tag so transient API failures aren't read as "tag absent". - Build the release tree from `git status` partitions so additions, deletions, and modifications round-trip correctly. - Clarify the "no file changes" error with operator guidance. - Check that git is installed in release.sh. --- .github/workflows/release.yaml | 46 +++++++++++++++++++++++----------- release.sh | 10 +++++--- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b7ecbdac11..57d45aa2e3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -177,21 +177,33 @@ jobs: parent_sha=$(gh api "repos/${REPO}/git/refs/heads/main" -q .object.sha) base_tree=$(gh api "repos/${REPO}/git/commits/${parent_sha}" -q .tree.sha) - # Capture every touched file so transitive go.sum or PGO - # side effects aren't dropped from the release commit. - mapfile -t changed < <(git diff --name-only HEAD) - if [[ ${#changed[@]} -eq 0 ]]; then - echo "::error::No file changes detected after PGO/bump steps." + # Capture every touched file (modifications, additions, + # deletions) so transitive go.sum or PGO side effects aren't + # dropped from the release commit. Deletions are represented + # with a null sha in the tree. + mapfile -t modified < <(git diff --name-only --diff-filter=ACMR HEAD) + mapfile -t deleted < <(git diff --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 - printf 'Including in release tree: %s\n' "${changed[@]}" + present=("${modified[@]}" "${untracked[@]}") + [[ ${#present[@]} -gt 0 ]] && printf 'Including (added/modified): %s\n' "${present[@]}" + [[ ${#deleted[@]} -gt 0 ]] && printf 'Including (deleted): %s\n' "${deleted[@]}" tree_entries=$( - for path in "${changed[@]}"; do - sha=$(make_blob "${path}") - jq -nc --arg path "${path}" --arg sha "${sha}" \ - '{path: $path, mode: "100644", type: "blob", sha: $sha}' - done | jq -sc . + { + for path in "${modified[@]}" "${untracked[@]}"; do + sha=$(make_blob "${path}") + jq -nc --arg path "${path}" --arg sha "${sha}" \ + '{path: $path, mode: "100644", type: "blob", sha: $sha}' + done + for path in "${deleted[@]}"; do + jq -nc --arg path "${path}" \ + '{path: $path, mode: "100644", type: "blob", sha: null}' + done + } | jq -sc . ) tree_sha=$(jq -nc \ @@ -213,14 +225,18 @@ jobs: fi # Idempotent: skip if tag already points at the release commit, - # fail if it points elsewhere. + # 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 - if existing=$(gh api "repos/${REPO}/git/refs/tags/${tag}" 2>/dev/null); then + 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 .object.sha <<<"${existing}") - obj_type=$(jq -r .object.type <<<"${existing}") + 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 diff --git a/release.sh b/release.sh index 943031d3ed..d69f09edf3 100755 --- a/release.sh +++ b/release.sh @@ -9,10 +9,12 @@ set -o errexit set -o pipefail trap 'echo "Aborting on line $LINENO. Exit: $?" >&2' ERR -if ! command -v gh >/dev/null; then - echo 'The "gh" command must be installed.' >&2 - exit 1 -fi +for cmd in git gh; do + if ! command -v "$cmd" >/dev/null; then + echo "The \"$cmd\" command must be installed." >&2 + exit 1 + fi +done if [[ $# -ne 1 ]]; then echo "Usage: ./release.sh version" >&2 From 10e075bb8f3768b786645fb27d09e1b112e28c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 16 May 2026 17:43:24 +0200 Subject: [PATCH 4/6] ci: address second Copilot follow-up Cross-port of dunglas/mercure#1246 follow-up: - Verify the resumed commit actually contains the expected caddy/go.mod frankenphp pin and a non-trivial PGO profile before re-tagging or dispatching downstream builds. - Use --no-renames so renames decompose into add+delete and both halves land in the API tree mutation. - Preserve each file's existing mode (executable bit) when building tree entries instead of hardcoding 100644. - Scope the concurrency group per version so a pending environment approval doesn't block dispatches for a different version. --- .github/workflows/release.yaml | 51 ++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 57d45aa2e3..9ef2705aa0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,7 +15,10 @@ permissions: contents: write actions: write # to dispatch downstream binary build workflows concurrency: - group: ${{ github.workflow }} + # 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: @@ -100,6 +103,20 @@ jobs: exit 1 fi fi + # Verify the tagged commit actually contains the expected + # bump — protects against a tag created manually or by an + # earlier run on stale code. + git fetch --quiet origin "refs/tags/v${VERSION}:refs/tags/v${VERSION}" + if ! git show "v${VERSION}:caddy/go.mod" \ + | grep -qE "^[[:space:]]+github\\.com/dunglas/frankenphp v${VERSION//./\\.}\$"; then + echo "::error::v${VERSION} (${sha}) caddy/go.mod does not require frankenphp v${VERSION}." + exit 1 + fi + pgo_size=$(git cat-file -s "v${VERSION}:caddy/frankenphp/default.pgo" 2>/dev/null || echo 0) + if [[ "${pgo_size}" -lt 1024 ]]; then + echo "::error::v${VERSION} (${sha}) PGO profile is missing or suspiciously small (${pgo_size} bytes)." + exit 1 + fi echo "Resuming: v${VERSION} exists at ${sha}" { echo "resume=true" @@ -179,10 +196,11 @@ jobs: # Capture every touched file (modifications, additions, # deletions) so transitive go.sum or PGO side effects aren't - # dropped from the release commit. Deletions are represented - # with a null sha in the tree. - mapfile -t modified < <(git diff --name-only --diff-filter=ACMR HEAD) - mapfile -t deleted < <(git diff --name-only --diff-filter=D HEAD) + # 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." @@ -192,16 +210,31 @@ jobs: [[ ${#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}" \ - '{path: $path, mode: "100644", type: "blob", sha: $sha}' + 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 - jq -nc --arg path "${path}" \ - '{path: $path, mode: "100644", type: "blob", sha: null}' + jq -nc --arg path "${path}" --arg mode "$(mode_for "${path}")" \ + '{path: $path, mode: $mode, type: "blob", sha: null}' done } | jq -sc . ) From 78cc01e22adeda6121f64f8e45d27bb91d20362f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 16 May 2026 18:08:45 +0200 Subject: [PATCH 5/6] ci: address third Copilot follow-up Cross-port of dunglas/mercure#1246 follow-up: - Match the frankenphp require entry in caddy/go.mod under both block and single-line forms so a future `go mod tidy` reformat doesn't fail the resume verification. - Detect a release-shaped main HEAD without tags as resumable, covering the case where a previous run pushed the commit but failed before create_tag. - Guard against main advancing between checkout and the commit step by asserting parent_sha equals the original checkout SHA before building the API tree. - release.sh: restore set -o errtrace so the ERR trap fires inside any future helpers, and fetch tags so the operator sees existing release tags before dispatching. --- .github/workflows/release.yaml | 46 ++++++++++++++++++++++++++-------- release.sh | 3 ++- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9ef2705aa0..7a494d9909 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -86,6 +86,22 @@ jobs: printf '%s\n' "${sha}" fi } + # Match the frankenphp require entry in both `require ( ... )` + # block form and single-line `require x v...` form. + 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 @@ -103,21 +119,23 @@ jobs: exit 1 fi fi - # Verify the tagged commit actually contains the expected - # bump — protects against a tag created manually or by an - # earlier run on stale code. git fetch --quiet origin "refs/tags/v${VERSION}:refs/tags/v${VERSION}" - if ! git show "v${VERSION}:caddy/go.mod" \ - | grep -qE "^[[:space:]]+github\\.com/dunglas/frankenphp v${VERSION//./\\.}\$"; then - echo "::error::v${VERSION} (${sha}) caddy/go.mod does not require frankenphp v${VERSION}." + if ! verify_release_content "v${VERSION}"; then + echo "::error::v${VERSION} (${sha}) does not match expected release content." exit 1 fi - pgo_size=$(git cat-file -s "v${VERSION}:caddy/frankenphp/default.pgo" 2>/dev/null || echo 0) - if [[ "${pgo_size}" -lt 1024 ]]; then - echo "::error::v${VERSION} (${sha}) PGO profile is missing or suspiciously small (${pgo_size} bytes)." + 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 - echo "Resuming: v${VERSION} exists at ${sha}" + 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}" @@ -191,7 +209,15 @@ jobs: | 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, diff --git a/release.sh b/release.sh index d69f09edf3..abc9129e57 100755 --- a/release.sh +++ b/release.sh @@ -6,6 +6,7 @@ set -o nounset set -o errexit +set -o errtrace # so the ERR trap fires inside functions/subshells too set -o pipefail trap 'echo "Aborting on line $LINENO. Exit: $?" >&2' ERR @@ -32,7 +33,7 @@ if [[ -n "$(git status --porcelain)" ]]; then exit 1 fi -git fetch --quiet origin main +git fetch --quiet --tags origin main local_head="$(git rev-parse HEAD)" remote_head="$(git rev-parse origin/main)" if [[ "$local_head" != "$remote_head" ]]; then From 4019a2235068d4ab295b1875b3b3a5caadd7d577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 18 May 2026 15:11:12 +0200 Subject: [PATCH 6/6] ci: address fourth Copilot follow-up Cross-port of dunglas/mercure#1246 follow-up: - Add `set -euo pipefail` to the Validate inputs step. - Hardcode mode 100644 for deletion entries since the mode is purely formal when sha is null. - Reword the verify_release_content comment to spell out that omitting the `require` keyword from the grep is what makes it match both block and single-line layouts. --- .github/workflows/release.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7a494d9909..40d13fbbf3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -37,6 +37,7 @@ jobs: 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 @@ -86,8 +87,9 @@ jobs: printf '%s\n' "${sha}" fi } - # Match the frankenphp require entry in both `require ( ... )` - # block form and single-line `require x v...` form. + # 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 \ @@ -259,8 +261,10 @@ jobs: '{path: $path, mode: $mode, type: "blob", sha: $sha}' done for path in "${deleted[@]}"; do - jq -nc --arg path "${path}" --arg mode "$(mode_for "${path}")" \ - '{path: $path, mode: $mode, type: "blob", sha: null}' + # 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 . )