diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 59989ee81f..7a494d9909 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,10 +13,12 @@ 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 }} + # 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: @@ -34,14 +31,20 @@ 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 + # 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: | 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 @@ -50,8 +53,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}" @@ -63,45 +65,87 @@ 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 - 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 [] (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 - 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 - # Refuse to resume against a tag that isn't reachable from main: - # protects against an orphan tag created on a side branch. + } + # 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 + 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 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 + 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 - echo "::error::GitHub API call for tag v${VERSION} failed:" - cat "${err}" >&2 - exit 1 + 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 @@ -121,8 +165,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 /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 @@ -233,15 +319,12 @@ jobs: create_tag "v${VERSION}" create_tag "caddy/v${VERSION}" - # Pull the new commit + tags so the release-draft step's git - # describe can resolve 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` validates the tag through GraphQL, which can - # lag minutes behind the Git Data API we just used to create the - # tag — leading to "no matches found" or `untagged-*` placeholder - # releases. Use the REST releases endpoints directly: they see the - # tag immediately and behave deterministically. + # `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: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} @@ -267,11 +350,9 @@ jobs: fi gh api "repos/${REPO}/releases" "${create_args[@]}" --silent - name: Trigger downstream release builds - # GITHUB_TOKEN-driven API writes don't trigger workflows that listen - # on tag/push events, so dispatch each downstream explicitly. Keep - # going on partial failure so the operator only needs to re-run the - # specific dispatches that didn't go through. Re-dispatch on resume - # is harmless: it just queues another build run. + # 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: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} diff --git a/release.sh b/release.sh index 8ad715d3b8..abc9129e57 100755 --- a/release.sh +++ b/release.sh @@ -6,26 +6,23 @@ 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 -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 exit 1 fi -# Adapted from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -if [[ ! $1 =~ ^(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 "Invalid version number: $1" >&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 @@ -36,9 +33,17 @@ if [[ -n "$(git status --porcelain)" ]]; then exit 1 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 +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 + 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