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
119 changes: 119 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading