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
Owner

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 pointernot 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.txtsrc/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 accurateintegration.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 description provided.