Skip to content
Open
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
26 changes: 26 additions & 0 deletions .github/workflows/check-release.yml
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
branches: ["main", "master"]
pull_request:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
test:
name: Test (Python 3.12)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v6
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync --locked

- name: Test
run: uv run pytest -v
1 change: 0 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +41 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: using EOF as the heredoc delimiter for multiline GITHUB_OUTPUT is fragile — if any future changelog entry contains a line that is exactly EOF, this step will produce a malformed output and the release notes will be wrong or the step will fail. GitHub's own docs recommend a random delimiter. (not blocking)

delim="EOF_$(openssl rand -hex 8)"
{
  echo "body<<$delim"
  echo "$body"
  echo "$delim"
} >> "$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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: make_latest: true is unconditional, but the tag pattern 'v[0-9]*' matches pre-releases like v1.0.0a1 / v2.0.0rc1. Marking a pre-release as "latest" on GitHub is usually wrong (it surfaces as the default download for users). Consider deriving this from whether the version has a PEP 440 suffix, e.g. compute is_prerelease in the meta step and pass make_latest: ${{ steps.meta.outputs.is_prerelease == 'false' }}. (not blocking)

20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 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

- Managed database helpers on `HotdataClient`.

## [0.1.0] - 2026-05-06

### Added

- Initial release.
43 changes: 43 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions scripts/check-release.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +34 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this hardcodes the base to origin/main (or origin/master) instead of using the PR's actual base ref. For a PR that targets a non-default branch (release branch, stacked PR), the diff is taken against the wrong baseline and could either skip the check incorrectly or fail spuriously. In pull_request events the base is available as $GITHUB_BASE_REF:

base_ref = os.environ.get("GITHUB_BASE_REF") or "main"
base = f"origin/{base_ref}"

(not blocking — fine for the standard PR-to-main flow this repo uses today.)


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()
36 changes: 36 additions & 0 deletions scripts/extract-changelog.py
Original file line number Diff line number Diff line change
@@ -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")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: title is assigned but never used — _, _, body = section.partition("\n") or just slicing past the first newline reads a bit cleaner. (not blocking)

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()
75 changes: 75 additions & 0 deletions scripts/publish-workflow.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
name: Publish to PyPI

on:
push:
tags:
- 'v[0-9]*'

concurrency:
group: pypi-publish-\${{ github.ref_name }}
cancel-in-progress: false

permissions:
contents: read

jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'

- name: Install build tooling
run: python -m pip install --upgrade build twine

- name: Verify tag matches pyproject version
run: |
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
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
Loading
Loading