Skip to content

Commit 61c22c7

Browse files
committed
ci: add standardized release script and automation
Add scripts/release.sh for version bumps, changelog updates, tagging, and GitHub Release creation via CI. Enforce changelog checks on version PRs.
1 parent cd8ac5d commit 61c22c7

9 files changed

Lines changed: 546 additions & 1 deletion

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Check release metadata
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'pyproject.toml'
7+
- 'CHANGELOG.md'
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
check:
14+
name: Verify changelog matches version bump
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
18+
with:
19+
fetch-depth: 0
20+
21+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
22+
with:
23+
python-version: '3.12'
24+
25+
- name: Check release metadata
26+
run: python scripts/check-release.py

.github/workflows/publish.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ jobs:
2828

2929
- name: Verify tag matches pyproject version
3030
run: |
31-
# Release tags must start with `v` followed by a PEP 440 version (e.g. v1.2.3, v1.2.3a1).
3231
if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
3332
echo "Release tag '$GITHUB_REF_NAME' must start with 'v' followed by a digit (e.g. v1.0.0)" >&2
3433
exit 1

.github/workflows/release.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: GitHub Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v[0-9]*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
name: Create GitHub Release
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
17+
18+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
19+
with:
20+
python-version: '3.12'
21+
22+
- name: Read package metadata
23+
id: meta
24+
run: |
25+
pkg_name=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['name'])")
26+
pkg_version="${GITHUB_REF_NAME#v}"
27+
echo "name=${pkg_name}" >> "$GITHUB_OUTPUT"
28+
echo "version=${pkg_version}" >> "$GITHUB_OUTPUT"
29+
30+
- name: Extract changelog notes
31+
id: notes
32+
run: |
33+
set -euo pipefail
34+
version="${GITHUB_REF_NAME#v}"
35+
if [[ -f CHANGELOG.md ]]; then
36+
body="$(python scripts/extract-changelog.py "$version")"
37+
else
38+
body="Release ${version}."
39+
fi
40+
{
41+
echo "body<<EOF"
42+
echo "$body"
43+
echo "EOF"
44+
} >> "$GITHUB_OUTPUT"
45+
46+
- name: Create GitHub Release
47+
uses: softprops/action-gh-release@1e812e8210a4a8a0b23075e5795f2a4e2b2a0b7 # v2.2.2
48+
with:
49+
tag_name: ${{ github.ref_name }}
50+
name: ${{ steps.meta.outputs.name }} ${{ steps.meta.outputs.version }}
51+
body: ${{ steps.notes.outputs.body }}
52+
generate_release_notes: false
53+
make_latest: true

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
## [0.1.0] - 2026-05-19
11+
12+
### Added
13+
14+
- Initial release with LlamaIndex tools for Hotdata managed databases.

RELEASING.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Releasing
2+
3+
Every release uses `./scripts/release.sh`. Do not bump versions, tag, or create GitHub Releases manually.
4+
5+
## One-time setup
6+
7+
- Install [GitHub CLI](https://cli.github.com/) (`gh`) and authenticate.
8+
- Ensure PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) is configured for this repo (`publish.yml` uses the `pypi` GitHub environment).
9+
10+
## Release steps
11+
12+
1. Add user-facing notes under `## [Unreleased]` in `CHANGELOG.md`.
13+
2. Prepare the release PR:
14+
15+
```bash
16+
./scripts/release.sh prepare patch # or minor | major | 1.2.3
17+
```
18+
19+
3. Merge the PR after CI passes (including the changelog check).
20+
4. Publish from a clean default branch checkout:
21+
22+
```bash
23+
git checkout main # or master for hotdata-marimo
24+
git pull
25+
./scripts/release.sh publish
26+
```
27+
28+
## What happens automatically
29+
30+
Pushing a `vX.Y.Z` tag triggers two workflows:
31+
32+
| Workflow | Purpose |
33+
|----------|---------|
34+
| `publish.yml` | Build wheel/sdist and publish to PyPI |
35+
| `release.yml` | Create the GitHub Release with notes from `CHANGELOG.md` |
36+
37+
## Enforcement
38+
39+
- **PR check** (`check-release.yml`): if `pyproject.toml` version changes, `CHANGELOG.md` must contain a matching `## [X.Y.Z]` section.
40+
- **Tag check** (`publish.yml`): the tag (without `v`) must match `[project].version` in `pyproject.toml`.
41+
- **Publish guard** (`release.sh publish`): refuses to tag if the changelog section is missing.
42+
43+
Together, these make it hard to ship a version without changelog notes or a GitHub Release.

scripts/check-release.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env python3
2+
"""Fail CI when pyproject.toml version changes without a matching CHANGELOG entry."""
3+
4+
from __future__ import annotations
5+
6+
import re
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
12+
def git_show(path: str, ref: str) -> str:
13+
try:
14+
return subprocess.check_output(["git", "show", f"{ref}:{path}"], text=True)
15+
except subprocess.CalledProcessError:
16+
return ""
17+
18+
19+
def read_version(text: str) -> str:
20+
match = re.search(r'(?m)^version = "([^"]+)"', text)
21+
if not match:
22+
raise SystemExit("could not read version from pyproject.toml")
23+
return match.group(1)
24+
25+
26+
def has_changelog_section(version: str) -> bool:
27+
changelog = Path("CHANGELOG.md")
28+
if not changelog.exists():
29+
return False
30+
return bool(re.search(rf"^## \[{re.escape(version)}\]", changelog.read_text(), re.M))
31+
32+
33+
def main() -> None:
34+
base = "origin/main"
35+
for candidate in ("origin/main", "origin/master"):
36+
if subprocess.call(["git", "rev-parse", "--verify", candidate], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
37+
base = candidate
38+
break
39+
40+
current = Path("pyproject.toml").read_text()
41+
previous = git_show("pyproject.toml", base)
42+
if not previous:
43+
print("skip: no base pyproject.toml to compare")
44+
return
45+
46+
old_version = read_version(previous)
47+
new_version = read_version(current)
48+
if old_version == new_version:
49+
print(f"version unchanged ({new_version})")
50+
return
51+
52+
if not has_changelog_section(new_version):
53+
raise SystemExit(
54+
f"pyproject.toml version bumped to {new_version} but CHANGELOG.md "
55+
f"has no '## [{new_version}]' section"
56+
)
57+
58+
print(f"release metadata ok for {new_version}")
59+
60+
61+
if __name__ == "__main__":
62+
main()

scripts/extract-changelog.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env python3
2+
"""Print the Keep a Changelog section for a release version."""
3+
4+
from __future__ import annotations
5+
6+
import re
7+
import sys
8+
from pathlib import Path
9+
10+
11+
def extract(changelog: str, version: str) -> str:
12+
pattern = rf"^## \[{re.escape(version)}\].*$"
13+
match = re.search(pattern, changelog, re.M)
14+
if not match:
15+
raise SystemExit(f"no changelog section for {version}")
16+
17+
start = match.start()
18+
rest = changelog[match.end() :]
19+
next_heading = re.search(r"^## \[", rest, re.M)
20+
end = match.end() + (next_heading.start() if next_heading else len(rest))
21+
section = changelog[start:end].strip()
22+
title, _, body = section.partition("\n")
23+
return body.strip() or f"Release {version}."
24+
25+
26+
def main() -> None:
27+
if len(sys.argv) != 2:
28+
raise SystemExit("usage: extract-changelog.py VERSION")
29+
30+
version = sys.argv[1]
31+
changelog = Path("CHANGELOG.md").read_text()
32+
print(extract(changelog, version))
33+
34+
35+
if __name__ == "__main__":
36+
main()

scripts/publish-workflow.sh

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env bash
2+
# Generate publish.yml for a package. Usage: publish-workflow.sh hotdata-runtime
3+
set -euo pipefail
4+
pkg="${1:?package name}"
5+
cat <<EOF
6+
name: Publish to PyPI
7+
8+
on:
9+
push:
10+
tags:
11+
- 'v[0-9]*'
12+
13+
concurrency:
14+
group: pypi-publish-\${{ github.ref_name }}
15+
cancel-in-progress: false
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
build:
22+
name: Build distribution
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
26+
27+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
28+
with:
29+
python-version: '3.12'
30+
31+
- name: Install build tooling
32+
run: python -m pip install --upgrade build twine
33+
34+
- name: Verify tag matches pyproject version
35+
run: |
36+
if [[ ! "\$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
37+
echo "Release tag '\$GITHUB_REF_NAME' must start with 'v' followed by a digit (e.g. v1.0.0)" >&2
38+
exit 1
39+
fi
40+
tag="\${GITHUB_REF_NAME#v}"
41+
pkg_version=\$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
42+
if [ "\$tag" != "\$pkg_version" ]; then
43+
echo "Release tag (\$tag) does not match pyproject.toml version (\$pkg_version)" >&2
44+
exit 1
45+
fi
46+
47+
- name: Build sdist and wheel
48+
run: python -m build
49+
50+
- name: Check distribution metadata
51+
run: python -m twine check --strict dist/*
52+
53+
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
54+
with:
55+
name: dist
56+
path: dist/
57+
58+
publish:
59+
name: Publish to PyPI
60+
needs: build
61+
runs-on: ubuntu-latest
62+
environment:
63+
name: pypi
64+
url: https://pypi.org/p/${pkg}
65+
permissions:
66+
id-token: write
67+
steps:
68+
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
69+
with:
70+
name: dist
71+
path: dist/
72+
73+
- name: Publish via Trusted Publishing
74+
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
75+
EOF

0 commit comments

Comments
 (0)