From 68ae814697c45625a3193f7fc31ff61552cb14f5 Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Fri, 31 Jul 2026 22:02:02 -0400 Subject: [PATCH] chore(release): 0.3.0, and make the release guards able to fail (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps both version declarations and rolls [Unreleased] into [0.3.0]. Two latent defects in the release plumbing are fixed first, because a PyPI upload can only be yanked, never replaced. The wheel guard could not catch what it exists to catch. It asserted >= 14 JinjaX templates, cotton wrappers and cotton partials — figures from 0.1.0, when 14 components in one set was the whole package. The real counts are now 21 wrappers and 105 partials, so a packaging regression that dropped all seven primitives from all five themes would have left exactly 14 and passed. Its required-assets list had also never picked up cf_ui_primitives.json from #52. Bumping the numbers would only move the staleness to the next component, so the mechanism is replaced: the guard derives its expectation from the source tree and asserts the wheel contains every shipped template and asset. It cannot drift, and it fails on one missing file rather than only on a missing fourteen. That swap carries the same hazard in a new place — if the source layout moves, the expectation set silently becomes empty and every wheel passes. So the guard asserts its own scan found something first, with a floor far below the real figure. The version was declared twice with nothing comparing them. pyproject.toml and _version.py both carry it and the tag-match step reads only the former, so a bump missing _version.py would publish a correctly-named wheel whose cf_ui.__version__ reported the previous version, permanently, with every gate green. Now asserted in the unit suite rather than at release time, because the drift is introduced when the bump commit is written. Both new guards were mutation-tested rather than assumed: version agreement clean pass; fails with _version.py left at 0.2.0 PEP 440 shape isolated by mutating both declarations to 0.3.0.dev0, so agreement holds and only the shape check can fire — it did wheel, intact pass, 237/237 wheel, 7 daisy primitives removed fail (the old guard's blind spot) wheel, primitives.json removed fail (never in the old list) wheel, src/ emptied of templates fail, refusing to pass vacuously Gate: 2313 passed, 13 skipped · ruff check and format clean · mkdocs build --strict clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- .github/workflows/release.yml | 86 ++++++++++++++++++----------------- CHANGELOG.md | 35 ++++++++++++++ pyproject.toml | 2 +- src/cf_ui/_version.py | 2 +- tests/unit/test_version.py | 46 +++++++++++++++++++ 5 files changed, 128 insertions(+), 43 deletions(-) create mode 100644 tests/unit/test_version.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a60bc26..59fc81a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,54 +42,58 @@ jobs: # them up with no explicit include, which is exactly what makes a # regression here quiet: a packaging change that drops them produces a # wheel that installs cleanly, imports cleanly, and renders nothing. - # The counts come from the same invariant the unit suite asserts — - # 14 components per theme, in both template sets. - - name: Wheel must contain the templates and generated assets + # + # This compares the wheel against the *source tree* rather than against + # hardcoded counts. The counts were the original design and they went + # stale the moment the primitives layer landed: the thresholds said + # ">= 14 per template set" while the real figures had grown to 21 + # wrappers and 105 partials, so a regression dropping all seven + # primitives from all five themes would have left exactly 14 and passed. + # Deriving the expectation cannot drift, and it fails on one missing + # file instead of only on a missing fourteen. + - name: Wheel must contain every shipped template and asset run: | python - <<'PY' import pathlib, sys, zipfile + SRC = pathlib.Path("src") + SHIPPED_SUFFIXES = {".jinja", ".html", ".js", ".mjs", ".css", ".json"} + + # Wheel entries are src-relative (`cf_ui/templates/...`), so the + # relative path is directly comparable with no rewriting. + expected = { + p.relative_to(SRC).as_posix() + for p in SRC.rglob("*") + if p.is_file() + and p.suffix in SHIPPED_SUFFIXES + and ("templates" in p.parts or "static" in p.parts) + } + + # The guard this replaces could not fail once its thresholds went + # stale. This one has the same hazard in a different place: if the + # source layout moves, `expected` silently becomes empty and every + # wheel passes. So assert the scan found something first — the + # floor is deliberately far below the real figure (237 at 0.3.0), + # low enough never to need editing, high enough that an empty or + # near-empty scan is caught. + if len(expected) < 100: + sys.exit( + f"only {len(expected)} shipped files found under {SRC}/ — the " + f"scan itself is broken, so this check proves nothing. Fix the " + f"scan before trusting a pass." + ) + wheel = next(iter(sorted(pathlib.Path("dist").glob("*.whl")))) - names = zipfile.ZipFile(wheel).namelist() - print(f"{wheel.name}: {len(names)} entries") + names = set(zipfile.ZipFile(wheel).namelist()) + print(f"{wheel.name}: {len(names)} entries / {len(expected)} expected from {SRC}/") - def count(suffix: str, *parts: str) -> int: - return sum( - 1 for n in names - if n.endswith(suffix) and all(p in n for p in parts) - ) + missing = sorted(expected - names) + if missing: + shown = "\n ".join(missing[:20]) + more = f"\n ... and {len(missing) - 20} more" if len(missing) > 20 else "" + sys.exit(f"wheel is missing {len(missing)} shipped file(s):\n {shown}{more}") - failures = [] - - jinja = count(".jinja", "cf_ui/templates/jinja/") - if jinja < 14: - failures.append(f"only {jinja} JinjaX component templates (expected >= 14)") - - wrappers = count(".html", "cf_ui/templates/cotton/cf/") - if wrappers < 14: - failures.append(f"only {wrappers} cotton wrappers (expected >= 14)") - - partials = count(".html", "cf_ui/templates/cotton/_themes/") - if partials < 14: - failures.append(f"only {partials} cotton theme partials (expected >= 14)") - - # assets.jinja is a sibling of templates/jinja/, so it is the one a - # theme-shaped glob would miss — and Litestar's asset macros come - # from it (#42). - for required in [ - "cf_ui/templates/cf_ui/assets.jinja", - "cf_ui/static/cf_ui/cf_ui_alpine.js", - "cf_ui/static/cf_ui/cf_ui_axes.css", - "cf_ui/static/cf_ui/cf_ui_axes.json", - "cf_ui/static/cf_ui/cf_ui_tailwind_plugin.mjs", - ]: - if not any(n.endswith(required) for n in names): - failures.append(f"missing {required}") - - if failures: - sys.exit("wheel is missing shipped content:\n " + "\n ".join(failures)) - - print(f"ok: {jinja} jinja, {wrappers} wrappers, {partials} partials, assets present") + print(f"ok: all {len(expected)} shipped templates and assets present") PY - uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a2639..834a6b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## [Unreleased] +## [0.3.0] — 2026-07-31 + +The primitives layer. 0.2.0 shipped 14 *structural* components — card, modal, +navbar, table — and nothing underneath them, which is the layer an app +actually uses hundreds of times. This release adds it: seven primitives across +five themes in both template sets, on one shared vocabulary. + +**Upgrading from 0.2.0 on the daisy theme:** `cf_ui_head` now emits Tailwind's +Play CDN script alongside the daisyUI stylesheet (#56). If you already have a +real Tailwind build supplying both layers, set `CF_UI_DAISY_CDN = "off"` to +keep the previous single-tag output. Every other theme is unchanged. + +### Changed — the release wheel guard is derived, not counted (#63) + +- **`release.yml` checked the wheel against hardcoded floors and they had gone + stale.** It asserted `>= 14` JinjaX templates, cotton wrappers and cotton + partials — figures from 0.1.0, when 14 components in one set was the whole + package. By 0.3.0 the real counts are 21 wrappers and 105 partials, so a + packaging regression dropping all seven primitives from all five themes + would have left exactly 14 and **passed**. The list of required static + assets had also never picked up `cf_ui_primitives.json` from #52. The guard + now derives its expectation from the source tree and asserts the wheel + contains every shipped template and asset — it cannot go stale, and it fails + on one missing file rather than only on a missing fourteen. It also checks + that its own scan found something first, because a guard whose input + silently becomes empty passes vacuously, which is the failure being replaced. + +- **The version was declared twice with nothing comparing them.** + `pyproject.toml` and `src/cf_ui/_version.py` both carry it, and the tag-match + step reads only the former — so a bump that missed `_version.py` would + publish a correctly-named wheel whose `cf_ui.__version__` reported the + previous version, permanently, with every gate green. `tests/unit/test_version.py` + now asserts they agree, in the unit suite rather than at release time, + because the drift is introduced when the bump is written. + ### Decided — layout is out of scope; cf-ui ships no grid (#55) - **Tier 3 (`grid`) is closed as won't-do, and `docs/primitives.md` now says diff --git a/pyproject.toml b/pyproject.toml index 33ac4be..86cc27a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cf-ui" -version = "0.2.0" +version = "0.3.0" description = "CSS framework UI kit for component-framework — Bulma, Bootstrap, Foundation, Fomantic, DaisyUI" readme = "README.md" requires-python = ">=3.11" diff --git a/src/cf_ui/_version.py b/src/cf_ui/_version.py index d3ec452..493f741 100644 --- a/src/cf_ui/_version.py +++ b/src/cf_ui/_version.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 0000000..00121af --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,46 @@ +"""The version is declared twice, so something has to check they agree (#63). + +``pyproject.toml`` carries it because that is what hatchling builds the wheel +from and what ``release.yml`` compares the git tag against. +``src/cf_ui/_version.py`` carries it because ``cf_ui.__version__`` has to +answer at runtime without reading package metadata. + +Nothing connected the two. The release workflow's tag-match step reads +``pyproject.toml`` only, so a bump that missed ``_version.py`` would publish a +correctly-named wheel whose ``cf_ui.__version__`` reported the *previous* +version — indefinitely, and with every gate green. This lives in the unit +suite rather than in ``release.yml`` on purpose: the drift is introduced when +the bump commit is written, and that is when it should fail, not at the tag. +""" + +import re +import tomllib +from pathlib import Path + +import cf_ui + +PYPROJECT = Path(__file__).parent.parent.parent / "pyproject.toml" + + +def _declared_version() -> str: + return tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"]["version"] + + +def test_pyproject_and_dunder_version_agree(): + assert cf_ui.__version__ == _declared_version(), ( + f"cf_ui.__version__ is {cf_ui.__version__!r} but pyproject.toml declares " + f"{_declared_version()!r} — bump both, or the published wheel reports a " + f"version its own metadata contradicts" + ) + + +def test_version_is_pep440_release(): + """A stray suffix would sort wrong on PyPI and break the tag comparison. + + ``release.yml`` asserts ``pyproject`` == ``${tag#v}`` as a literal string, + so `0.3.0.dev0` left in by accident fails there — but only at the tag, + after the commit is already on master. + """ + assert re.fullmatch(r"\d+\.\d+\.\d+", _declared_version()), ( + f"{_declared_version()!r} is not a plain X.Y.Z release version" + )