bug(release-decide): Layer 2 must use head.label not head.ref — Forgejo replaces head.ref with refs/pull/N/head on auto-delete-branch merge #92

Closed
opened 2026-06-26 09:29:25 +02:00 by quartermaster · 0 comments

Symptom

When operator merged the v0.6.0 rolling PR #90, the cut workflow fired but emitted mode=noop instead of mode=cut. The first task ran in 4s (decide+act) + the second in 0s (release job) + manifest stayed at v0.5.0 + no v0.6.0 draft was created.

Re-dispatching via workflow_dispatch produced the same mode=noop result. Both runs identical: act on decision: noop: no_release_relevant_content — nothing to do.

Root cause

Verified at source: Forgejo's default_delete_branch_after_merge: true (the toolkit repo's setting) deletes the head branch when a PR merges. After deletion, the PR's head.ref is replaced from the original branch name (release-prep/rolling) to the pull-request internal ref (refs/pull/90/head).

The PR API shows for #90 (verified post-merge):

{
  "head_ref": "refs/pull/90/head",       // BROKEN — was 'release-prep/rolling' pre-merge
  "head_label": "release-prep/rolling",  // PRESERVED across branch lifecycle
  "head_sha": "ba10f67c..."
}

My Layer 2 check (release-decide.sh:226) does [[ "$head_ref" == "$ROLLING_BRANCH_NAME" ]] → FALSE because head.ref is now refs/pull/90/head. Layer 2 returns fail → fall-through to mode=update → bump determination finds zero release-relevant content (fragment already consumed in prep commit; cc-commits are chore(...) + fold(...) + fix+feat: non-standard prefixes) → mode=noop with reason=no_release_relevant_content.

Fix

Use head.label (or head.label.split(":")[-1]) instead of head.ref for the branch-source check. head.label is the canonical "owner:branch-name" form that PRESERVES across the branch-lifecycle.

Concretely:

# In check_layer2_branch_source — instead of:
head_ref=$(printf '%s' "$pr_json" | jq -r '.head.ref // empty')
if [[ "$head_ref" == "$ROLLING_BRANCH_NAME" ]]; then
    printf 'pass\n'
fi

# Use:
head_label=$(printf '%s' "$pr_json" | jq -r '.head.label // empty')
# head_label is "owner:branch-name"; extract the branch part
head_branch="${head_label#*:}"
if [[ "$head_branch" == "$ROLLING_BRANCH_NAME" ]]; then
    printf 'pass\n'
fi

Apply the same fix to forgejo_find_pr_by_merge_sha's downstream consumers (if any).

Also affects #87 stale rolling PR cleanup

The new #87 cleanup queries OPEN PRs with head.ref == "release-prep/rolling" — but for a stale rolling PR that was opened pre-fall-through, the head.ref should still be release-prep/rolling (no merge yet → no branch deletion). So #87 is likely fine. But worth dual-checking after the Layer 2 fix lands — the rolling branch is the STABLE rolling identity; if it gets recreated after the cleanup deletion-on-merge, the substrate behavior could shift.

Same lesson class as AGENTS.md §8 4th instance

I assumed head.ref preserves across the branch lifecycle without verifying. Surveyor's source-grounded-vs-invented discipline applies AGAIN — 5th empirical instance of the §8 axis. Banking as a v0.5.2 AGENTS.md update to surface n=5 promoting the axis to a named project pattern per the empirical-promotion criterion.

Recovery (already done for v0.6.0)

  • Manually ran scripts/draft-release.sh --version 0.6.0 --commit ba10f67c → draft release id=125 created
  • Manually updated manifest to v0.6.0 lineage + pushed to main (admin bypass at d44546e)
  • Operator can click Publish on the draft to ship v0.6.0 GA

Refs

  • Surfaced: 2026-06-26 v0.6.0 cut on operator's merge of #90 (and re-dispatch)
  • Sibling: #86 (Layer 2 retry — fixed in v0.5.1; this issue is a SEPARATE Layer 2 substrate gap that retry-with-backoff doesn't address)
  • Sister: #64 (Layer-2/3 FAIL-path coverage — the new bats tests use release-prep/rolling directly without exercising the post-merge branch-deletion case; v0.5.2 test should cover head.ref-replaced scenario)
  • Carry-forward class: 5th instance of source-grounded-vs-invented (per AGENTS.md §8 4th-instance entry + this new catch)
  • v0.5.2 substrate-correctness sweep candidate alongside #56 + the prior #86/#87 stragglers
## Symptom When operator merged the v0.6.0 rolling PR #90, the cut workflow fired but emitted `mode=noop` instead of `mode=cut`. The first task ran in 4s (decide+act) + the second in 0s (release job) + manifest stayed at v0.5.0 + no v0.6.0 draft was created. Re-dispatching via `workflow_dispatch` produced the same `mode=noop` result. Both runs identical: `act on decision: noop: no_release_relevant_content — nothing to do`. ## Root cause Verified at source: Forgejo's `default_delete_branch_after_merge: true` (the toolkit repo's setting) deletes the head branch when a PR merges. After deletion, the PR's `head.ref` is **replaced** from the original branch name (`release-prep/rolling`) to the pull-request internal ref (`refs/pull/90/head`). The PR API shows for #90 (verified post-merge): ```json { "head_ref": "refs/pull/90/head", // BROKEN — was 'release-prep/rolling' pre-merge "head_label": "release-prep/rolling", // PRESERVED across branch lifecycle "head_sha": "ba10f67c..." } ``` My Layer 2 check (`release-decide.sh:226`) does `[[ "$head_ref" == "$ROLLING_BRANCH_NAME" ]]` → FALSE because `head.ref` is now `refs/pull/90/head`. Layer 2 returns `fail` → fall-through to mode=update → bump determination finds zero release-relevant content (fragment already consumed in prep commit; cc-commits are `chore(...)` + `fold(...)` + `fix+feat:` non-standard prefixes) → mode=noop with `reason=no_release_relevant_content`. ## Fix Use `head.label` (or `head.label.split(":")[-1]`) instead of `head.ref` for the branch-source check. `head.label` is the canonical "owner:branch-name" form that PRESERVES across the branch-lifecycle. Concretely: ```bash # In check_layer2_branch_source — instead of: head_ref=$(printf '%s' "$pr_json" | jq -r '.head.ref // empty') if [[ "$head_ref" == "$ROLLING_BRANCH_NAME" ]]; then printf 'pass\n' fi # Use: head_label=$(printf '%s' "$pr_json" | jq -r '.head.label // empty') # head_label is "owner:branch-name"; extract the branch part head_branch="${head_label#*:}" if [[ "$head_branch" == "$ROLLING_BRANCH_NAME" ]]; then printf 'pass\n' fi ``` Apply the same fix to `forgejo_find_pr_by_merge_sha`'s downstream consumers (if any). ## Also affects #87 stale rolling PR cleanup The new #87 cleanup queries OPEN PRs with `head.ref == "release-prep/rolling"` — but for a stale rolling PR that was opened pre-fall-through, the head.ref should still be `release-prep/rolling` (no merge yet → no branch deletion). So #87 is likely fine. **But worth dual-checking after the Layer 2 fix lands** — the rolling branch is the STABLE rolling identity; if it gets recreated after the cleanup deletion-on-merge, the substrate behavior could shift. ## Same lesson class as AGENTS.md §8 4th instance I assumed `head.ref` preserves across the branch lifecycle without verifying. Surveyor's source-grounded-vs-invented discipline applies AGAIN — **5th empirical instance** of the §8 axis. Banking as a v0.5.2 AGENTS.md update to surface n=5 promoting the axis to a named project pattern per the empirical-promotion criterion. ## Recovery (already done for v0.6.0) - Manually ran `scripts/draft-release.sh --version 0.6.0 --commit ba10f67c` → draft release id=125 created - Manually updated manifest to v0.6.0 lineage + pushed to main (admin bypass at d44546e) - Operator can click Publish on the draft to ship v0.6.0 GA ## Refs - **Surfaced**: 2026-06-26 v0.6.0 cut on operator's merge of #90 (and re-dispatch) - **Sibling**: [#86](https://git.frankenbit.de/frankenbit/release-toolkit/issues/86) (Layer 2 retry — fixed in v0.5.1; this issue is a SEPARATE Layer 2 substrate gap that retry-with-backoff doesn't address) - **Sister**: [#64](https://git.frankenbit.de/frankenbit/release-toolkit/issues/64) (Layer-2/3 FAIL-path coverage — the new bats tests use `release-prep/rolling` directly without exercising the post-merge branch-deletion case; v0.5.2 test should cover head.ref-replaced scenario) - **Carry-forward class**: 5th instance of source-grounded-vs-invented (per AGENTS.md §8 4th-instance entry + this new catch) - **v0.5.2 substrate-correctness sweep candidate** alongside #56 + the prior #86/#87 stragglers
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#92
No description provided.