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" + )