diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml new file mode 100644 index 0000000..00e1063 --- /dev/null +++ b/.github/workflows/check-release.yml @@ -0,0 +1,26 @@ +name: Check release metadata + +on: + pull_request: + paths: + - 'pyproject.toml' + - 'CHANGELOG.md' + +permissions: + contents: read + +jobs: + check: + name: Verify changelog matches version bump + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.12' + + - name: Check release metadata + run: python scripts/check-release.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 85077b9..3b40fca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,7 +28,6 @@ jobs: - name: Verify tag matches pyproject version run: | - # Release tags must start with `v` followed by a PEP 440 version (e.g. v1.2.3, v1.2.3a1). if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then echo "Release tag '$GITHUB_REF_NAME' must start with 'v' followed by a digit (e.g. v1.0.0)" >&2 exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..66c6c1e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: GitHub Release + +on: + push: + tags: + - 'v[0-9]*' + +permissions: + contents: write + +jobs: + release: + name: Create GitHub Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.12' + + - name: Read package metadata + id: meta + run: | + pkg_name=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['name'])") + pkg_version="${GITHUB_REF_NAME#v}" + echo "name=${pkg_name}" >> "$GITHUB_OUTPUT" + echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" + + - name: Extract changelog notes + id: notes + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + if [[ -f CHANGELOG.md ]]; then + body="$(python scripts/extract-changelog.py "$version")" + else + body="Release ${version}." + fi + delimiter="EOF_${RANDOM}_${RANDOM}" + { + echo "body<<${delimiter}" + echo "$body" + echo "${delimiter}" + } >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@1e812e8210a4a8a0b23075e5795f2a4e2b2a0b7 # v2.2.2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ steps.meta.outputs.name }} ${{ steps.meta.outputs.version }} + body: ${{ steps.notes.outputs.body }} + generate_release_notes: false + make_latest: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2ea03..e292b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.1.1] - 2026-05-19 + +### Added + +- Managed database widgets (`databases_panel`, `ManagedDatabaseWriter`). + ## [0.1.0] - 2026-05-06 ### Added diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..0b50ff4 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,43 @@ +# Releasing + +Every release uses `./scripts/release.sh`. Do not bump versions, tag, or create GitHub Releases manually. + +## One-time setup + +- Install [GitHub CLI](https://cli.github.com/) (`gh`) and authenticate. +- Ensure PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) is configured for this repo (`publish.yml` uses the `pypi` GitHub environment). + +## Release steps + +1. Add user-facing notes under `## [Unreleased]` in `CHANGELOG.md`. +2. Prepare the release PR: + + ```bash + ./scripts/release.sh prepare patch # or minor | major | 1.2.3 + ``` + +3. Merge the PR after CI passes (including the changelog check). +4. Publish from a clean default branch checkout: + + ```bash + git checkout main # or master for hotdata-marimo + git pull + ./scripts/release.sh publish + ``` + +## What happens automatically + +Pushing a `vX.Y.Z` tag triggers two workflows: + +| Workflow | Purpose | +|----------|---------| +| `publish.yml` | Build wheel/sdist and publish to PyPI | +| `release.yml` | Create the GitHub Release with notes from `CHANGELOG.md` | + +## Enforcement + +- **PR check** (`check-release.yml`): if `pyproject.toml` version changes, `CHANGELOG.md` must contain a matching `## [X.Y.Z]` section. +- **Tag check** (`publish.yml`): the tag (without `v`) must match `[project].version` in `pyproject.toml`. +- **Publish guard** (`release.sh publish`): refuses to tag if the changelog section is missing. + +Together, these make it hard to ship a version without changelog notes or a GitHub Release. diff --git a/scripts/check-release.py b/scripts/check-release.py new file mode 100755 index 0000000..7d437f5 --- /dev/null +++ b/scripts/check-release.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Fail CI when pyproject.toml version changes without a matching CHANGELOG entry.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + + +def git_show(path: str, ref: str) -> str: + try: + return subprocess.check_output(["git", "show", f"{ref}:{path}"], text=True) + except subprocess.CalledProcessError: + return "" + + +def read_version(text: str) -> str: + match = re.search(r'(?m)^version = "([^"]+)"', text) + if not match: + raise SystemExit("could not read version from pyproject.toml") + return match.group(1) + + +def has_changelog_section(version: str) -> bool: + changelog = Path("CHANGELOG.md") + if not changelog.exists(): + return False + return bool(re.search(rf"^## \[{re.escape(version)}\]", changelog.read_text(), re.M)) + + +def main() -> None: + base = "origin/main" + for candidate in ("origin/main", "origin/master"): + if subprocess.call(["git", "rev-parse", "--verify", candidate], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: + base = candidate + break + + current = Path("pyproject.toml").read_text() + previous = git_show("pyproject.toml", base) + if not previous: + print("skip: no base pyproject.toml to compare") + return + + old_version = read_version(previous) + new_version = read_version(current) + if old_version == new_version: + print(f"version unchanged ({new_version})") + return + + if not has_changelog_section(new_version): + raise SystemExit( + f"pyproject.toml version bumped to {new_version} but CHANGELOG.md " + f"has no '## [{new_version}]' section" + ) + + print(f"release metadata ok for {new_version}") + + +if __name__ == "__main__": + main() diff --git a/scripts/extract-changelog.py b/scripts/extract-changelog.py new file mode 100755 index 0000000..c2caef1 --- /dev/null +++ b/scripts/extract-changelog.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Print the Keep a Changelog section for a release version.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def extract(changelog: str, version: str) -> str: + pattern = rf"^## \[{re.escape(version)}\].*$" + match = re.search(pattern, changelog, re.M) + if not match: + raise SystemExit(f"no changelog section for {version}") + + start = match.start() + rest = changelog[match.end() :] + next_heading = re.search(r"^## \[", rest, re.M) + end = match.end() + (next_heading.start() if next_heading else len(rest)) + section = changelog[start:end].strip() + title, _, body = section.partition("\n") + return body.strip() or f"Release {version}." + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit("usage: extract-changelog.py VERSION") + + version = sys.argv[1] + changelog = Path("CHANGELOG.md").read_text() + print(extract(changelog, version)) + + +if __name__ == "__main__": + main() diff --git a/scripts/publish-workflow.sh b/scripts/publish-workflow.sh new file mode 100755 index 0000000..88c9db9 --- /dev/null +++ b/scripts/publish-workflow.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Generate publish.yml for a package. Usage: publish-workflow.sh hotdata-runtime +set -euo pipefail +pkg="${1:?package name}" +cat <&2 + exit 1 + fi + tag="\${GITHUB_REF_NAME#v}" + pkg_version=\$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "\$tag" != "\$pkg_version" ]; then + echo "Release tag (\$tag) does not match pyproject.toml version (\$pkg_version)" >&2 + exit 1 + fi + + - name: Build sdist and wheel + run: python -m build + + - name: Check distribution metadata + run: python -m twine check --strict dist/* + + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/${pkg} + permissions: + id-token: write + steps: + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: dist + path: dist/ + + - name: Publish via Trusted Publishing + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 +EOF diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..03aaeae --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +die() { echo "error: $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +usage() { + cat <<'EOF' +Usage: + ./scripts/release.sh prepare [patch|minor|major|X.Y.Z] + ./scripts/release.sh publish + +Workflow: + 1. Move notes from [Unreleased] in CHANGELOG.md (or add them there). + 2. ./scripts/release.sh prepare patch + 3. Merge the release PR. + 4. ./scripts/release.sh publish + +Tag push triggers PyPI publish and GitHub Release creation in CI. +EOF +} + +get_version() { + python3 - <<'PY' +import tomllib +from pathlib import Path +print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]) +PY +} + +get_pkg_name() { + python3 - <<'PY' +import tomllib +from pathlib import Path +print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["name"]) +PY +} + +set_version() { + local ver="$1" + python3 - "$ver" <<'PY' +import re, sys +from pathlib import Path +ver = sys.argv[1] +path = Path("pyproject.toml") +text = path.read_text() +new, n = re.subn(r'(?m)^version = "[^"]+"', f'version = "{ver}"', text, count=1) +if n != 1: + raise SystemExit("could not update version in pyproject.toml") +path.write_text(new) +PY +} + +bump_version() { + local kind="$1" current="$2" + python3 - "$kind" "$current" <<'PY' +import re, sys +kind, current = sys.argv[1], sys.argv[2] +match = re.match(r"^(\d+)\.(\d+)\.(\d+)(.*)$", current) +if not match: + raise SystemExit(f"unsupported version: {current}") +major, minor, patch, suffix = int(match[1]), int(match[2]), int(match[3]), match[4] +if suffix: + raise SystemExit("pre-release versions must be set explicitly as X.Y.Z") +if kind == "patch": + patch += 1 +elif kind == "minor": + minor += 1 + patch = 0 +elif kind == "major": + major += 1 + minor = 0 + patch = 0 +else: + raise SystemExit(f"unknown bump kind: {kind}") +print(f"{major}.{minor}.{patch}") +PY +} + +default_branch() { + local remote="${1:-origin}" + git symbolic-ref --quiet "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s|refs/remotes/${remote}/||" \ + || { git branch -r | sed -n "s|^ ${remote}/\\(main\\|master\\)$|\\1|p" | head -1; } \ + || echo main +} + +ensure_clean() { + [[ -z "$(git status --porcelain)" ]] || die "working tree is not clean" +} + +update_changelog() { + local ver="$1" + local date + date="$(date +%Y-%m-%d)" + python3 scripts/update_changelog.py "$ver" "$date" +} + +cmd_prepare() { + local bump="${1:-}" + [[ -n "$bump" ]] || { usage; die "missing bump kind or explicit version"; } + need gh + need python3 + ensure_clean + + local current new base branch pkg + current="$(get_version)" + if [[ "$bump" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + new="$bump" + else + new="$(bump_version "$bump" "$current")" + fi + [[ "$new" != "$current" ]] || die "new version ($new) equals current ($current)" + + base="$(default_branch)" + git fetch origin "$base" + git checkout "$base" + git pull --ff-only origin "$base" + ensure_clean + + set_version "$new" + update_changelog "$new" + + branch="release/v${new}" + git checkout -b "$branch" + git add pyproject.toml CHANGELOG.md + git commit -m "chore: release v${new}" + + pkg="$(get_pkg_name)" + git push -u origin "$branch" + gh pr create --base "$base" --head "$branch" \ + --title "chore: release ${pkg} v${new}" \ + --body "## Summary +Release **${pkg} v${new}**. + +## Checklist +- [x] Version bumped in \`pyproject.toml\` +- [x] \`CHANGELOG.md\` updated +- [ ] CI green + +After merge, run \`./scripts/release.sh publish\` from a clean \`${base}\` checkout." + + echo "Prepared ${pkg} v${new}. Merge the PR, then run: ./scripts/release.sh publish" +} + +cmd_publish() { + need gh + need python3 + ensure_clean + + local base ver tag + base="$(default_branch)" + git fetch origin "$base" + git checkout "$base" + git pull --ff-only origin "$base" + ensure_clean + + ver="$(get_version)" + tag="v${ver}" + + git rev-parse "$tag" >/dev/null 2>&1 && die "tag $tag already exists" + [[ -f CHANGELOG.md ]] || die "CHANGELOG.md is required" + python3 - "$ver" <<'PY' +import re, sys +from pathlib import Path +ver = sys.argv[1] +text = Path("CHANGELOG.md").read_text() +if not re.search(rf"^## \[{re.escape(ver)}\]", text, re.M): + raise SystemExit(f"CHANGELOG.md missing section for {ver}") +PY + + git tag "$tag" + git push origin "$tag" + + pkg="$(get_pkg_name)" + echo "Pushed ${tag} for ${pkg}." + echo "CI will publish to PyPI and create the GitHub Release." +} + +case "${1:-}" in + prepare) shift; cmd_prepare "${1:-}" ;; + publish) cmd_publish ;; + -h|--help|help|"") usage ;; + *) usage; die "unknown command: $1" ;; +esac diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py new file mode 100755 index 0000000..a81b5a6 --- /dev/null +++ b/scripts/update_changelog.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Update CHANGELOG.md for a new release version.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def update_changelog_text(text: str, ver: str, date: str) -> str: + if re.search(rf"^## \[{re.escape(ver)}\]", text, re.M): + return text + + unreleased = re.search(r"^## \[Unreleased\]\s*\n(.*?)(?=^## \[|\Z)", text, re.M | re.S) + if unreleased: + body = unreleased.group(1).strip() + if body: + section = f"## [{ver}] - {date}\n\n{body}\n\n" + else: + section = ( + f"## [{ver}] - {date}\n\n" + "### Changed\n\n" + f"- Release {ver}\n\n" + ) + return re.sub( + r"^(## \[Unreleased\]\s*\n)(.*?)(?=^## \[|\Z)", + lambda match: match.group(1) + "\n" + section, + text, + count=1, + flags=re.M | re.S, + ) + + section = ( + f"## [Unreleased]\n\n" + f"## [{ver}] - {date}\n\n" + "### Changed\n\n" + f"- Release {ver}\n\n" + ) + first_heading = re.search(r"^## \[", text, re.M) + if first_heading: + pos = first_heading.start() + return text[:pos] + section + text[pos:] + return text.rstrip() + "\n\n" + section + + +def update_changelog_file(path: Path, ver: str, date: str) -> None: + if not path.exists(): + path.write_text( + "# Changelog\n\n" + "All notable changes to this project will be documented in this file.\n\n" + "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n" + "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n" + f"## [Unreleased]\n\n" + f"## [{ver}] - {date}\n\n" + "### Changed\n\n" + f"- Release {ver}\n" + ) + return + + path.write_text(update_changelog_text(path.read_text(), ver, date)) + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit("usage: update_changelog.py VERSION YYYY-MM-DD") + update_changelog_file(Path("CHANGELOG.md"), sys.argv[1], sys.argv[2]) + + +if __name__ == "__main__": + main() diff --git a/tests/test_update_changelog.py b/tests/test_update_changelog.py new file mode 100644 index 0000000..c34c126 --- /dev/null +++ b/tests/test_update_changelog.py @@ -0,0 +1,48 @@ +import importlib.util +from pathlib import Path + + +def _update_changelog_text(text: str, ver: str, date: str) -> str: + path = Path(__file__).resolve().parents[1] / "scripts" / "update_changelog.py" + spec = importlib.util.spec_from_file_location("update_changelog", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module.update_changelog_text(text, ver, date) + + +HEADER = """# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.1] - 2026-05-19 + +### Added + +- Initial feature. +""" + + +def test_empty_unreleased_inserts_version_without_duplicate_heading(): + result = _update_changelog_text(HEADER, "0.1.2", "2026-05-20") + assert result.count("## [Unreleased]") == 1 + assert "## [0.1.2] - 2026-05-20" in result + assert "The format is based on [Keep a Changelog]" in result.split("## [0.1.2]")[0] + assert result.index("## [0.1.2]") < result.index("## [0.1.1]") + + +def test_populated_unreleased_moves_notes_into_new_section(): + text = HEADER.replace( + "## [Unreleased]\n\n", + "## [Unreleased]\n\n### Added\n\n- New widget.\n\n", + ) + result = _update_changelog_text(text, "0.1.2", "2026-05-20") + assert result.count("## [Unreleased]") == 1 + assert "- New widget." in result + assert result.index("- New widget.") < result.index("## [0.1.1]") + assert "The format is based on [Keep a Changelog]" in result.split("## [0.1.2]")[0]