From 05511a0a062b76d74d35d9694742f35392a1961e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 15 May 2026 15:34:59 +0200 Subject: [PATCH 1/3] fix(release): detect resume by tag, not by HEAD commit message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous logic only recognised a resume when main HEAD itself was the `chore: prepare release X [skip ci]` commit. If a follow-up commit landed on top (a hotfix to the release workflow itself, in v1.12.3's case), HEAD was no longer the release commit and the tag-exists guard fired, refusing to resume — even though the rest of the work was already done and the commit was still in main's history. Switch to tag-based detection: if v exists on origin, this is a resume; pull the release commit SHA from the tag's object (deref the annotated tag). The tag is the actual artifact we're trying to land, so it's the right resume signal regardless of where main HEAD has moved. A bare caddy/v without v still aborts as a split- state guard. --- .github/workflows/release.yaml | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7b47c006fb..477a182cc7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -63,28 +63,30 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.version }} - # Detect whether main already carries the release commit (we're - # resuming after a mid-flight failure) or whether this is a fresh - # release attempt. The downstream steps gate themselves on `resume`. + # 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. run: | set -euo pipefail - main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/heads/main" -q .object.sha) - first_line=$(gh api "repos/${GITHUB_REPOSITORY}/git/commits/${main_sha}" -q .message | head -n1) - expected="chore: prepare release ${VERSION} [skip ci]" - if [[ "${first_line}" == "${expected}" ]]; then - echo "Resuming: release commit already at ${main_sha}" + if ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/v${VERSION}" 2>/dev/null); then + sha=$(jq -r .object.sha <<<"${ref}") + type=$(jq -r .object.type <<<"${ref}") + if [[ "${type}" == "tag" ]]; then + sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" -q .object.sha) + fi + echo "Resuming: v${VERSION} exists at ${sha}" { echo "resume=true" - echo "release_commit=${main_sha}" + echo "release_commit=${sha}" } >> "${GITHUB_OUTPUT}" else echo "resume=false" >> "${GITHUB_OUTPUT}" - for tag in "v${VERSION}" "caddy/v${VERSION}"; do - if gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/${tag}" --silent 2>/dev/null; then - echo "::error::Tag ${tag} exists but main HEAD is not the release commit; refusing to release." - exit 1 - fi - done + if gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/caddy/v${VERSION}" --silent 2>/dev/null; then + echo "::error::caddy/v${VERSION} exists but v${VERSION} does not; refusing to release into a split state." + exit 1 + fi fi - if: steps.state.outputs.resume != 'true' uses: ./.github/actions/setup-go From 73f66dbab8d3d1846495fc8b8ddfa731d8186515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 15 May 2026 15:40:17 +0200 Subject: [PATCH 2/3] fix(release): refuse to resume from an orphan tag Per Copilot review: resume-by-tag-existence proceeds even when the tag's target isn't reachable from main (orphan tag created on a side branch). Add a `git merge-base --is-ancestor` check so we abort rather than skip the bump/commit steps against unreachable history. --- .github/workflows/release.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 477a182cc7..47cd96c633 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -76,6 +76,12 @@ jobs: if [[ "${type}" == "tag" ]]; then sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" -q .object.sha) fi + # 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 echo "Resuming: v${VERSION} exists at ${sha}" { echo "resume=true" From 771839790e3ab7b5a978be329778e209e240d956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 15 May 2026 15:51:32 +0200 Subject: [PATCH 3/3] fix(release): fail closed on non-404 errors during tag lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Copilot review (flagged twice): `if ref=$(gh api ...); then` swallowed transient API failures (rate limit, 5xx, auth) as "tag missing", silently downgrading a resume into a fresh attempt with no visible state divergence. Capture stderr, branch on whether it contains `(HTTP 404)`: only that case is a true "tag absent → fresh attempt". Anything else aborts with the original stderr surfaced. --- .github/workflows/release.yaml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 47cd96c633..ec70bd2714 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -70,7 +70,12 @@ jobs: # otherwise it's a fresh attempt and tags must not exist. run: | set -euo pipefail - if ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/v${VERSION}" 2>/dev/null); then + 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}") if [[ "${type}" == "tag" ]]; then @@ -87,12 +92,16 @@ jobs: echo "resume=true" echo "release_commit=${sha}" } >> "${GITHUB_OUTPUT}" - else + 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 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 fi - if: steps.state.outputs.resume != 'true' uses: ./.github/actions/setup-go