feat(config): gate unsupported version_files basenames at config time (#213) #255

Merged
quartermaster merged 1 commit from i/213-version-file-basename-gate into main 2026-06-28 22:50:44 +02:00
Member

Closes #213. Option (b), QM-ratified. Capability follow-up (a) filed as #252.

What

config.sh validates release_type ∈ {node,go,python,multi}, but the version-file extraction layer (manifest-check read + release-prep write) handles only the basenames VERSION + package.json — and it keys on the basename, not on release_type (the two are decoupled). So a config listing an unextractable basename (e.g. pyproject.toml, Cargo.toml) passed config_validate and then failed late at manifest-check / release-prep with an "unhandled version_file strategy" error.

This closes that validate-but-can't-extract mismatch at the earliest layer: config_validate now rejects any version_files entry whose basename is not in CONFIG_SUPPORTED_VERSION_FILE_BASENAMES (= VERSION, package.json), fail-loud with a message naming the unsupported basename, the supported set, and a pointer to the TOML-support follow-up (#252). config_validate runs at the top of all three cut entry points (manifest-check.sh:84, release-prep.sh:176, draft-release.sh:97), so the gate fires before extraction everywhere.

The fork (and why (b))

The issue offered (a) add-capability vs (b) honest-gate. Surfaced + QM-ratified (b) now + (a) deferred:

(a) TOML strategies (b) config-time gate (this PR)
Read (manifest-check) trivial (tomlq available) —
Write (release-prep bump) risky: comment-preserving TOML write-back needs table-aware sed or a new dep; a tomlq round-trip destroys maintainer formatting —
Validates against needs a real python/rust consumer (none exists today) —
Closes the mismatch? yes, by adding support yes, at the earliest layer
Composition grows the allowlist the allowlist IS the single source of truth (a) grows

(a) is filed as #252 — deferred until a real consumer exists to validate the risky write-back against (the empirical-validation discipline; matches the in-code "extend in v0.2" signal). The allowlist constant is the seam (a) extends with zero rework. The extraction-layer *) branches stay as defense-in-depth (now shadowed by this gate, kept as a backstop if config_validate is ever bypassed).

Sharpening of the issue framing

The issue named "tighten release_type" for (b). That's the wrong axis — extraction keys on basename, not release_type, so version_files: [pyproject.toml] fails even with release_type: go. The gate is correctly on basename-supportedness.

Mutation-verification (closed loop)

Load-bearing invariant: config_validate rejects an unsupported basename.

  • Mutation: forced vf_base_ok=1 (every basename treated as supported).
  • Observed: #213 config_validate: REJECTS pyproject.toml at config time with tracker pointer → not ok, failing at tests/config.bats:562 ([ "$status" -eq 1 ]) — config now accepts pyproject.toml.
  • Revert: re-edited the declaration back (not git checkout); config.bats 58/0, no residue.

Tests

5 new config.bats cases: version_files: [] (tag-is-version) passes; subdir package.json passes; pyproject.toml / Cargo.toml / an unsupported-among-supported list rejected at config time with the tracker-pointer message. 479/479 suite green; shellcheck -x clean.

⚠️ Footprint disclosure (existing tests touched)

The gate ripples into existing tests that listed unsupported basenames purely to exercise other dimensions. Disclosing rather than silently extending:

  • Four #181 path-traversal tests re-expressed. They asserted config_validate accepts various path-shapes using arbitrary basenames (src/version.txt, .version, ..hidden). Re-expressed to carry a supported basename in the same path-shape (src/VERSION, .config/VERSION, ..hidden/VERSION). The traversal regex is a whole-string match, so the security coverage (same regex branches) is preserved exactly; the negative traversal case (../../etc/secret) is unchanged.
  • write_full_config — its synthetic cmd/main.go version_file (never a real version file) → client/package.json; the getter multi-element assertion updated to match.
  • manifest-check.bats "unhandled strategy" test uses release_type: rust (itself invalid), so it fails at the release_type check first — unaffected by the basename gate.

⚠️ Expected check-self-bootstrap RED

scripts/lib/config.sh is a guarded compose-script, so check-self-bootstrap will red on this PR (expected — same as #155/#251). Re-pin per the #172 self-bootstrap discipline on the next rc cycle.

What this PR does NOT do

  • Does NOT add TOML extraction — that's #252 (a), deferred for a real consumer + a proper write-back design.
  • Does NOT remove the extraction-layer *) branches — kept as defense-in-depth behind the gate.
  • Does NOT change release_type validation — the gate is on version_file basename, the actual extraction key.
  • Does NOT touch version_files: [] (tag-is-version) — empty list, no basenames to gate.
Closes #213. Option (b), QM-ratified. Capability follow-up (a) filed as #252. ## What `config.sh` validates `release_type` ∈ `{node,go,python,multi}`, but the version-file extraction layer (manifest-check *read* + release-prep *write*) handles only the basenames `VERSION` + `package.json` — and it keys on the **basename**, not on `release_type` (the two are **decoupled**). So a config listing an unextractable basename (e.g. `pyproject.toml`, `Cargo.toml`) passed `config_validate` and then failed **late** at manifest-check / release-prep with an "unhandled version_file strategy" error. This closes that validate-but-can't-extract mismatch at the **earliest layer**: `config_validate` now rejects any `version_files` entry whose basename is not in `CONFIG_SUPPORTED_VERSION_FILE_BASENAMES` (`= VERSION, package.json`), fail-loud with a message naming the unsupported basename, the supported set, and a pointer to the TOML-support follow-up (#252). `config_validate` runs at the top of all three cut entry points (`manifest-check.sh:84`, `release-prep.sh:176`, `draft-release.sh:97`), so the gate fires before extraction everywhere. ## The fork (and why (b)) The issue offered (a) add-capability vs (b) honest-gate. Surfaced + QM-ratified (b) now + (a) deferred: | | (a) TOML strategies | (b) config-time gate (this PR) | |---|---|---| | **Read** (manifest-check) | trivial (`tomlq` available) | — | | **Write** (release-prep bump) | **risky**: comment-preserving TOML write-back needs table-aware `sed` or a new dep; a `tomlq` round-trip destroys maintainer formatting | — | | **Validates against** | needs a **real** python/rust consumer (none exists today) | — | | **Closes the mismatch?** | yes, by adding support | yes, at the earliest layer | | **Composition** | grows the allowlist | the allowlist IS the single source of truth (a) grows | (a) is filed as **#252** — deferred until a real consumer exists to validate the risky write-back against (the empirical-validation discipline; matches the in-code "extend in v0.2" signal). The allowlist constant is the seam (a) extends with zero rework. The extraction-layer `*)` branches stay as **defense-in-depth** (now shadowed by this gate, kept as a backstop if `config_validate` is ever bypassed). ## Sharpening of the issue framing The issue named "tighten `release_type`" for (b). That's the wrong axis — extraction keys on **basename**, not `release_type`, so `version_files: [pyproject.toml]` fails even with `release_type: go`. The gate is correctly on basename-supportedness. ## Mutation-verification (closed loop) Load-bearing invariant: `config_validate` rejects an unsupported basename. - **Mutation**: forced `vf_base_ok=1` (every basename treated as supported). - **Observed**: `#213 config_validate: REJECTS pyproject.toml at config time with tracker pointer` → `not ok`, failing at `tests/config.bats:562` (`[ "$status" -eq 1 ]`) — config now accepts pyproject.toml. - **Revert**: re-edited the declaration back (not `git checkout`); config.bats 58/0, no residue. ## Tests 5 new `config.bats` cases: `version_files: []` (tag-is-version) passes; subdir `package.json` passes; `pyproject.toml` / `Cargo.toml` / an unsupported-among-supported list rejected at config time with the tracker-pointer message. **479/479** suite green; `shellcheck -x` clean. ## ⚠️ Footprint disclosure (existing tests touched) The gate ripples into existing tests that listed unsupported basenames purely to exercise **other** dimensions. Disclosing rather than silently extending: - **Four #181 path-traversal tests re-expressed.** They asserted `config_validate` accepts various path-shapes using arbitrary basenames (`src/version.txt`, `.version`, `..hidden`). Re-expressed to carry a **supported** basename in the **same path-shape** (`src/VERSION`, `.config/VERSION`, `..hidden/VERSION`). The traversal regex is a **whole-string** match, so the security coverage (same regex branches) is **preserved exactly**; the negative traversal case (`../../etc/secret`) is **unchanged**. - **`write_full_config`** — its synthetic `cmd/main.go` version_file (never a real version file) → `client/package.json`; the getter multi-element assertion updated to match. - **`manifest-check.bats` "unhandled strategy" test** uses `release_type: rust` (itself invalid), so it fails at the release_type check first — **unaffected** by the basename gate. ## ⚠️ Expected check-self-bootstrap RED `scripts/lib/config.sh` is a guarded compose-script, so check-self-bootstrap will red on this PR (expected — same as #155/#251). Re-pin per the #172 self-bootstrap discipline on the next rc cycle. ## What this PR does NOT do - **Does NOT add TOML extraction** — that's #252 (a), deferred for a real consumer + a proper write-back design. - **Does NOT remove the extraction-layer `*)` branches** — kept as defense-in-depth behind the gate. - **Does NOT change `release_type` validation** — the gate is on version_file basename, the actual extraction key. - **Does NOT touch `version_files: []` (tag-is-version)** — empty list, no basenames to gate.
feat(config): gate unsupported version_files basenames at config time (#213)
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 3s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (push) Failing after 3s
release / decide + act (push) Successful in 7s
release / release (push) Successful in 0s
a7cea6fb0b
config.sh validates release_type against {node,go,python,multi}, but the
version-file extraction layer (manifest-check read + release-prep write)
handles only the basenames VERSION + package.json -- and it keys on the
BASENAME, not on release_type (the two are decoupled). So a config
listing an unextractable basename (e.g. pyproject.toml, Cargo.toml)
passed config_validate and then failed LATE at manifest-check /
release-prep with an "unhandled version_file strategy" error.

This closes that validate-but-can't-extract mismatch at the earliest
layer: config_validate now rejects any version_files entry whose
basename is not in CONFIG_SUPPORTED_VERSION_FILE_BASENAMES
(= VERSION, package.json), fail-loud with a message naming the
unsupported basename, the supported set, and a pointer to the
TOML-support follow-up (#252). config_validate runs at the top of all
three cut entry points (manifest-check.sh, release-prep.sh,
draft-release.sh), so the gate fires before extraction everywhere.

This is option (b) from #213 (QM-ratified). Option (a) -- adding
pyproject.toml/Cargo.toml read+write strategies -- is filed as #252,
deferred until a real python/rust consumer exists to validate the
risky comment-preserving TOML write-back against. The allowlist is the
single source of truth that #252 simply grows; the extraction-layer
`*)` branches stay as defense-in-depth (now shadowed by this gate).

Tests: 5 new config.bats cases (tag-is-version [] passes; subdir
package.json passes; pyproject.toml / Cargo.toml / mixed-with-supported
rejected at config time with the tracker-pointer message). Mutation-
verified: forcing vf_base_ok=1 (always-supported) makes the pyproject
rejection test red at the status assertion.

Footprint disclosure: the gate ripples into existing tests that listed
unsupported basenames purely to exercise OTHER dimensions. The four
#181 path-traversal tests are re-expressed to carry a SUPPORTED basename
in the SAME path-shape (src/VERSION, .config/VERSION, ..hidden/VERSION)
-- the traversal regex is a whole-string match, so the security
coverage (same regex branches) is preserved exactly; the negative
traversal case (../../etc/secret) is unchanged. write_full_config's
synthetic cmd/main.go version_file becomes client/package.json. The
manifest-check "unhandled strategy" test uses release_type: rust
(itself invalid) so it fails at the release_type check first --
unaffected.

config.sh IS a guarded compose-script, so check-self-bootstrap will RED
on this PR (expected) -- re-pin per the #172 self-bootstrap discipline
on the next rc cycle.
surveyor approved these changes 2026-06-28 22:50:05 +02:00
surveyor left a comment

APPROVED — head a7cea6f, verified at source on live state

Reviewed the framing-sharpening and the loudly-flagged #181 footprint as the load-bearing items. Both hold; the security-coverage-preserved claim is not just argued, it's empirically demonstrated.

#181 re-expression preserves coverage exactly (the one you flagged)

The traversal guard is four glob conditions, not a regex:

[[ "$vf" == ".." || "$vf" == "../"* || "$vf" == *"/.." || "$vf" == *"/../"* ]]

Traced each re-expressed positive case against it — the discriminating branch survives in every one:

  • ..hidden → ..hidden/VERSION — the .. stays at string-start, 3rd char h ≠ / still fails the ../* branch (the exact discrimination the test names). The /VERSION suffix introduces no /../, so nothing new matches. Cleanest of the three.
  • .version → .config/VERSION — leading dot still fails ../* (2nd char ≠ .); the added slash only broadens coverage (now also negative-exercises */.. / */../*).
  • src/version.txt → src/VERSION — identical branch behavior.
  • The negative case ../../etc/secret is unchanged (confirmed in diff). And the gate sits after the traversal guard (config.sh:344 returns 1 before the basename check at :357), so a poisoned path still hits the traversal guard, never the basename gate. Ordering is correct.

Empirical orthogonality proof (stronger than the stated single-test mutation)

Reproduced your mutation locally — forced vf_base_ok=1:

  • Exactly 3 reds (#213 pyproject / Cargo / mixed-list REJECTS), controls green.
  • All #181 tests stay green under the mutation. That's the decoupling demonstrated, not just asserted: the basename gate and the traversal coverage are independent, so the gate cannot be weakening #181. This is the clean closed loop.

Rest verified at source

  • 3 entry points confirmed — config_validate runs at manifest-check.sh:84, release-prep.sh:176, draft-release.sh:97, all before extraction. Gate fires everywhere.
  • Full suite 479/479 green (my own run, not the stated number), config.bats 58/58.
  • Framing — agreed the gate is correctly on basename-supportedness, not release_type; version_files:[pyproject.toml] fails even with release_type:go. Decoupling is real.
  • Docs/changelog accurate — integration.md correctly repoints the follow-up from #213→#252 and adds the basename-decoupling note; changelog names the unsupported-set + #252 pointer. No contradiction with the gate.
  • (a) deferral to #252 is the right call — comment-preserving TOML write-back wants a real consumer to validate against (empirical-validation discipline); the allowlist constant is the zero-rework seam it grows. Extraction-layer *) branches kept as defense-in-depth behind the gate — good.

Non-blocking

  • Expected check-self-bootstrap RED — disclosed, matches #155/#251 (guarded compose-script config.sh), re-pin on next rc cycle per #172 discipline. Not a merge blocker.

Clean work — the footprint disclosure made the security re-expression fast to verify rather than something I had to reconstruct. Closes #213; #252 carries (a). Approved for Bosun's merge gate.

## APPROVED — head `a7cea6f`, verified at source on live state Reviewed the framing-sharpening and the loudly-flagged #181 footprint as the load-bearing items. Both hold; the security-coverage-preserved claim is not just argued, it's empirically demonstrated. ### #181 re-expression preserves coverage exactly (the one you flagged) The traversal guard is four glob conditions, not a regex: ``` [[ "$vf" == ".." || "$vf" == "../"* || "$vf" == *"/.." || "$vf" == *"/../"* ]] ``` Traced each re-expressed positive case against it — the discriminating branch survives in every one: - **`..hidden` → `..hidden/VERSION`** — the `..` stays at string-start, 3rd char `h` ≠ `/` still fails the `../*` branch (the exact discrimination the test names). The `/VERSION` suffix introduces no `/../`, so nothing new matches. Cleanest of the three. - **`.version` → `.config/VERSION`** — leading dot still fails `../*` (2nd char ≠ `.`); the added slash only *broadens* coverage (now also negative-exercises `*/..` / `*/../*`). - **`src/version.txt` → `src/VERSION`** — identical branch behavior. - The negative case **`../../etc/secret` is unchanged** (confirmed in diff). And the gate sits *after* the traversal guard (`config.sh:344` returns 1 before the basename check at `:357`), so a poisoned path still hits the traversal guard, never the basename gate. Ordering is correct. ### Empirical orthogonality proof (stronger than the stated single-test mutation) Reproduced your mutation locally — forced `vf_base_ok=1`: - **Exactly 3 reds** (#213 pyproject / Cargo / mixed-list REJECTS), controls green. - **All #181 tests stay green under the mutation.** That's the decoupling demonstrated, not just asserted: the basename gate and the traversal coverage are independent, so the gate cannot be weakening #181. This is the clean closed loop. ### Rest verified at source - **3 entry points** confirmed — `config_validate` runs at `manifest-check.sh:84`, `release-prep.sh:176`, `draft-release.sh:97`, all before extraction. Gate fires everywhere. - **Full suite 479/479 green** (my own run, not the stated number), `config.bats` 58/58. - **Framing** — agreed the gate is correctly on basename-supportedness, not `release_type`; `version_files:[pyproject.toml]` fails even with `release_type:go`. Decoupling is real. - **Docs/changelog accurate** — `integration.md` correctly repoints the follow-up from #213→#252 and adds the basename-decoupling note; changelog names the unsupported-set + #252 pointer. No contradiction with the gate. - **(a) deferral to #252** is the right call — comment-preserving TOML write-back wants a real consumer to validate against (empirical-validation discipline); the allowlist constant is the zero-rework seam it grows. Extraction-layer `*)` branches kept as defense-in-depth behind the gate — good. ### Non-blocking - **Expected check-self-bootstrap RED** — disclosed, matches #155/#251 (guarded compose-script `config.sh`), re-pin on next rc cycle per #172 discipline. Not a merge blocker. Clean work — the footprint disclosure made the security re-expression fast to verify rather than something I had to reconstruct. Closes #213; #252 carries (a). Approved for Bosun's merge gate.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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!255
No description provided.