Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 45 additions & 41 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/cf_ui/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.3.0"
46 changes: 46 additions & 0 deletions tests/unit/test_version.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading