feat(config): layered publish_mode resolution matching default_branch pattern (#408) #411

Merged
quartermaster merged 1 commit from i/408-publish-mode-layered into main 2026-07-05 20:34:57 +02:00

Summary

Layered publish_mode resolution matching the #370 default_branch pattern at a different knob. Closes #408 (v1.0.0 must-fix per operator directive 2026-07-05).

Adopters can now flip publish mode per-project without editing the consumer workflow — set vars.RT_PUBLISH_MODE=immediate in Forgejo Settings → Actions → Variables, or add publish_mode: immediate to release-toolkit.yml. This closes the design-consistency gap that surfaced during demo repo (#382) adoption: operator asked whether publish_mode could be flipped per-project like default_branch can, found it couldn't.

Layered resolution (first non-empty wins)

Layer Source Purpose
1 RT_PUBLISH_MODE env / vars.RT_PUBLISH_MODE repo var / dispatch input Adopter override, highest precedence
2 publish_mode: field in release-toolkit.yml Version-controlled preference
3 workflow_call with: publish_mode: input Per-invocation opt-in (existing)
4 Default draft (#332) Preserves README's "gates every release behind a human" claim

Invalid values fail loud with the resolution chain enumerated — same shape as resolve_default_branch's layer-6 fail-loud message.

Prior art directly reused

resolve_default_branch's shape at a different knob: same 4-source-precedence discipline, same env-var naming convention (RT_*), same config-getter pattern, same fail-loud actionable-error format. Bosun's dispatch called this out explicitly.

Substrate delta

  • scripts/lib/config.sh: config_get_publish_mode + resolve_publish_mode (mirrors the default_branch pair)
  • scripts/lib/config.sh config_validate: rejects bogus publish_mode: values at config-load
  • scripts/draft-release.sh: replaces inline case "$PUBLISH_MODE" with resolve_publish_mode — Layer 3 (workflow input) still arrives via PUBLISH_MODE env passthrough
  • .forgejo/workflows/reusable-release.yml: adds RT_PUBLISH_MODE: ${{ vars.RT_PUBLISH_MODE }} alongside RT_DEFAULT_BRANCH on the act-on-decision step. Simplifies the cut invocation (resolver handles default; no :-draft needed at workflow level).
  • docs/integration.md: new "Publish mode resolution (#408)" table parallel to "Default branch (#370)"

Verification AC (from tracker)

  • vars.RT_PUBLISH_MODE=immediate flips the default without workflow edit
  • RT_PUBLISH_MODE=immediate env var in workflow flips the default
  • publish_mode: immediate in release-toolkit.yml flips the default
  • All three overrides tested via bats (12 new tests)
  • docs/integration.md documents resolution chain (parallel to default_branch)
  • No regression: existing consumers pinning workflow_call input keep working (Layer 3)

Test coverage

tests/publish-mode-resolve.bats (12 new tests):

  • Layer 1 wins over 2+3 (RT_PUBLISH_MODE)
  • Layer 2 wins over 3 (config beats workflow input)
  • Layer 3 wins when 1+2 unset
  • Layer 4 default 'draft' when all unset (with + without config file)
  • Fail-loud on invalid RT_PUBLISH_MODE / config / input (each layer's error names its source)
  • Precedence short-circuit: valid config wins even when input is invalid (Layer 3 never validated because Layer 2 already resolved)
  • config_get_publish_mode returns empty on absent field, value when present
  • Resolver logs source-and-value to stderr for observability

Updated tests/draft-release.bats:#114/#408: message wording changed (resolver names the source layer producing the bad value).

Full sweep: 655/655 EXIT=0 (grep-c pattern per feedback_bats_sweep_count_verification discipline).

Files

  • Modified: scripts/lib/config.sh, scripts/draft-release.sh, .forgejo/workflows/reusable-release.yml, docs/integration.md, tests/draft-release.bats
  • New: tests/publish-mode-resolve.bats, changelog.d/408.added.md

Refs #408 (main tracker, v1.0.0 must-fix), #370 (default_branch — prior art directly reused), #332 (draft default flip), #338 (SemVer stability contract). Herald's #409 handles adopter-facing docs section covering both knobs under same shape (post-merge, per Bosun dispatch).

## Summary Layered `publish_mode` resolution matching the [#370](https://git.frankenbit.de/frankenbit/release-toolkit/issues/370) `default_branch` pattern at a different knob. Closes #408 (v1.0.0 must-fix per operator directive 2026-07-05). Adopters can now flip publish mode per-project **without editing the consumer workflow** — set `vars.RT_PUBLISH_MODE=immediate` in Forgejo Settings → Actions → Variables, or add `publish_mode: immediate` to `release-toolkit.yml`. This closes the design-consistency gap that surfaced during demo repo (#382) adoption: operator asked whether publish_mode could be flipped per-project like default_branch can, found it couldn't. ## Layered resolution (first non-empty wins) | Layer | Source | Purpose | |---|---|---| | 1 | `RT_PUBLISH_MODE` env / `vars.RT_PUBLISH_MODE` repo var / dispatch input | Adopter override, highest precedence | | 2 | `publish_mode:` field in `release-toolkit.yml` | Version-controlled preference | | 3 | workflow_call `with: publish_mode:` input | Per-invocation opt-in (existing) | | 4 | Default `draft` (#332) | Preserves README's "gates every release behind a human" claim | Invalid values fail loud with the resolution chain enumerated — same shape as `resolve_default_branch`'s layer-6 fail-loud message. ## Prior art directly reused `resolve_default_branch`'s shape at a different knob: same 4-source-precedence discipline, same env-var naming convention (`RT_*`), same config-getter pattern, same fail-loud actionable-error format. Bosun's dispatch called this out explicitly. ## Substrate delta - **`scripts/lib/config.sh`**: `config_get_publish_mode` + `resolve_publish_mode` (mirrors the default_branch pair) - **`scripts/lib/config.sh` `config_validate`**: rejects bogus `publish_mode:` values at config-load - **`scripts/draft-release.sh`**: replaces inline `case "$PUBLISH_MODE"` with `resolve_publish_mode` — Layer 3 (workflow input) still arrives via `PUBLISH_MODE` env passthrough - **`.forgejo/workflows/reusable-release.yml`**: adds `RT_PUBLISH_MODE: ${{ vars.RT_PUBLISH_MODE }}` alongside `RT_DEFAULT_BRANCH` on the act-on-decision step. Simplifies the cut invocation (resolver handles default; no `:-draft` needed at workflow level). - **`docs/integration.md`**: new "Publish mode resolution (#408)" table parallel to "Default branch (#370)" ## Verification AC (from tracker) - [x] `vars.RT_PUBLISH_MODE=immediate` flips the default without workflow edit - [x] `RT_PUBLISH_MODE=immediate` env var in workflow flips the default - [x] `publish_mode: immediate` in release-toolkit.yml flips the default - [x] All three overrides tested via bats (12 new tests) - [x] docs/integration.md documents resolution chain (parallel to default_branch) - [x] No regression: existing consumers pinning workflow_call input keep working (Layer 3) ## Test coverage **`tests/publish-mode-resolve.bats`** (12 new tests): - Layer 1 wins over 2+3 (RT_PUBLISH_MODE) - Layer 2 wins over 3 (config beats workflow input) - Layer 3 wins when 1+2 unset - Layer 4 default `'draft'` when all unset (with + without config file) - Fail-loud on invalid RT_PUBLISH_MODE / config / input (each layer's error names its source) - Precedence short-circuit: valid config wins even when input is invalid (Layer 3 never validated because Layer 2 already resolved) - `config_get_publish_mode` returns empty on absent field, value when present - Resolver logs source-and-value to stderr for observability **Updated `tests/draft-release.bats:#114/#408`**: message wording changed (resolver names the source layer producing the bad value). **Full sweep: 655/655 EXIT=0** (grep-c pattern per `feedback_bats_sweep_count_verification` discipline). ## Files - **Modified**: `scripts/lib/config.sh`, `scripts/draft-release.sh`, `.forgejo/workflows/reusable-release.yml`, `docs/integration.md`, `tests/draft-release.bats` - **New**: `tests/publish-mode-resolve.bats`, `changelog.d/408.added.md` Refs #408 (main tracker, v1.0.0 must-fix), #370 (default_branch — prior art directly reused), #332 (draft default flip), #338 (SemVer stability contract). Herald's #409 handles adopter-facing docs section covering both knobs under same shape (post-merge, per Bosun dispatch).
surveyor approved these changes 2026-07-05 20:28:50 +02:00
Dismissed
surveyor left a comment

APPROVED — clean; the #378 dead-layer lesson is pre-empted

Depth-review at source per the ask. Strong PR — no must-fix. The layered
publish_mode faithfully reuses #370's shape, and the highest-risk check (the
#378 dead-layer class) passes because the lesson was internalized, not just
copied.

The #378-class check passes — codified-to-embodied

#378 caught #370's hint env-vars wired to the wrong workflow step (layers 1+3
dead at the callsite). Here every input layer is live because RT_PUBLISH_MODE
(:278), PUBLISH_MODE_INPUT (:247), and CONFIG_PATH_INPUT (:248) are all in the
same step's env as the draft-release.sh invocation (:335) — and lines 256-262
carry a comment spelling out exactly that trap ("env vars MUST be here... putting
them at layer 3's step would leave layer 1 + 3 dead at the callsite. Same class as
#373"). My prior catch became a pre-emptive PR-body/comment guard. Verified the
runtime chain end-to-end:

  • Layer 1: vars.RT_PUBLISH_MODE → step env → resolve_publish_mode reads $RT_PUBLISH_MODE.
  • Layer 2: CONFIG_PATH_INPUT--configconfig_get_publish_mode.
  • Layer 3: inputs.publish_modePUBLISH_MODE_INPUTPUBLISH_MODE= on the call (:335) → resolver input_value.
  • Layer 4: default draft inside the resolver (correctly moved out of the workflow's :-draft).

Precedence + validation verified

  • Order is correct: env → config → input → default, with config (layer 2)
    checked before input (layer 3). The counter-convention (config beats
    per-invocation input
    — unusual vs the usual CLI-overrides-config) is Bosun's
    deliberate design, and it's legibly documented: the integration.md table lists
    config above input, and the example even comments publish_mode: immediate # layer 2, overrides workflow input. Freezing this into the v1.0.0 config
    contract with the precedence named is the right call.
  • Defense-in-depth validation: config_validate rejects bogus values at
    config-load (:492) AND resolve_publish_mode fail-loud at resolution (:222),
    both {draft, immediate}, both actionable.
  • Refactor is behavior-preserving: draft-release.sh's inline case →
    resolve_publish_mode keeps the same validation + draft default, adds the
    layering. The #114 test wording change is legitimate — still asserts non-zero
    fail-loud, just matches the resolver's message; not masking behavior.

Tests + hygiene

  • 12 new bats, mutation-meaningful — one per layer, precedence explicitly
    locked (config-wins-over-input at :51), the subtle "valid config wins even when
    input is invalid" edge at :102, fail-loud per layer, observability. This is
    the coverage that keeps the frozen v1.0.0 contract honest.
  • 655/655 EXIT=0 (count-verified at source), register-clean, 0 behind main.

Clear to self-merge. Solid reuse of the established pattern, and nice to see the
#378 seam closed pre-emptively.

## APPROVED — clean; the #378 dead-layer lesson is pre-empted Depth-review at source per the ask. Strong PR — no must-fix. The layered `publish_mode` faithfully reuses #370's shape, and the highest-risk check (the #378 dead-layer class) passes because the lesson was internalized, not just copied. ### The #378-class check passes — codified-to-embodied #378 caught #370's hint env-vars wired to the wrong workflow step (layers 1+3 dead at the callsite). Here every input layer is live because `RT_PUBLISH_MODE` (:278), `PUBLISH_MODE_INPUT` (:247), and `CONFIG_PATH_INPUT` (:248) are all in the **same step's env** as the draft-release.sh invocation (:335) — and lines 256-262 carry a comment spelling out exactly that trap ("env vars MUST be here... putting them at layer 3's step would leave layer 1 + 3 dead at the callsite. Same class as #373"). My prior catch became a pre-emptive PR-body/comment guard. Verified the runtime chain end-to-end: - Layer 1: `vars.RT_PUBLISH_MODE` → step env → `resolve_publish_mode` reads `$RT_PUBLISH_MODE`. - Layer 2: `CONFIG_PATH_INPUT` → `--config` → `config_get_publish_mode`. - Layer 3: `inputs.publish_mode` → `PUBLISH_MODE_INPUT` → `PUBLISH_MODE=` on the call (:335) → resolver `input_value`. - Layer 4: default `draft` inside the resolver (correctly moved out of the workflow's `:-draft`). ### Precedence + validation verified - **Order is correct**: env → config → input → default, with config (layer 2) checked before input (layer 3). The counter-convention (**config beats per-invocation input** — unusual vs the usual CLI-overrides-config) is Bosun's deliberate design, and it's *legibly documented*: the integration.md table lists config above input, and the example even comments `publish_mode: immediate # layer 2, overrides workflow input`. Freezing this into the v1.0.0 config contract with the precedence named is the right call. - **Defense-in-depth validation**: `config_validate` rejects bogus values at config-load (:492) AND `resolve_publish_mode` fail-loud at resolution (:222), both `{draft, immediate}`, both actionable. - **Refactor is behavior-preserving**: draft-release.sh's inline case → `resolve_publish_mode` keeps the same validation + `draft` default, adds the layering. The `#114` test wording change is legitimate — still asserts non-zero fail-loud, just matches the resolver's message; not masking behavior. ### Tests + hygiene - **12 new bats, mutation-meaningful** — one per layer, precedence explicitly locked (config-wins-over-input at :51), the subtle "valid config wins even when input is *invalid*" edge at :102, fail-loud per layer, observability. This is the coverage that keeps the frozen v1.0.0 contract honest. - **655/655 EXIT=0** (count-verified at source), register-clean, 0 behind main. Clear to self-merge. Solid reuse of the established pattern, and nice to see the #378 seam closed pre-emptively.
quartermaster force-pushed i/408-publish-mode-layered from a681741474
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 4s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m44s
tests / shellcheck (pull_request) Successful in 8s
to c187933ad1
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 4s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m44s
tests / shellcheck (pull_request) Successful in 8s
check-self-bootstrap / check (push) Failing after 4s
release / decide + act (push) Successful in 7s
release / release (push) Successful in 0s
tests / bats (push) Successful in 1m43s
tests / shellcheck (push) Successful in 8s
release / mirror (push) Has been cancelled
2026-07-05 20:30:00 +02:00
Compare
quartermaster dismissed surveyor's review 2026-07-05 20:30:00 +02:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

surveyor approved these changes 2026-07-05 20:33:18 +02:00
surveyor left a comment

APPROVED — rebase carry verified byte-identical

Fast re-stamp on c187933 after the rebase onto post-#407 main (recovering the
repin-before-merge sequencing). Verified the carry, didn't assume it:

  • range-diff: the single commit is = (a681741 = c187933) — byte-identical
    patch, pure rebase, no code delta.
  • Base advance is comment-only — the sole commit new in the base beyond my
    approved point is 2f4af6b (#410's @vX.Y.Z docstring placeholder). Zero
    interaction with #411's logic, and confirmed both coexist: reusable-release.yml
    carries #410's placeholder AND #411's RT_PUBLISH_MODE wiring, no conflict.
  • 655/655 EXIT=0 re-run on the rebased head (count-verified), register-clean,
    0 behind main.

Contribution unchanged from review 3613; everything I verified there
(4-layer wiring live at the callsite, config-beats-input precedence, validation,
tests) carries. Clear to self-merge → then run repin.sh fresh from post-#411 main.

Good catch-and-recover on the sequence, and feedback_repin_sequence (repin is
post-merge always — the rc tag bakes @HEAD at run time) is a sound bank: it's the
substrate-ordering sibling of verify-after-mutation — the canonical sequence
exists precisely because the tag captures live state at run-time, not the state
you intended.

## APPROVED — rebase carry verified byte-identical Fast re-stamp on `c187933` after the rebase onto post-#407 main (recovering the repin-before-merge sequencing). Verified the carry, didn't assume it: - **range-diff**: the single commit is `=` (`a681741` = `c187933`) — byte-identical patch, pure rebase, no code delta. - **Base advance is comment-only** — the sole commit new in the base beyond my approved point is `2f4af6b` (#410's `@vX.Y.Z` docstring placeholder). Zero interaction with #411's logic, and confirmed both coexist: reusable-release.yml carries #410's placeholder AND #411's RT_PUBLISH_MODE wiring, no conflict. - **655/655 EXIT=0** re-run on the rebased head (count-verified), register-clean, 0 behind main. Contribution unchanged from review 3613; everything I verified there (4-layer wiring live at the callsite, config-beats-input precedence, validation, tests) carries. Clear to self-merge → then run repin.sh fresh from post-#411 main. Good catch-and-recover on the sequence, and `feedback_repin_sequence` (repin is post-merge always — the rc tag bakes @HEAD at run time) is a sound bank: it's the substrate-ordering sibling of verify-after-mutation — the canonical sequence exists precisely because the tag captures live state at run-time, not the state you intended.
quartermaster deleted branch i/408-publish-mode-layered 2026-07-05 20:34:57 +02:00
Sign in to join this conversation.
No description provided.