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
256 changes: 256 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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<version> # must equal package.json's version — the
git push origin v<version> # 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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Loading
Loading