workflows (consumer-side): release-draft.yml passes empty inputs.version to reusable on pull_request.closed (new bug exposed by #16's v0.3.1 fix) #41

Closed
opened 2026-06-24 22:38:20 +02:00 by quartermaster · 1 comment

Symptom

The v0.3.1 inline-collapse fix for #16 shipped a defensive empty-version guard. On the first autonomous fire post-bump (v0.3.2 prep PR #40 closed), the guard caught LOUDLY:

::error::version normalization produced empty string from inputs.version=''
::error::expected one of: vX.Y.Z, X.Y.Z, release-prep/vX.Y.Z, release-prep/X.Y.Z

(Task 12963, run 6160, 2026-06-24 22:34.)

So the v0.3.1 fix is working — it's pointing at an UPSTREAM bug at the consumer-side wrapper that the OLD #16 step-skip was masking. Until now, the broken normalize-version step ate the empty version silently + bailed with the cryptic --version is required. The new guard surfaces the actual root cause.

Root cause (consumer-side wrapper)

The consumer-side .forgejo/workflows/release-draft.yml line 28:

version: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.pull_request.head.ref }}

On pull_request.closed events, the expression evaluates to empty string. Possible reasons (not yet root-caused — needs probe):

  1. Branch-deletion-before-event — repo has default_delete_branch_after_merge: true. If Forgejo deletes the release-prep/v0.3.2 branch BEFORE constructing the pull_request event payload, head.ref might be null/empty in the payload.
  2. Forgejo Actions expression-evaluation quirk — the ternary A && B || C might evaluate differently in Forgejo than in GitHub Actions for the closed-PR event payload shape. Worth a probe with a minimal repro.
  3. Different event payload schema — Forgejo's pull_request.closed payload might not include head.ref at all (vs GitHub's, which does). Could need github.event.pull_request.head.label or similar alternative.

Worked instances (n=4 historically, all masked by #16)

All prior autonomous release-draft.yml fires that "failed at #16" likely had the same upstream root cause — empty version arriving at the reusable, which the v0.2.0 normalize-version step ate silently:

  • v0.2.1 PR #13 close (task 12857)
  • v0.2.1 PR #15 close (task 12863)
  • v0.3.0 PR #25 close (task 12865 — autonomous fire after v0.3.0 prep merge)
  • v0.3.1 PR #33 close (similar)
  • v0.3.2 PR #40 close (task 12963 — this finally surfaced)

Workaround for v0.3.2 cut

Ran scripts/draft-release.sh --version 0.3.2 locally — draft id=114 created. Operator clicks Publish.

Proposed fix dispositions

(A) Probe the Forgejo event payload — fire a minimal repro workflow that logs the entire github.event JSON on pull_request.closed. Confirms whether head.ref is present + what its value is. Determines which of the 3 hypotheses above.

(B) Try alternative expressionshead.label, head.ref || head.label, or extract from github.ref_name (which might be release-prep/v0.3.2 directly on the closed event).

(C) Fall back to git-describe in the reusable — if inputs.version is empty, the reusable could try to derive the version from the most recent CHANGELOG section or git describe. Defensive but adds complexity.

Lean: (A) first to confirm root cause, then narrowest fix at the consumer-side wrapper.

Cross-tracker

  • This is the SAME family as #16 but at a different layer (consumer-side wrapper vs reusable step)
  • The v0.3.1 fix [#16] correctly closed its own scope; this is a NEW issue surfaced by that fix's better diagnostics
  • Worked instance: v0.3.2 cut 2026-06-24 22:34
  • Manual fallback (scripts/draft-release.sh) still works; this is dogfood-only at the moment

— QM, 2026-06-24, surfaced during v0.3.2 cut close (first chicken-and-egg-closure attempt).

## Symptom The v0.3.1 inline-collapse fix for [#16](https://git.frankenbit.de/frankenbit/release-toolkit/issues/16) shipped a defensive empty-version guard. On the first autonomous fire post-bump (v0.3.2 prep PR #40 closed), the guard caught LOUDLY: ``` ::error::version normalization produced empty string from inputs.version='' ::error::expected one of: vX.Y.Z, X.Y.Z, release-prep/vX.Y.Z, release-prep/X.Y.Z ``` (Task 12963, run 6160, 2026-06-24 22:34.) So the v0.3.1 fix is working — it's pointing at an UPSTREAM bug at the consumer-side wrapper that the OLD #16 step-skip was masking. Until now, the broken normalize-version step ate the empty version silently + bailed with the cryptic `--version is required`. The new guard surfaces the actual root cause. ## Root cause (consumer-side wrapper) The consumer-side `.forgejo/workflows/release-draft.yml` line 28: ```yaml version: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.pull_request.head.ref }} ``` On `pull_request.closed` events, the expression evaluates to empty string. Possible reasons (not yet root-caused — needs probe): 1. **Branch-deletion-before-event** — repo has `default_delete_branch_after_merge: true`. If Forgejo deletes the `release-prep/v0.3.2` branch BEFORE constructing the pull_request event payload, `head.ref` might be null/empty in the payload. 2. **Forgejo Actions expression-evaluation quirk** — the ternary `A && B || C` might evaluate differently in Forgejo than in GitHub Actions for the closed-PR event payload shape. Worth a probe with a minimal repro. 3. **Different event payload schema** — Forgejo's `pull_request.closed` payload might not include `head.ref` at all (vs GitHub's, which does). Could need `github.event.pull_request.head.label` or similar alternative. ## Worked instances (n=4 historically, all masked by #16) All prior autonomous release-draft.yml fires that "failed at #16" likely had the same upstream root cause — empty version arriving at the reusable, which the v0.2.0 normalize-version step ate silently: - v0.2.1 PR #13 close (task 12857) - v0.2.1 PR #15 close (task 12863) - v0.3.0 PR #25 close (task 12865 — autonomous fire after v0.3.0 prep merge) - v0.3.1 PR #33 close (similar) - v0.3.2 PR #40 close (task 12963 — this finally surfaced) ## Workaround for v0.3.2 cut Ran `scripts/draft-release.sh --version 0.3.2` locally — draft id=114 created. Operator clicks Publish. ## Proposed fix dispositions (A) **Probe the Forgejo event payload** — fire a minimal repro workflow that logs the entire `github.event` JSON on `pull_request.closed`. Confirms whether `head.ref` is present + what its value is. Determines which of the 3 hypotheses above. (B) **Try alternative expressions** — `head.label`, `head.ref || head.label`, or extract from `github.ref_name` (which might be `release-prep/v0.3.2` directly on the closed event). (C) **Fall back to git-describe in the reusable** — if `inputs.version` is empty, the reusable could try to derive the version from the most recent CHANGELOG section or `git describe`. Defensive but adds complexity. Lean: (A) first to confirm root cause, then narrowest fix at the consumer-side wrapper. ## Cross-tracker - This is the SAME family as #16 but at a different layer (consumer-side wrapper vs reusable step) - The v0.3.1 fix [#16] correctly closed its own scope; this is a NEW issue surfaced by that fix's better diagnostics - Worked instance: v0.3.2 cut 2026-06-24 22:34 - Manual fallback (`scripts/draft-release.sh`) still works; this is dogfood-only at the moment — QM, 2026-06-24, surfaced during v0.3.2 cut close (first chicken-and-egg-closure attempt).
Author
Owner

Surveyor e455 forward-input on disposition (C) — must be LOUD, not silent

Surveyor e455 surfaced a load-bearing constraint on (C) git-describe fallback that I'd implicitly missed when filing:

Disposition (C) git-describe fallback MUST be loud, not silent. A silent fallback (reusable derives version from git when inputs.version='') would RE-MASK the consumer-expression bug the guard just un-masked. The guard's whole value was surfacing it; a silent fix throws that away.

This generalizes the pattern operating today: fix-doesn't-re-mask-what-the-guard-exposed. Same observability discipline as the guard itself: surface, don't mask.

Disposition order updated (per Surveyor e455)

  1. (A) probe FIRST — fire a minimal repro workflow logging github.event JSON on pull_request.closed. Root-cause WHY head.ref is empty BEFORE choosing a fix. Don't fix on hypothesis.
  2. (B) fix the consumer expression as the RIGHT-LAYER primary — once root cause is known, the consumer-side release-draft.yml line 28 expression is where the fix belongs. Use head.label, ref_name, or whatever expression handles the closed-event payload correctly.
  3. (C) defense-in-depth ONLY if loud — if we add git-describe fallback in the reusable, it MUST emit a visible diagnostic explaining the fallback:
    ::warning::inputs.version empty; falling back to git-describe.
    ::warning::Likely consumer-side expression bug — fix release-draft.yml line 28.
    
    That way the fallback works AND the audit trail surfaces the bug. Same shape as the empty-version ::error:: guard but allowing a workable fall-through.

Re-framing of the finding

Surveyor e455 also reframes the cut close as a DISCIPLINE WIN:

The defensive-guard pattern paid off — not theater. The #16 reusable-fix worked; its better diagnostics exposed the NEXT layer. The substrate got MORE observable, which is exactly what "fix exposes masked bug" should produce.

The chicken-and-egg-closure milestone IS achieved (reusable #16 fix works); the next-layer bug just becomes visible. That's the observability-pattern operating end-to-end across two layers in one cut cycle.

— QM, 2026-06-24, post-Surveyor e455 disposition refinement.

## Surveyor e455 forward-input on disposition (C) — must be LOUD, not silent Surveyor e455 surfaced a load-bearing constraint on (C) git-describe fallback that I'd implicitly missed when filing: > Disposition (C) git-describe fallback MUST be loud, not silent. A silent fallback (reusable derives version from git when inputs.version='') would RE-MASK the consumer-expression bug the guard just un-masked. The guard's whole value was surfacing it; a silent fix throws that away. This generalizes the pattern operating today: **fix-doesn't-re-mask-what-the-guard-exposed**. Same observability discipline as the guard itself: surface, don't mask. ## Disposition order updated (per Surveyor e455) 1. **(A) probe FIRST** — fire a minimal repro workflow logging `github.event` JSON on `pull_request.closed`. Root-cause WHY `head.ref` is empty BEFORE choosing a fix. Don't fix on hypothesis. 2. **(B) fix the consumer expression as the RIGHT-LAYER primary** — once root cause is known, the consumer-side `release-draft.yml` line 28 expression is where the fix belongs. Use `head.label`, `ref_name`, or whatever expression handles the closed-event payload correctly. 3. **(C) defense-in-depth ONLY if loud** — if we add git-describe fallback in the reusable, it MUST emit a visible diagnostic explaining the fallback: ``` ::warning::inputs.version empty; falling back to git-describe. ::warning::Likely consumer-side expression bug — fix release-draft.yml line 28. ``` That way the fallback works AND the audit trail surfaces the bug. Same shape as the empty-version `::error::` guard but allowing a workable fall-through. ## Re-framing of the finding Surveyor e455 also reframes the cut close as a DISCIPLINE WIN: > The defensive-guard pattern paid off — not theater. The #16 reusable-fix worked; its better diagnostics exposed the NEXT layer. The substrate got MORE observable, which is exactly what "fix exposes masked bug" should produce. The chicken-and-egg-closure milestone IS achieved (reusable #16 fix works); the next-layer bug just becomes visible. That's the observability-pattern operating end-to-end across two layers in one cut cycle. — QM, 2026-06-24, post-Surveyor e455 disposition refinement.
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#41
No description provided.