diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bd3b08d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,256 @@ +name: Release + +# Tag-driven publish. Push a v* tag and this verifies, runs the full CI matrix +# against the tagged commit, builds, gates the artifacts, publishes to PyPI via +# OIDC trusted publishing, and opens a GitHub Release. +# +# NO API TOKEN IS STORED ANYWHERE. Trusted publishing has PyPI verify a +# short-lived OIDC token minted by GitHub for this specific repo + workflow + +# environment, so there is no long-lived secret to leak or rotate. The +# publisher is configured on PyPI (added 2026-08-03) as: +# Owner: pip-install-python +# Repository: dash-mui-scheduler <- must match the GitHub repo +# name EXACTLY; OIDC claims do +# not follow rename redirects +# Workflow name: release.yml +# Environment name: pypi +# +# The version lives in package.json (setup.py reads it) — NOT pyproject.toml, +# which carries only the build-system table. The component bundle +# (dash_mui_scheduler/*.min.js) is committed to git, so the build needs no +# Node toolchain. +# +# Same conventions as ci.yml/cd.yml: least-privilege `permissions`, a +# `concurrency` group, and an explicit `timeout-minutes` on every job (the +# default is six hours). + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + dry_run: + description: "Build and verify, but publish to TestPyPI instead of PyPI" + type: boolean + default: true + +permissions: + contents: read + +concurrency: + # Never cancel a release mid-upload: a PyPI version can only be uploaded + # once, so a half-finished publish is unrecoverable. + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + FORCE_COLOR: "1" + +jobs: + verify: + name: Verify the tag + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + # Needed for the ancestry check below. + fetch-depth: 0 + + - name: Tag must match the version in package.json + # Catches the classic release mistake: bumping the code but tagging + # the old number (or the reverse). PyPI would accept the mismatch. + if: startsWith(github.ref, 'refs/tags/v') + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG_VER=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") + echo "tag=$TAG package.json=$PKG_VER" + if [ "$TAG" != "$PKG_VER" ]; then + echo "::error::Tag v$TAG does not match package.json version $PKG_VER" + exit 1 + fi + + - name: Tag must point at a commit that is on main + # A PyPI upload is irreversible. Refuse to publish from a commit that + # never landed on main — a tag on a stale branch or a local experiment + # would otherwise ship straight to users. + if: startsWith(github.ref, 'refs/tags/v') + run: | + # Compare against FETCH_HEAD, not origin/main: on a tag checkout the + # remote-tracking branch may not exist. + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" FETCH_HEAD; then + echo "::error::$GITHUB_SHA is not an ancestor of origin/main — merge the release commit before tagging" + exit 1 + fi + + - name: CHANGELOG must have a section for this version + # The GitHub Release notes are lifted from CHANGELOG.md; without a + # matching section the release ships a placeholder line instead. + if: startsWith(github.ref, 'refs/tags/v') + run: | + VERSION="${GITHUB_REF_NAME#v}" + # -E so the bracket escapes are well-defined (POSIX leaves `\]` in a + # basic regex undefined; GNU and BSD grep happen to agree, ERE does + # not rely on that). + if ! grep -qE "^## \[$VERSION\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## [$VERSION]' section — the GitHub Release would ship placeholder notes" + exit 1 + fi + + # The same matrix cd.yml runs before a deploy, against the tagged commit — + # so a release can never ship something CI rejected. Secretless by design, + # so no `secrets:` block is needed. + ci: + name: ci + needs: verify + uses: ./.github/workflows/ci.yml + + build: + name: Build and gate the artifacts + needs: [verify, ci] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build + run: | + python -m pip install --upgrade pip build twine + python -m build + python -m twine check dist/* + + - name: The sdist must carry the component bundle + # `pip install --no-binary` and downstream repackagers build from the + # sdist; MANIFEST.in is the only thing putting the bundle in there. + run: | + python3 - <<'PY' + import glob + import tarfile + + sdist = glob.glob("dist/*.tar.gz")[0] + with tarfile.open(sdist) as tar: + names = tar.getnames() + assert any( + n.endswith("dash_mui_scheduler/dash_mui_scheduler.min.js") for n in names + ), f"sdist {sdist} ships no component bundle — check MANIFEST.in" + print(f"ok: {sdist} carries the bundle") + PY + + - name: Install the wheel in a clean venv and prove it is a working component package + # The docs site never enters the wheel (setup.py packages only + # dash_mui_scheduler); what CAN silently break is the bundle — a wheel + # that installs but ships no .min.js renders nothing at runtime. This + # asserts the installed package carries its JS dist and reports the + # tagged version. + run: | + EXPECTED=$(python3 -c "import json; print(json.load(open('package.json'))['version'])") + python -m venv /tmp/wheelcheck + /tmp/wheelcheck/bin/pip install --quiet dist/*.whl + # Run from OUTSIDE the checkout. A script fed on stdin puts the cwd + # at sys.path[0], and the repo root holds dash_mui_scheduler/ — from + # there the import resolves to the working tree and every assertion + # below would pass against a wheel that shipped nothing at all. + cd /tmp + EXPECTED="$EXPECTED" /tmp/wheelcheck/bin/python - <<'PY' + import os + import pathlib + from importlib.metadata import version + + import dash_mui_scheduler as pkg + + expected = os.environ["EXPECTED"] + installed = version("dash-mui-scheduler") + assert installed == expected, f"wheel says {installed}, package.json says {expected}" + + here = pathlib.Path(pkg.__file__).resolve().parent + assert "site-packages" in here.parts, f"imported a checkout, not the wheel: {here}" + + assert pkg._js_dist, "no _js_dist — not a loadable Dash component package" + bundle = here / "dash_mui_scheduler.min.js" + assert bundle.exists(), "component bundle missing from the installed wheel" + print(f"ok: dash-mui-scheduler {installed}, bundle {bundle.stat().st_size} bytes") + PY + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + # The environment name must match the publisher configured on PyPI. Add a + # required reviewer on this environment in repo settings for a human + # approval gate between the tag and the upload. + environment: + name: pypi + url: https://pypi.org/p/dash-mui-scheduler + permissions: + # `id-token: write` is what lets GitHub mint the OIDC token PyPI checks. + # Without it trusted publishing fails with an opaque 403. + id-token: write + steps: + - name: A manual run can only publish to TestPyPI + # Without this the job would match neither publish step below, run + # zero steps, and report a misleading green. + if: github.event_name == 'workflow_dispatch' && !inputs.dry_run + run: | + echo "::error::Manual runs publish to TestPyPI only (dry_run: true). To release to PyPI, push a v* tag." + exit 1 + + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to TestPyPI (manual dry run) + if: github.event_name == 'workflow_dispatch' && inputs.dry_run + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + - name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: GitHub Release + needs: publish + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Extract this version's CHANGELOG section + run: | + VERSION="${GITHUB_REF_NAME#v}" + awk -v v="$VERSION" ' + $0 ~ "^## \\[" v "\\]" {found=1; next} + found && /^## \[/ {exit} + found {print} + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "See CHANGELOG.md for details." > release-notes.md + fi + + - name: Create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" dist/* \ + --title "$GITHUB_REF_NAME" \ + --notes-file release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c6bd5b1..7d006a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [1.0.0] - 2026-08-03 + +The component API has been stable since the first release and the docs site is +now on the network standard, so this graduates the package out of 0.x. Nothing +in the component itself changed — existing code keeps working untouched. + +### Fixed +- **`pip install dash-mui-scheduler` no longer leans on a dependency it never + declared.** Every component imports `typing_extensions`, which the package + had been getting for free because current versions of Dash happen to install + it. Anyone resolving to an older Dash could install this package successfully + and then have it fail on import. It is now declared outright. + +### Added +- **Releasing is now one push of a tag.** Publishing to PyPI used to be a manual + upload from a laptop. Pushing a `v*` tag now runs the whole test matrix against + that exact commit, builds the package, proves the built result is a working + component library, publishes it, and opens a GitHub release whose notes are + lifted from this file. No PyPI token is stored anywhere — PyPI verifies a + short-lived identity token that GitHub mints for this repository alone, so + there is no long-lived secret to leak or rotate. `RELEASING.md` documents the + flow. +- **Guard rails against the releases that go wrong quietly.** A release stops + before it can upload if the tag disagrees with the version the package + declares, if the tagged commit never landed on `main`, or if this changelog + has no section for the version being cut. The packaging is checked as well: + both the wheel and the source archive must carry the built component bundle — + a package that installs cleanly and then renders nothing is otherwise + indistinguishable from a good one — and that check reads a clean install of + the built artifact rather than the source tree sitting beside it. + ## [0.1.1] - 2026-08-01 +*Shipped to the documentation site; superseded on PyPI by 1.0.0, which carries +these changes.* + ### Added - **The docs site is now on the 2plot network standard**, the baseline proven on 2plot.ai, 2plot.dev and the other satellite documentation sites: diff --git a/CLAUDE.md b/CLAUDE.md index ed806c0..7d6990a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,10 @@ The React sources live in `src/lib/components/`; the **built bundle + generated are COMMITTED** (`dash_mui_scheduler/*.min.js` + `*.py`), so `pip install -e .` works without npm. Changing anything under `src/` requires `npm install && npm run build` and committing the regenerated artifacts. `setup.py` reads `package.json` for the version — keep them in sync -(currently 0.1.1; PyPI publish is an owner step in `.claude/migration/OWNER-ACTIONS.md`). +(currently 1.0.0). **PyPI publishing is tag-driven** — push a `v*` tag and +`.github/workflows/release.yml` verifies, tests, builds, gates and publishes via OIDC trusted +publishing; see `RELEASING.md`. (PyPI holds 0.1.0, uploaded by hand before that existed; 0.1.1 +shipped to the docs site only.) ## Layout - `dash_mui_scheduler/` — the built package (5 wrappers + bundles). `src/lib/` — React sources. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..af4ad89 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,68 @@ +# Releasing dash-mui-scheduler + +Releases are tag-driven: push a `v*` tag and `.github/workflows/release.yml` +verifies the tag, runs the full CI matrix against the tagged commit, builds, +gates the artifacts, publishes to PyPI via OIDC trusted publishing, and opens a +GitHub Release. No PyPI token is stored anywhere. + +## PyPI trusted publisher — configured + +Added on 2026-08-03 to the existing project (0.1.0 was uploaded by hand on +2026-07-17). For the record, pypi.org → **dash-mui-scheduler** → Manage → +Publishing shows: + +- Owner: `pip-install-python` +- Repository: `dash-mui-scheduler` +- Workflow name: `release.yml` +- Environment name: `pypi` + +The repository name must keep matching letter-for-letter — OIDC claims do not +follow GitHub's rename redirects, so renaming the repo breaks publishing until +the publisher is re-added. + +Optionally add a required reviewer on the `pypi` environment in the GitHub repo +settings to put a human approval gate between the tag and the upload. + +## Cutting a release + +1. The version lives in **package.json** (`setup.py` reads it; `pyproject.toml` + carries only the build-system table). Bump it, and add a + `## [x.y.z] - date` section to `CHANGELOG.md` — the workflow lifts that + section into the GitHub Release notes and **refuses to release without it**. +2. The component bundle (`dash_mui_scheduler/*.min.js`) is committed to git; if + `src/` changed, run `npm run build` and commit the regenerated artifacts in + the same change. +3. Merge to `main`. The workflow runs from the tagged commit and refuses to + publish a commit that is not an ancestor of `origin/main`, so tag only after + the merge — and the tag must point at a commit that contains `release.yml`. +4. Tag and push: + + ```bash + git tag v # must equal package.json's version — the + git push origin v # workflow refuses a mismatch + ``` + +5. Watch Actions → Release: + - **verify** — tag/version parity, the commit is on `main`, the CHANGELOG + section exists; + - **ci** — the same matrix `cd.yml` runs before a deploy (lint, secretless + pytest on both backends, the Docker image built, booted and smoke-tested), + so a release can never ship something CI rejected; + - **build** — wheel + sdist, `twine check`, the sdist proven to carry the + bundle, then the wheel installed into a clean venv and asserted to report + the tagged version and carry its JS dist. That last check runs from + *outside* the checkout on purpose: a script fed to `python` on stdin puts + the working directory at `sys.path[0]`, and the repo root holds + `dash_mui_scheduler/` — run from there it would import the source tree and + pass against a wheel that shipped nothing; + - **publish** — environment `pypi`, OIDC, no stored token; + - **github-release** — release created with the CHANGELOG section as notes. + +## Dry run + +Actions → Release → Run workflow with `dry_run: true` builds and gates +everything, then publishes to **TestPyPI** instead. (The upload step itself +needs a matching trusted publisher on test.pypi.org; the build and the gates run +regardless.) A manual run with `dry_run: false` fails deliberately rather than +reporting a green run that published nothing — releasing to PyPI is the tag's +job. diff --git a/package.json b/package.json index 69dd709..2411d45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dash_mui_scheduler", - "version": "0.1.1", + "version": "1.0.0", "description": "Dash components wrapping MUI X Scheduler — Event Calendar (Community & Premium) and Event Timeline", "main": "build/index.js", "repository": { diff --git a/setup.py b/setup.py index 5e35f4f..f320542 100644 --- a/setup.py +++ b/setup.py @@ -25,9 +25,13 @@ 'Source': 'https://github.com/pip-install-python/dash-mui-scheduler', 'Documentation': 'https://muischeduler.2plot.dev', }, - install_requires=['dash>=2.11.0'], + # typing_extensions is imported directly by every generated wrapper + # (TypedDict/NotRequired/Literal). Recent dash pulls it in unconditionally, + # but the floor here is dash 2.11 — which gated it behind a python_version + # marker — so declare it rather than inherit it. + install_requires=['dash>=2.11.0', 'typing_extensions>=4.1.1'], classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Framework :: Dash', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License',