Skip to content

Commit 1824bfe

Browse files
authored
Merge pull request #99 from OO-LD/ci-semver-changelog
ci(release): automate versioning and changelog with python-semantic-release
2 parents 2de730a + cfaa811 commit 1824bfe

9 files changed

Lines changed: 438 additions & 30 deletions

File tree

.github/workflows/main.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,61 @@ jobs:
6969
- name: Check if documentation can be built
7070
run: uv run zensical build -s
7171

72+
version-preview:
73+
# Show contributors what a merge would release, based on the conventional
74+
# commits on the branch. Pull requests only; informational.
75+
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
76+
runs-on: ubuntu-latest
77+
permissions:
78+
contents: read
79+
pull-requests: write
80+
steps:
81+
- name: Check out
82+
uses: actions/checkout@v4
83+
with:
84+
fetch-depth: 0 # PSR needs full history + tags to compute the bump
85+
ref: ${{ github.event.pull_request.head.sha }}
86+
87+
- name: Set up the environment
88+
uses: ./.github/actions/setup-python-env
89+
90+
- name: Compute release preview
91+
id: preview
92+
run: |
93+
current=$(uv version --short 2>/dev/null || echo "unknown")
94+
next=$(uv run semantic-release version --print 2>/dev/null || echo "")
95+
if [ -n "$next" ] && [ "$next" != "$current" ]; then
96+
echo "released=true" >> "$GITHUB_OUTPUT"
97+
else
98+
echo "released=false" >> "$GITHUB_OUTPUT"
99+
fi
100+
echo "current=$current" >> "$GITHUB_OUTPUT"
101+
echo "next=$next" >> "$GITHUB_OUTPUT"
102+
{
103+
echo "changelog<<PSR_EOF"
104+
uv run semantic-release changelog --print 2>/dev/null | head -60 || echo "(no changelog preview)"
105+
echo "PSR_EOF"
106+
} >> "$GITHUB_OUTPUT"
107+
108+
- name: Post release preview comment
109+
uses: marocchino/sticky-pull-request-comment@v2
110+
with:
111+
header: version-preview
112+
message: |
113+
## Release preview
114+
115+
${{ steps.preview.outputs.released == 'true' && format('Merging this PR would release **v{0}** (current: `{1}`).', steps.preview.outputs.next, steps.preview.outputs.current) || format('No version bump from the current commits (stays at `{0}`). Use conventional commit types (`feat`, `fix`, ...) to trigger a release.', steps.preview.outputs.current) }}
116+
117+
<details><summary>Changelog preview (truncated)</summary>
118+
119+
```markdown
120+
${{ steps.preview.outputs.changelog }}
121+
```
122+
123+
</details>
124+
125+
<sub>Computed by python-semantic-release from the conventional commits on this branch.</sub>
126+
72127
benchmark:
73128
runs-on: ubuntu-latest
74129
if: github.event.pull_request.draft == false

.github/workflows/on-release-main.yml

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,71 @@ name: release-main
22

33
on:
44
push:
5-
tags: ['v[0-9]*']
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: release
10+
cancel-in-progress: false
611

712
jobs:
813

9-
publish:
14+
release:
1015
runs-on: ubuntu-latest
16+
permissions:
17+
id-token: write # OIDC trusted publishing to PyPI
18+
contents: write # push the release commit and tag, create the GitHub release
19+
outputs:
20+
released: ${{ steps.version.outputs.released }}
21+
version: ${{ steps.version.outputs.version }}
1122
steps:
1223
- name: Check out
1324
uses: actions/checkout@v4
1425
with:
15-
fetch-depth: 0 # full history + tags so hatch-vcs derives the version
26+
fetch-depth: 0 # full history + tags so PSR can analyze commits
27+
ref: main
1628

1729
- name: Set up the environment
1830
uses: ./.github/actions/setup-python-env
1931

32+
- name: Configure git identity
33+
run: |
34+
git config user.name "github-actions[bot]"
35+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
36+
37+
- name: Semantic release version
38+
id: version
39+
env:
40+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41+
run: |
42+
before=$(git rev-parse HEAD)
43+
uv run semantic-release version
44+
after=$(git rev-parse HEAD)
45+
if [ "$before" != "$after" ]; then
46+
tag=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -1)
47+
echo "released=true" >> "$GITHUB_OUTPUT"
48+
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
49+
echo "Released $tag"
50+
else
51+
echo "released=false" >> "$GITHUB_OUTPUT"
52+
echo "No release for this push."
53+
fi
54+
2055
- name: Build package
56+
if: steps.version.outputs.released == 'true'
2157
run: uv build
2258

23-
- name: Publish package
24-
run: uv publish
25-
env:
26-
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
59+
- name: Check package metadata
60+
if: steps.version.outputs.released == 'true'
61+
run: uvx twine check dist/*
62+
63+
- name: Publish to PyPI (OIDC trusted publishing)
64+
if: steps.version.outputs.released == 'true'
65+
run: uv publish --trusted-publishing always
2766

2867
deploy-docs:
29-
needs: publish
68+
needs: release
69+
if: needs.release.outputs.released == 'true'
3070
permissions:
3171
contents: read
3272
pages: write
@@ -38,6 +78,8 @@ jobs:
3878
steps:
3979
- name: Check out
4080
uses: actions/checkout@v4
81+
with:
82+
ref: main # include the release commit just pushed by PSR
4183

4284
- name: Set up the environment
4385
uses: ./.github/actions/setup-python-env

.pre-commit-config.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# Install both the pre-commit and commit-msg stage hooks on a plain
2+
# `pre-commit install` (no --hook-type flags needed).
3+
default_install_hook_types: [pre-commit, commit-msg]
4+
15
repos:
26
- repo: https://github.com/pre-commit/pre-commit-hooks
37
rev: "v6.0.0"
@@ -20,3 +24,12 @@ repos:
2024
- id: ruff-check
2125
args: [--exit-non-zero-on-fix]
2226
- id: ruff-format
27+
28+
# Enforce Conventional Commits locally so python-semantic-release can derive
29+
# versions and the changelog. Runs on the commit-msg stage; install it with
30+
# `pre-commit install --hook-type commit-msg` (done by `make install`).
31+
- repo: https://github.com/compilerla/conventional-pre-commit
32+
rev: "v4.0.0"
33+
hooks:
34+
- id: conventional-pre-commit
35+
stages: [commit-msg]

CHANGELOG.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
# Changelog
1+
# CHANGELOG
22

3-
## Version 0.1 (development)
3+
All notable changes to this project are documented here. Versions are cut
4+
automatically from Conventional Commits on every merge to main by
5+
[python-semantic-release](https://python-semantic-release.readthedocs.io/). Do
6+
not edit released sections by hand.
47

5-
- Feature A added
6-
- FIX: nasty bug #1729 fixed
7-
- add your changes here!
8+
<!-- version list -->

CITATION.cff

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ cff-version: 1.2.0
22
message: "If you use this software, please cite it as below."
33
type: software
44
title: "oold-python: Object Oriented Linked Data for Python"
5+
# Managed by python-semantic-release (version_variables). Do not edit by hand.
6+
version: 0.16.2
57
abstract: "A Python package for abstract and object-oriented access to knowledge graphs, bridging semantic web technologies (RDF, JSON-LD, SPARQL) with Pydantic type safety."
68
repository-code: "https://github.com/OO-LD/oold-python"
79
url: "https://OO-LD.github.io/oold-python/"

CONTRIBUTING.md

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ make install
2020
Without `make`:
2121

2222
```bash
23-
uv sync
23+
uv sync --all-extras
2424
uv run pre-commit install
2525
```
2626

27+
`pre-commit install` installs both the `pre-commit` and `commit-msg` stage hooks
28+
(via `default_install_hook_types`); the latter enforces Conventional Commits (see
29+
below).
30+
2731
## Making Changes
2832

2933
1. Create a branch: `git checkout -b name-of-your-fix`
@@ -66,19 +70,42 @@ uv run zensical serve
6670
uv run zensical build -s
6771
```
6872

69-
## Releasing
73+
## Commit messages (Conventional Commits)
74+
75+
This project uses [Conventional Commits](https://www.conventionalcommits.org/).
76+
Commit messages drive versioning and the changelog automatically, so the format
77+
matters. The local `commit-msg` hook rejects malformed messages.
78+
79+
Format: `type(scope): subject`, for example `fix: correct sidebar collapse on
80+
small screens`. The scope is optional.
7081

71-
Releases are published automatically by CI when a version tag is pushed.
82+
| Type | Release effect | Use for |
83+
| ---- | -------------- | ------- |
84+
| `feat` | minor bump | a new feature |
85+
| `fix` | patch bump | a bug fix |
86+
| `perf` | patch bump | a performance improvement |
87+
| `docs`, `chore`, `test`, `refactor`, `ci`, `style`, `build` | no release | changes that do not ship user-facing behavior |
88+
| `BREAKING CHANGE:` footer, or `!` after the type | major bump | an incompatible change |
89+
90+
A breaking change is marked either with a `!` (`feat!: drop Python 3.9`) or a
91+
`BREAKING CHANGE:` footer in the commit body.
92+
93+
## Releasing
7294

73-
1. Ensure all changes are merged to `main`
74-
2. Tag the commit and push:
95+
Releases are fully automated by python-semantic-release. You do not tag or bump
96+
the version by hand.
7597

76-
```bash
77-
git tag v0.17.0
78-
git push origin v0.17.0
79-
```
98+
1. Open a PR. CI comments the version that a merge would release, based on your
99+
commits.
100+
2. Merge to `main`. On merge, CI reads the new conventional commits, bumps the
101+
version in `pyproject.toml` and `CITATION.cff`, updates `CHANGELOG.md`,
102+
commits with `[skip ci]`, and pushes the `vX.Y.Z` tag.
103+
3. CI then builds the package, publishes it to PyPI via OIDC trusted publishing,
104+
and deploys the docs to GitHub Pages.
80105

81-
CI will build the package (`uv build`), publish it to PyPI, and deploy the docs to GitHub Pages. The version is derived from the git tag via `hatch-vcs`, so no manual version bumping is needed.
106+
If a merge contains only non-releasing commit types (for example `docs` or
107+
`chore`), no release is cut. The version lives in `pyproject.toml`; never edit it
108+
manually.
82109

83110
## Citation and authorship
84111

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
.PHONY: install
22
install: ## Install the virtual environment and install the pre-commit hooks
33
@echo "🚀 Creating virtual environment using uv"
4-
@uv sync
4+
@uv sync --all-extras
55
@uv run pre-commit install
66

77
.PHONY: check

pyproject.toml

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ license = "Apache-2.0"
77
license-files = ["LICENSE"]
88
keywords = ['python', 'linked-data', 'json-ld', 'pydantic', 'oo-ld']
99
requires-python = ">=3.10,<4.0"
10-
dynamic = ["version"]
10+
# Version is managed by python-semantic-release (see [tool.semantic_release]).
11+
# Do not edit by hand; it is bumped automatically on merge to main.
12+
version = "0.16.2"
1113
classifiers = [
1214
"Development Status :: 3 - Alpha",
1315
"Intended Audience :: Developers",
@@ -88,18 +90,40 @@ dev = [
8890
"ruff>=0.15.7",
8991
"zensical>=0.0.26",
9092
"mkdocstrings-python>=1.0.3",
93+
"python-semantic-release>=10.0.0",
9194
]
9295

9396
[build-system]
94-
requires = ["hatchling", "hatch-vcs"]
97+
requires = ["hatchling"]
9598
build-backend = "hatchling.build"
9699

97-
[tool.hatch.version]
98-
source = "vcs"
99-
100100
[tool.hatch.build.targets.wheel]
101101
packages = ["src/oold"]
102102

103+
[tool.semantic_release]
104+
# python-semantic-release owns the version and changelog. On merge to main it
105+
# reads the conventional commits, bumps the static version in the files below,
106+
# updates the changelog, commits, and tags. The v-prefixed tag namespace is
107+
# preserved (matches the existing 32 releases).
108+
commit_parser = "conventional"
109+
tag_format = "v{version}"
110+
commit_message = "chore(release): v{version} [skip ci]"
111+
allow_zero_version = true
112+
build_command = "uv lock"
113+
version_toml = ["pyproject.toml:project.version"]
114+
version_variables = ["CITATION.cff:version"]
115+
exclude_commit_patterns = ['''chore\(release\):.*''']
116+
# uv.lock pins oold's own version, so re-lock (build_command) and include it in
117+
# the release commit to keep `uv sync --frozen` consistent on the next run.
118+
assets = ["uv.lock"]
119+
120+
[tool.semantic_release.changelog]
121+
mode = "update"
122+
insertion_flag = "<!-- version list -->"
123+
124+
[tool.semantic_release.changelog.default_templates]
125+
changelog_file = "CHANGELOG.md"
126+
103127
[tool.ty.environment]
104128
python = "./.venv"
105129
python-version = "3.10"

0 commit comments

Comments
 (0)