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
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
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
3 changes: 3 additions & 0 deletions .github/workflows/regenerate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ jobs:
p.write_text(src)
PY

- name: Patch ApiClient close lifecycle
run: python3 scripts/patch_api_client_close.py

- name: Clean up generated artifacts
run: |
rm -f openapi.yaml
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"

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

### Fixed

- Add `ApiClient.close()` and `RESTClientObject.close()` so callers can release urllib3 connection pools and use context managers safely.

## [0.2.1] - 2026-05-20

### Changed

- Regenerated Results API client from the latest OpenAPI spec.

## [0.2.0] - 2026-05-19

### Changed

- Managed database API updates and publish workflow.

## [0.1.0] - 2026-04-25

### Changed

- Initial published 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.
6 changes: 5 additions & 1 deletion hotdata/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,12 @@ def __init__(
def __enter__(self):
return self

def close(self) -> None:
if self.rest_client is not None:
self.rest_client.close()

def __exit__(self, exc_type, exc_value, traceback):
pass
self.close()

@property
def user_agent(self):
Expand Down
4 changes: 4 additions & 0 deletions hotdata/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ def __init__(self, configuration) -> None:
else:
self.pool_manager = urllib3.PoolManager(**pool_args)

def close(self) -> None:
if self.pool_manager is not None:
self.pool_manager.clear()

def request(
self,
method,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "hotdata"
version = "0.2.1"
version = "0.2.2"
description = "Hotdata API"
authors = [
{name = "Hotdata",email = "developers@hotdata.dev"},
Expand Down
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

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")
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()
61 changes: 61 additions & 0 deletions scripts/patch_api_client_close.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Re-apply ApiClient.close() after OpenAPI regeneration."""

from __future__ import annotations

import pathlib
import sys

ROOT = pathlib.Path(__file__).resolve().parents[1]


def patch_rest_client() -> None:
path = ROOT / "hotdata" / "rest.py"
src = path.read_text()
needle = " self.pool_manager = urllib3.PoolManager(**pool_args)\n\n def request("
insert = (
" self.pool_manager = urllib3.PoolManager(**pool_args)\n\n"
" def close(self) -> None:\n"
" if self.pool_manager is not None:\n"
" self.pool_manager.clear()\n\n"
" def request("
)
if "def close(self) -> None:" in src and "pool_manager.clear()" in src:
return
if needle not in src:
sys.exit(f"Failed to patch {path}: RESTClientObject anchor not found")
path.write_text(src.replace(needle, insert, 1))


def patch_api_client() -> None:
path = ROOT / "hotdata" / "api_client.py"
src = path.read_text()
needle = (
" def __enter__(self):\n"
" return self\n\n"
" def __exit__(self, exc_type, exc_value, traceback):\n"
" pass\n"
)
replacement = (
" def __enter__(self):\n"
" return self\n\n"
" def close(self) -> None:\n"
" if self.rest_client is not None:\n"
" self.rest_client.close()\n\n"
" def __exit__(self, exc_type, exc_value, traceback):\n"
" self.close()\n"
)
if "def close(self) -> None:" in src and "self.rest_client.close()" in src:
return
if needle not in src:
sys.exit(f"Failed to patch {path}: ApiClient context manager anchor not found")
path.write_text(src.replace(needle, replacement, 1))


def main() -> None:
patch_rest_client()
patch_api_client()


if __name__ == "__main__":
main()
Loading