From e32a4607ec102d0fb397a1debf246f3d926af481 Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Thu, 30 Jul 2026 21:42:04 -0400 Subject: [PATCH] ci(release): publish to PyPI via trusted publishing (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `v*` tag now builds, validates, and publishes. Trusted publishing means no API token is created, stored, or rotated: the job mints a short-lived OIDC token that PyPI exchanges for an upload token scoped to this project alone. There is no repo secret that could leak. The upload is a separate job gated on a `pypi` environment, so protection rules can require a review before anything reaches PyPI. Three assertions run before the artifact is handed to the publish job, all of them because a bad upload can only be yanked, never replaced: * `twine check` on both distributions — a malformed README renders as a broken PyPI page that cannot be fixed in place. * The tag must equal `pyproject.toml`'s version, or the wheel would publish under a name the tag does not describe. * The wheel must actually contain the templates. This is the one worth spelling out: cf-ui *is* templates, and they ship inside the package so hatchling includes them with no explicit config — which is exactly what makes a regression here quiet. A packaging change that dropped them would produce a wheel that installs cleanly, imports cleanly, and renders nothing. The check counts JinjaX templates, cotton wrappers, and cotton theme partials against the 14-per-set invariant the unit suite already asserts, then requires `assets.jinja` (a sibling of `templates/jinja/`, so a theme-shaped glob would miss it — see #42) and the four static assets. Verified against a real wheel: 70 jinja, 14 wrappers, 70 partials, assets present. Verified it can fail: a wheel rebuilt with `cf_ui/templates/` stripped exits 1 naming all four missing groups. Closes #45 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- .github/workflows/release.yml | 119 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 18 +++++ 2 files changed, 137 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a60bc26 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,119 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + python-version: "3.12" + + - name: Build sdist and wheel + run: uv build + + # Catches a malformed README or broken metadata before anything is + # uploaded — a bad upload cannot be replaced, only yanked. + - name: Validate distributions + run: uvx twine check dist/* + + # The tag is the release's identity; if it disagrees with the version in + # pyproject.toml the wheel would be published under a name the tag does + # not describe. Fail here rather than after upload. + - name: Tag must match the declared version + if: startsWith(github.ref, 'refs/tags/v') + run: | + declared=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + tagged="${GITHUB_REF_NAME#v}" + echo "pyproject: $declared / tag: $tagged" + test "$declared" = "$tagged" + + # cf-ui is templates. They live inside the package so hatchling picks + # 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 + run: | + python - <<'PY' + import pathlib, sys, zipfile + + wheel = next(iter(sorted(pathlib.Path("dist").glob("*.whl")))) + names = zipfile.ZipFile(wheel).namelist() + print(f"{wheel.name}: {len(names)} entries") + + 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) + ) + + 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") + PY + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + # Gates the upload behind a named environment, so protection rules can + # require a review before anything reaches PyPI. + environment: + name: pypi + url: https://pypi.org/p/cf-ui + permissions: + # Required for trusted publishing: the job mints a short-lived OIDC + # token that PyPI exchanges for an upload token. No API token is stored. + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index c99f7a5..5692a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,24 @@ License, Python 3.11–3.14, Framework and Topic entries. There were none at all, which is what the PyPI page is built from. +### Release automation (#45) + +- **Added `.github/workflows/release.yml`.** A `v*` tag builds, validates, and + publishes to PyPI via **trusted publishing** — the job mints a short-lived + OIDC token that PyPI exchanges for an upload token scoped to this project. + No API token is created, stored, or rotated, so there is no repo secret to + leak. The upload is a separate job behind a `pypi` environment, so + protection rules can require a review before anything reaches PyPI. +- **The build job refuses to hand off a wheel that is missing its templates.** + cf-ui is templates; they live inside the package so hatchling picks them up + with no explicit include, which is exactly what would make a regression here + quiet — a packaging change that dropped them would produce a wheel that + installs cleanly, imports cleanly, and renders nothing. The job counts the + JinjaX templates, the cotton wrappers and theme partials, and asserts + `assets.jinja` and the generated axis assets are present. It also checks the + tag against `pyproject.toml`'s version and runs `twine check`, because a bad + upload can only be yanked, never replaced. + ### Documentation — a published site, and three broken README samples fixed (#39) - **A MkDocs + Material site now builds from `docs/` and deploys to GitHub