diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee97def..543beca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: echo "Install smoke pin: $TAG (make print-sfetch-version)" bash scripts/install-sfetch.sh --dry-run --tag "$TAG" --require-minisign - - name: Live verified bootstrap (N-1 SHA256SUMS route) + - name: Live verified bootstrap (N-1 minisig/current-key route) run: | set -euo pipefail TAG=$(make -s print-sfetch-version) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08f541e..0b50a71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,12 +127,13 @@ jobs: # Per-file .sha256 removed - signed SHA256SUMS is the authoritative source # Maintainer generates SHA256SUMS locally and signs with minisign/pgp - # Appends this matrix entry's archive to the release created above. - # action-gh-release@v3 is idempotent on the tag; parallel matrix jobs each - # upload a distinctly-named asset, so no upload_url plumbing is needed. + # Reopens the same release as a draft while appending this matrix entry's + # distinctly named archive. Every release-writing action must state + # draft: true so parallel uploads cannot publish before signing. - name: Upload Archive uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: + draft: true files: ${{ env.ASSET_FILE }} - name: Signing handled manually diff --git a/CHANGELOG.md b/CHANGELOG.md index 18c3b78..883d922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.13] - 2026-09-18 + +### Changed + +- **Verified self-bootstrap and CI smoke now pin published v0.4.12.** The + development bootstrap remains on a fully signed N-1 release and exercises + the current-key minisign route. + +### Fixed + +- **Release matrix uploads preserve draft state explicitly.** Every + release-writing action step now declares `draft: true`, with a precommit + regression that rejects missing or false draft settings before a tag run. + ## [0.4.12] - 2026-09-17 ### Changed @@ -204,7 +218,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **Maintenance note:** This file is pruned to the latest 10 releases. For older entries, see `docs/releases/`. -[Unreleased]: https://github.com/3leaps/sfetch/compare/v0.4.12...HEAD +[Unreleased]: https://github.com/3leaps/sfetch/compare/v0.4.13...HEAD +[0.4.13]: https://github.com/3leaps/sfetch/compare/v0.4.12...v0.4.13 [0.4.12]: https://github.com/3leaps/sfetch/compare/v0.4.11...v0.4.12 [0.4.11]: https://github.com/3leaps/sfetch/compare/v0.4.10...v0.4.11 [0.4.10]: https://github.com/3leaps/sfetch/compare/v0.4.9...v0.4.10 diff --git a/Makefile b/Makefile index 5e0a89f..6c6a7c6 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ BIN_DIR := $(CURDIR)/bin # pin (see .github/workflows/ci.yml). Always a fully-signed published release — # never "latest" and never the in-flight cut — so main CI cannot 404 during the # tag/upload window. Advance after each release publishes + signs. -SFETCH_VERSION := v0.4.11 +SFETCH_VERSION := v0.4.12 GONEAT_VERSION ?= v0.6.0 GOVULNCHECK_VERSION ?= v1.8.0 @@ -61,7 +61,7 @@ CORPUS_DEST ?= test-corpus .PHONY: release-verify-key release-verify-minisign-pubkey release-verify-keys release-verify-signatures release-verify .PHONY: release-clean bootstrap-script build-all gosec gosec-high update-scoop-manifest .PHONY: version-check version-set version-patch version-minor version-major -.PHONY: print-sfetch-version test-release-verify-checksums test-release-verify-signatures test-bootstrap-sfetch-verified test-bootstrap-range-release test-install-anchor-select +.PHONY: print-sfetch-version test-release-verify-checksums test-release-verify-signatures test-release-workflow-draft test-bootstrap-sfetch-verified test-bootstrap-range-release test-install-anchor-select all: build @@ -211,6 +211,7 @@ precommit: ## Run pre-commit checks (goneat assess + Go tests + build) # CI runs make precommit (not prepush); keep fail-closed release-verify regression on this path. $(MAKE) test-release-verify-checksums $(MAKE) test-release-verify-signatures + $(MAKE) test-release-workflow-draft $(MAKE) test-bootstrap-sfetch-verified $(MAKE) test-bootstrap-range-release $(MAKE) test-install-anchor-select @@ -286,6 +287,9 @@ test-release-verify-checksums: ## Regression: fail-closed checksum verify (corru test-release-verify-signatures: ## Regression: required installer minisig + sign targets @./scripts/test-release-verify-signatures.sh +test-release-workflow-draft: ## Regression: every release-writing action preserves draft state + @python3 ./scripts/test-release-workflow-draft.py + test-bootstrap-sfetch-verified: ## Regression: dual-route bootstrap rejects + fail-closed @./scripts/test-bootstrap-sfetch-verified.sh diff --git a/README.md b/README.md index 4f56d41..866e25a 100644 --- a/README.md +++ b/README.md @@ -214,14 +214,14 @@ Match `-P` to the tag: tags ≤ v0.4.11 verify under the previous key; newer tag ```bash # Three-step path when install-sfetch.sh.minisig is published (v0.4.11+) -TAG=v0.4.12 +TAG=v0.4.13 curl -fsSL "https://github.com/3leaps/sfetch/releases/download/${TAG}/install-sfetch.sh" -o install-sfetch.sh curl -fsSL "https://github.com/3leaps/sfetch/releases/download/${TAG}/install-sfetch.sh.minisig" -o install-sfetch.sh.minisig minisign -Vm install-sfetch.sh -P RWTqUZ/PtmfAbQ3RKIlp/YTKT6zFeNMAZ8iAMelhlmqEsVqjKj3ctSA1 bash install-sfetch.sh --tag "$TAG" --yes # Or use the shared engine (CI/Makefile; refuses "latest"): -# ./scripts/bootstrap-sfetch-verified.sh --version v0.4.12 --dir ~/.local/bin +# ./scripts/bootstrap-sfetch-verified.sh --version v0.4.13 --dir ~/.local/bin ``` **GitHub Actions:** pin the composite action by commit SHA — see [CI/CD Usage Guide](docs/cicd-usage-guide.md). @@ -229,7 +229,7 @@ bash install-sfetch.sh --tag "$TAG" --yes ```yaml - uses: 3leaps/sfetch/.github/actions/setup-sfetch@ with: - sfetch-version: v0.4.12 + sfetch-version: v0.4.13 ``` #### Quick install (interactive humans) @@ -254,7 +254,7 @@ Pass arguments using `bash -s --`: curl -sSfL .../install-sfetch.sh | bash -s -- --dir ~/bin # Install specific version -curl -sSfL .../install-sfetch.sh | bash -s -- --tag v0.4.12 +curl -sSfL .../install-sfetch.sh | bash -s -- --tag v0.4.13 # Dry run (download and verify, don't install) curl -sSfL .../install-sfetch.sh | bash -s -- --dry-run @@ -302,13 +302,13 @@ Tag CI creates a **draft** release with unsigned archives. Maintainers generate export SFETCH_MINISIGN_KEY=/path/to/sfetch.key export SFETCH_PGP_KEY_ID=security@fulmenhq.dev # optional -RELEASE_TAG=v0.4.12 make release-download -RELEASE_TAG=v0.4.12 make release-checksums -RELEASE_TAG=v0.4.12 make release-sign # signs manifests + install-sfetch.sh +RELEASE_TAG=v0.4.13 make release-download +RELEASE_TAG=v0.4.13 make release-checksums +RELEASE_TAG=v0.4.13 make release-sign # signs manifests + install-sfetch.sh make release-verify -RELEASE_TAG=v0.4.12 make release-notes -RELEASE_TAG=v0.4.12 make release-upload -gh release edit v0.4.12 --draft=false # publish only after signatures upload +RELEASE_TAG=v0.4.13 make release-notes +RELEASE_TAG=v0.4.13 make release-upload +gh release edit v0.4.13 --draft=false # publish only after signatures upload ``` Set `RELEASE_TAG` to the tag you're publishing. The scripts in `scripts/` can be used individually if you prefer manual control. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 51fb7fe..cff07ba 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,34 @@ +## v0.4.13 + +### Summary + +Maintenance release that advances development self-bootstrap and CI smoke to +the fully signed v0.4.12 release and keeps every automated release upload in +draft state until the attended sign, verify, upload, and publish ceremony. +There are no CLI, dependency, or trust-anchor changes. + +### Highlights + +**Verified bootstrap** +- The Makefile N-1 pin advances from v0.4.11 to published v0.4.12. +- A fresh bootstrap of that pin uses the minisign route and current release + trust anchor. Historical v0.4.11 previous-key and v0.4.10 checksum-route + coverage remains in place. + +**Draft-preserving release uploads** +- Every `softprops/action-gh-release` step explicitly sets `draft: true`, + including each matrix archive upload. +- A focused precommit regression rejects omitted or false draft settings. +- Publication remains an attended action after signatures and checksum + manifests are verified and uploaded. + +### Compatibility + +No user migration is required. CLI behavior, dependencies, supported +platforms, and verification trust anchors are unchanged. + +--- + ## v0.4.12 ### Summary diff --git a/VERSION b/VERSION index 75274d8..1f77169 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.12 +0.4.13 diff --git a/docs/releases/v0.4.13.md b/docs/releases/v0.4.13.md new file mode 100644 index 0000000..53712da --- /dev/null +++ b/docs/releases/v0.4.13.md @@ -0,0 +1,29 @@ +# sfetch v0.4.13 + +Maintenance release for verified development bootstrap and draft-preserving +release uploads. + +## Highlights + +### Verified bootstrap + +- The Makefile N-1 self-bootstrap and CI install-smoke pin advances from + v0.4.11 to the fully signed v0.4.12 release. +- Fresh isolated bootstrap verification selects the minisign route and current + release trust anchor for v0.4.12. +- The bootstrap engine accepts v0.4.13 as its committed ceiling. Historical + v0.4.11 previous-key and v0.4.10 checksum-route coverage remains unchanged. + +### Draft-preserving release uploads + +- Every `softprops/action-gh-release` step explicitly declares `draft: true`, + including all release-matrix archive uploads. +- A regression wired into `make precommit` enumerates those release-writing + steps and rejects omitted or false draft settings. +- Tag CI remains non-consumable until the maintainer completes the attended + sign, verify, upload, and explicit publish sequence. + +## Compatibility + +There are no CLI, dependency, supported-platform, self-update, or trust-anchor +changes. Existing consumers require no migration. diff --git a/docs/security.md b/docs/security.md index 6bbc6e5..3caa671 100644 --- a/docs/security.md +++ b/docs/security.md @@ -140,15 +140,15 @@ export SFETCH_MINISIGN_KEY=/path/to/key.key export SFETCH_MINISIGN_PUB=/path/to/key.pub export SFETCH_PGP_KEY_ID=your-key-id # optional -RELEASE_TAG=v0.4.12 make release-download -RELEASE_TAG=v0.4.12 make release-checksums -RELEASE_TAG=v0.4.12 make release-sign +RELEASE_TAG=v0.4.13 make release-download +RELEASE_TAG=v0.4.13 make release-checksums +RELEASE_TAG=v0.4.13 make release-sign make release-verify-signatures make release-export-keys make release-verify-keys -RELEASE_TAG=v0.4.12 make release-notes -RELEASE_TAG=v0.4.12 make release-upload -gh release edit v0.4.12 --draft=false +RELEASE_TAG=v0.4.13 make release-notes +RELEASE_TAG=v0.4.13 make release-upload +gh release edit v0.4.13 --draft=false ``` ### What is signed (v0.4.11+) diff --git a/scripts/bootstrap-sfetch-verified.sh b/scripts/bootstrap-sfetch-verified.sh index df9e0de..5f0488a 100755 --- a/scripts/bootstrap-sfetch-verified.sh +++ b/scripts/bootstrap-sfetch-verified.sh @@ -43,7 +43,7 @@ set -euo pipefail # make test-bootstrap-range-release asserts MAX == v$(cat VERSION) using two # committed values at the same SHA (never self-advancing at install time). readonly SFETCH_BOOTSTRAP_MIN="v0.4.9" -readonly SFETCH_BOOTSTRAP_MAX="v0.4.12" +readonly SFETCH_BOOTSTRAP_MAX="v0.4.13" # First release that publishes install-sfetch.sh.minisig (route selection). # Keep this accurate: too low → minisig route without asset; too high → 5-step # when 3-step exists. Both routes verify; neither silently falls back. diff --git a/scripts/test-bootstrap-sfetch-verified.sh b/scripts/test-bootstrap-sfetch-verified.sh index 989649a..67ea3f8 100755 --- a/scripts/test-bootstrap-sfetch-verified.sh +++ b/scripts/test-bootstrap-sfetch-verified.sh @@ -46,7 +46,7 @@ if "$SCRIPT" --version latest --dir /tmp 2>/dev/null; then fail "latest should f if "$SCRIPT" --version main --dir /tmp 2>/dev/null; then fail "main should fail"; else pass "rejects main"; fi if "$SCRIPT" --version v0.4.11-rc1 --dir /tmp 2>/dev/null; then fail "prerelease should fail"; else pass "rejects prerelease"; fi if "$SCRIPT" --version v0.4.8 --dir /tmp 2>/dev/null; then fail "below min should fail"; else pass "rejects below min"; fi -if "$SCRIPT" --version v0.4.13 --dir /tmp 2>/dev/null; then fail "above max should fail"; else pass "rejects above max"; fi +if "$SCRIPT" --version v0.4.14 --dir /tmp 2>/dev/null; then fail "above max should fail"; else pass "rejects above max"; fi # F2: leading-zero components must not pass range/route selection if "$SCRIPT" --version v0.4.09 --dir /tmp 2>/dev/null; then fail "v0.4.09 leading zero should fail"; else pass "rejects v0.4.09 leading zero"; fi if "$SCRIPT" --version v0.04.11 --dir /tmp 2>/dev/null; then fail "v0.04.11 leading zero should fail"; else pass "rejects v0.04.11 leading zero"; fi @@ -212,7 +212,7 @@ NEW_EPHEMERAL_PUBKEY="$(grep -E '^RW' "$NEWPUB" | head -n1 | tr -d '\r\n')" [ "$OLD_EPHEMERAL_PUBKEY" != "$NEW_EPHEMERAL_PUBKEY" ] || fail "stand-in keys must differ" SRV_ROOT="$WORKDIR/www" -mkdir -p "$SRV_ROOT/v0.4.11" "$SRV_ROOT/v0.4.10" "$SRV_ROOT/v0.4.12" +mkdir -p "$SRV_ROOT/v0.4.11" "$SRV_ROOT/v0.4.10" "$SRV_ROOT/v0.4.13" make_stub_installer() { local dest="$1" @@ -244,7 +244,7 @@ STUB make_stub_installer "$SRV_ROOT/v0.4.11/install-sfetch.sh" make_stub_installer "$SRV_ROOT/v0.4.10/install-sfetch.sh" -make_stub_installer "$SRV_ROOT/v0.4.12/install-sfetch.sh" +make_stub_installer "$SRV_ROOT/v0.4.13/install-sfetch.sh" minisign -S -s "$KEY" -t "test-v0.4.11" -m "$SRV_ROOT/v0.4.11/install-sfetch.sh" ( @@ -337,7 +337,7 @@ grep -Eq 'anchor=legacy' "$OUT_CURONLY" || fail "expected anchor=legacy selectio if grep -q '^route=' "$OUT_CURONLY"; then fail "failed run must not emit machine route="; fi pass "old pin ignores current anchor (no fallback)" -# Post-cutover selection: v0.4.12 is in the production engine's supported range. +# Post-cutover selection: v0.4.13 is the production engine's accepted ceiling. # Stand-in keys prove the rule: new pins use current, previous-key sigs fail. NEWENG="$WORKDIR/newpin-engine.sh" make_fixture_engine "$NEWENG" "$BASE" "$OLD_EPHEMERAL_PUBKEY" "SFETCH_MINISIGN_PUBKEY_LEGACY" @@ -345,39 +345,39 @@ sed -i.bak "s|${PROD_PUBKEY}|${NEW_EPHEMERAL_PUBKEY}|g" "$NEWENG" grep -q "$NEW_EPHEMERAL_PUBKEY" "$NEWENG" || fail "new-pin engine missing current stand-in" grep -q "$PROD_PUBKEY" "$NEWENG" && fail "new-pin engine still has production current key" grep -q "$OLD_EPHEMERAL_PUBKEY" "$NEWENG" || fail "new-pin engine lost legacy stand-in" -grep -q 'SFETCH_BOOTSTRAP_MAX="v0.4.12"' "$NEWENG" || fail "new-pin engine MAX drifted" +grep -q 'SFETCH_BOOTSTRAP_MAX="v0.4.13"' "$NEWENG" || fail "new-pin engine MAX drifted" # Positive: post-cutover pin verifies against the current anchor. -minisign -S -s "$NEWKEY" -t "test-v0.4.12-new" -m "$SRV_ROOT/v0.4.12/install-sfetch.sh" +minisign -S -s "$NEWKEY" -t "test-v0.4.13-new" -m "$SRV_ROOT/v0.4.13/install-sfetch.sh" NEWGOOD_DIR="$WORKDIR/newgood" mkdir -p "$NEWGOOD_DIR" set +e OUT_NEWGOOD="$WORKDIR/out-newgood.txt" -"$NEWENG" --version v0.4.12 --dir "$NEWGOOD_DIR" >"$OUT_NEWGOOD" 2>&1 +"$NEWENG" --version v0.4.13 --dir "$NEWGOOD_DIR" >"$OUT_NEWGOOD" 2>&1 RC=$? set -e -[ "$RC" -eq 0 ] || fail "positive v0.4.12 should succeed (log: $(cat "$OUT_NEWGOOD"))" +[ "$RC" -eq 0 ] || fail "positive v0.4.13 should succeed (log: $(cat "$OUT_NEWGOOD"))" [ "$(awk 'BEGIN{c=0} /^route=/{c++} END{print c}' "$OUT_NEWGOOD")" -eq 1 ] || fail "expected one route= field" -grep -q '^route=minisig$' "$OUT_NEWGOOD" || fail "positive v0.4.12 machine route missing" -grep -Eq 'anchor=current' "$OUT_NEWGOOD" || fail "positive v0.4.12 should log anchor=current" +grep -q '^route=minisig$' "$OUT_NEWGOOD" || fail "positive v0.4.13 machine route missing" +grep -Eq 'anchor=current' "$OUT_NEWGOOD" || fail "positive v0.4.13 should log anchor=current" [ -f "$NEWGOOD_DIR/.stub-ran" ] || fail "installer should execute after successful verify" [ -x "$NEWGOOD_DIR/sfetch" ] || fail "sfetch binary should be installed" -version_output_matches_pin "$("$NEWGOOD_DIR/sfetch" --version 2>&1)" "v0.4.12" || fail "stub sfetch version" +version_output_matches_pin "$("$NEWGOOD_DIR/sfetch" --version 2>&1)" "v0.4.13" || fail "stub sfetch version" pass "post-cutover pin uses current anchor" # Negative: previous-key signature must fail for a post-cutover pin (no downgrade). -minisign -S -s "$OLDKEY" -t "test-v0.4.12-old" -m "$SRV_ROOT/v0.4.12/install-sfetch.sh" +minisign -S -s "$OLDKEY" -t "test-v0.4.13-old" -m "$SRV_ROOT/v0.4.13/install-sfetch.sh" NEWDOWNGRADE_DIR="$WORKDIR/newdowngrade" mkdir -p "$NEWDOWNGRADE_DIR" set +e OUT_DOWNGRADE="$WORKDIR/out-downgrade.txt" -"$NEWENG" --version v0.4.12 --dir "$NEWDOWNGRADE_DIR" >"$OUT_DOWNGRADE" 2>&1 +"$NEWENG" --version v0.4.13 --dir "$NEWDOWNGRADE_DIR" >"$OUT_DOWNGRADE" 2>&1 RC=$? set -e -[ "$RC" -ne 0 ] || fail "previous-key signature for v0.4.12 must fail" +[ "$RC" -ne 0 ] || fail "previous-key signature for v0.4.13 must fail" [ ! -f "$NEWDOWNGRADE_DIR/.stub-ran" ] || fail "installer must not run on downgrade attempt" [ ! -f "$NEWDOWNGRADE_DIR/sfetch" ] || fail "sfetch must not be installed on failed verify" -grep -Eq 'anchor=current' "$OUT_DOWNGRADE" || fail "expected anchor=current selection for v0.4.12" +grep -Eq 'anchor=current' "$OUT_DOWNGRADE" || fail "expected anchor=current selection for v0.4.13" if grep -q '^route=' "$OUT_DOWNGRADE"; then fail "failed run must not emit machine route="; fi pass "post-cutover pin rejects previous-key signature (no downgrade)" diff --git a/scripts/test-release-workflow-draft.py b/scripts/test-release-workflow-draft.py new file mode 100644 index 0000000..fa77913 --- /dev/null +++ b/scripts/test-release-workflow-draft.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Regression checks for draft-preserving release action steps.""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" +ACTION_RE = re.compile(r"^(\s*)uses:\s*softprops/action-gh-release@\S+\s*(?:#.*)?$") + + +def action_blocks(text: str) -> list[tuple[int, int, int]]: + """Return start, end, and child indentation for each release action step.""" + lines = text.splitlines() + blocks: list[tuple[int, int, int]] = [] + for uses_index, line in enumerate(lines): + match = ACTION_RE.match(line) + if not match: + continue + uses_indent = len(match.group(1)) + step_indent = uses_indent - 2 + start = uses_index + while start >= 0 and not re.match(rf"^ {{{step_indent}}}-\s+", lines[start]): + start -= 1 + if start < 0: + raise ValueError( + f"release action at line {uses_index + 1} has no step boundary" + ) + end = uses_index + 1 + while end < len(lines) and not re.match( + rf"^ {{{step_indent}}}-\s+", lines[end] + ): + end += 1 + blocks.append((start, end, uses_indent + 2)) + return blocks + + +def validate(text: str) -> tuple[bool, str]: + lines = text.splitlines() + try: + blocks = action_blocks(text) + except ValueError as error: + return False, str(error) + if not blocks: + return False, "release workflow has no softprops/action-gh-release steps" + + failures: list[str] = [] + for start, end, child_indent in blocks: + drafts = [ + line.strip() + for line in lines[start:end] + if re.match(rf"^ {{{child_indent}}}draft:\s*", line) + ] + if drafts != ["draft: true"]: + failures.append( + f"release action step at line {start + 1} requires exactly one literal draft: true" + ) + if failures: + return False, "; ".join(failures) + return True, f"{len(blocks)} release-writing action steps preserve draft state" + + +def mutate_last_action(text: str, replacement: str | None) -> str: + lines = text.splitlines() + blocks = action_blocks(text) + if not blocks: + raise ValueError("cannot mutate workflow without a release action") + start, end, child_indent = blocks[-1] + draft_indexes = [ + index + for index in range(start, end) + if re.match(rf"^ {{{child_indent}}}draft:\s*", lines[index]) + ] + if len(draft_indexes) != 1: + raise ValueError("last release action must contain exactly one draft setting") + index = draft_indexes[0] + if replacement is None: + del lines[index] + else: + lines[index] = " " * child_indent + replacement + return "\n".join(lines) + "\n" + + +workflow = WORKFLOW.read_text(encoding="utf-8") +valid, message = validate(workflow) +if not valid: + raise SystemExit(f"FAIL: fixed workflow rejected: {message}") +print(f"PASS: {message}") + +for label, replacement in (("missing draft", None), ("draft false", "draft: false")): + fixture = mutate_last_action(workflow, replacement) + valid, _ = validate(fixture) + if valid: + raise SystemExit(f"FAIL: validator accepted fixture with {label}") + print(f"PASS: validator rejects {label}")