diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index ca11fa1..7b07a8b 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -270,7 +270,8 @@ set_required_bots() { # the PR author is recused by construction # The state machine. Pure functions over these globals, set per PR: # DRAFT true|false # HEAD_SHA the PR's current head commit -# BASE_SHA the PR's base branch head (the release-shape guard's ref) +# BASE_SHA the PR's base branch head +# MERGE_BASE_SHA the PR's merge base (the release-shape guard's ref) # REQUESTED newline-separated logins with a review currently requested # REVIEWS_JSON JSON array of submitted, gradeable reviews # MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) @@ -918,7 +919,8 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch # Drafts are exempt (the build phase is the builder's); the version # reads cost two API calls and only on PRs missing the label. if [ "$DRAFT" != true ] && ! has_label release; then - release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")" + release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \ + "$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")" fi # ---- merge-next: cleared, never set ---------------------------------- @@ -1031,6 +1033,7 @@ main() { set_required_bots "$AUTHOR" HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")" + MERGE_BASE_SHA="$(jq -r '.merge_base // empty' <<<"$PR_JSON")" LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" # This allow-list answers whether a row is a submitted, gradeable review; # bot_verdict separately answers what that submitted verdict says (#235). diff --git a/changelog.d/275.md b/changelog.d/275.md new file mode 100644 index 0000000..c4c4118 --- /dev/null +++ b/changelog.d/275.md @@ -0,0 +1,3 @@ +### Fixed + +- Compare release-shaped pull requests with their merge base so later base-branch releases do not create phantom version-change warnings (#275). diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 9dd791c..14b7ebc 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -175,6 +175,93 @@ expect "an unreadable head version is silent — never nag on a guess" "" \ expect "a bare head over an unreadable base still warns" yes \ "$(release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no)" +# The pure matrix above proves the warning predicate. These two fixtures drive +# the sweep boundary that chooses WHICH base tree feeds it (#275): the +# reporting Forgejo payload exposes the PR's moving base tip beside its fixed +# merge base, and using the former manufactures a downgrade after an +# intervening release. A missing merge-base field deliberately keeps the +# specified base-tip fallback for backends that do not supply that fact. +release_shape_ref_probe() { # $1 = phantom | bump | fallback + ( + # shellcheck disable=SC2030 # this probe intentionally isolates its repository fixture + REPO=owner/repo + LABELS_CONF="$FIXTURE_CONF" + CEREMONY_FORGE=github + mode="$1" + refs="$RTMP/release-shape-$mode-refs" + : >"$refs" + case "$mode" in + phantom) + head_ver=1.3.0 base_tip_ver=1.4.0 merge_base_ver=1.3.0 + merge_base_json='"merge-base"' ;; + bump) + head_ver=1.4.0 base_tip_ver=1.3.0 merge_base_ver=1.3.0 + merge_base_json='"merge-base"' ;; + fallback) + head_ver=1.4.0 base_tip_ver=1.4.0 merge_base_ver=unused + merge_base_json=null ;; + *) return 2 ;; + esac + # shellcheck disable=SC2317 # reached through the GitHub backend selected above + gh() { + if [ "$1" = label ] && [ "$2" = list ]; then + core_label_rows | cut -d'|' -f1 + return 0 + fi + if [ "$1" = pr ] && [ "$2" = list ]; then + printf '701\n' + return 0 + fi + if [ "$1" = pr ] && [ "$2" = view ]; then + jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}' + return 0 + fi + if [ "$1" = issue ] && [ "$2" = edit ]; then return 0; fi + case "$(forge_stub_path "$*")" in + *'repos/owner/repo/pulls/701 --jq .requested_reviewers'*) return 0 ;; + *repos/owner/repo/pulls/701/reviews*) return 0 ;; + *repos/owner/repo/pulls/701) + jq -n --argjson merge_base "$merge_base_json" \ + '{draft:false,user:{login:"fixture-builder"}, + head:{sha:"head"},base:{sha:"base-tip"},merge_base:$merge_base, + labels:[{name:"state:addressing"},{name:"blocked"}], + requested_reviewers:[],created_at:"2026-08-31T20:00:00Z"}' ;; + *repos/owner/repo/commits/head*) printf '2026-08-31T20:00:00Z\n' ;; + *repos/owner/repo/contents/VERSION\?ref=head*) + printf 'head\n' >>"$refs" + printf '%s' "$head_ver" | base64 ;; + *repos/owner/repo/contents/VERSION\?ref=base-tip*) + printf 'base-tip\n' >>"$refs" + printf '%s' "$base_tip_ver" | base64 ;; + *repos/owner/repo/contents/VERSION\?ref=merge-base*) + printf 'merge-base\n' >>"$refs" + printf '%s' "$merge_base_ver" | base64 ;; + *) printf '[]\n' ;; + esac + } + main + ) +} + +phantom_shape="$(release_shape_ref_probe phantom)" +expect "an unchanged branch cut before a later base release emits no release-shape warning" \ + no "$(grep -q 'release-shaped' <<<"$phantom_shape" && echo yes || echo no)" +expect "the unchanged-branch guard compares head with the PR merge base" \ + $'head\nmerge-base' "$(cat "$RTMP/release-shape-phantom-refs")" + +bump_shape="$(release_shape_ref_probe bump)" +expect "a genuine version bump relative to the merge base keeps the warning text" \ + yes "$(grep -qF '#701 is release-shaped (version 1.3.0 -> 1.4.0 at its head)' \ + <<<"$bump_shape" && echo yes || echo no)" +expect "the genuine-bump guard also compares head with the PR merge base" \ + $'head\nmerge-base' "$(cat "$RTMP/release-shape-bump-refs")" + +fallback_shape="$(release_shape_ref_probe fallback)" +expect "a null merge base falls back to the base tip without guessing a warning" \ + no "$(grep -q 'release-shaped' <<<"$fallback_shape" && echo yes || echo no)" +expect "the null merge-base fallback compares head with the base tip" \ + $'head\nbase-tip' "$(cat "$RTMP/release-shape-fallback-refs")" + # -- drafts are building, whoever is requested -------------------------------- DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' expect "draft PR is building" state:building "$(decide_state)"