diff --git a/.github/workflows/python_changelog_check.yaml b/.github/workflows/python_changelog_check.yaml new file mode 100644 index 0000000000..aa53185ea8 --- /dev/null +++ b/.github/workflows/python_changelog_check.yaml @@ -0,0 +1,44 @@ +name: python-changelog-check + +# Every pull request that changes python/ must add a changelog fragment in python/changelog.d. +# An empty .chore.md fragment marks a change with no user-visible effect. +# See python/changelog.d/README.md. + +on: + pull_request: + paths: + - 'python/**' + +permissions: + contents: read + +jobs: + fragment: + name: Changelog fragment + runs-on: ubuntu-latest + steps: + # HEAD is the merge of the PR into the base branch, and HEAD^1 is the base branch tip. + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Check for a fragment + run: uvx towncrier@26.9.0 check --dir python --config python/pyproject.toml --compare-with HEAD^1 + + # A release PR adds a version heading to the changelog. See python/changelog.d/README.md. + - name: Check that a release PR includes every fragment + run: | + if git diff HEAD^1 HEAD -- python/CHANGELOG.md | grep -q '^+## \[v'; then + if ! python3 python/scripts/release_version.py --leftover; then + echo "Error: the fragments above merged after this release PR was opened." + echo "Run Python Release Prep again to update this PR." + exit 1 + fi + fi + + - name: Check that the fragments render + run: uvx towncrier@26.9.0 build --dir python --config python/pyproject.toml --draft --version 0.0.0 --date "Draft" > /dev/null diff --git a/.github/workflows/python_release.yaml b/.github/workflows/python_release.yaml index 6fd73a864e..99f6abe85c 100644 --- a/.github/workflows/python_release.yaml +++ b/.github/workflows/python_release.yaml @@ -4,14 +4,49 @@ on: workflow_dispatch: jobs: + # A stable release tag must match the version in pyproject.toml and leave no unreleased + # changelog fragments. See python/changelog.d/README.md. Pre-release tags skip these checks. + preflight: + if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Check release tag + run: | + tag_version="${GITHUB_REF_NAME#python/v}" + if [[ ! "$tag_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Pre-release tag $GITHUB_REF_NAME. Skipping checks." + exit 0 + fi + version=$(python3 python/scripts/release_version.py --current) + if [[ "$version" != "$tag_version" ]]; then + echo "Error: tag $GITHUB_REF_NAME does not match version $version in pyproject.toml" + exit 1 + fi + if ! python3 python/scripts/release_version.py --leftover; then + echo "Error: the fragments above are not in the changelog for $GITHUB_REF_NAME" + exit 1 + fi + python-ci: if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags') + needs: preflight + permissions: + contents: read + pull-requests: read uses: ./.github/workflows/python_ci.yaml secrets: inherit build-offline-archives: if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags') needs: python-ci + # python_build.yaml nests python_ci.yaml, which needs pull-requests: read even when skipped. + permissions: + contents: read + pull-requests: read uses: ./.github/workflows/python_build.yaml secrets: inherit @@ -69,6 +104,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_NAME: ${{ github.ref_name }} + FULL_VERSION: ${{ steps.version.outputs.full_version }} IS_PRERELEASE: ${{ steps.version.outputs.is_prerelease }} run: | if [[ "$IS_PRERELEASE" == "true" ]]; then @@ -79,9 +115,16 @@ jobs: RELEASE_FLAGS=(--latest) fi - # Create release notes - cat > release_notes.md << 'EOL' - See [CHANGELOG.md](https://github.com/sift-stack/sift/blob/main/python/CHANGELOG.md) for details. + # Create release notes. Start with this version's section of the changelog, if any. + awk -v header="## [${FULL_VERSION}]" ' + index($0, header) == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' python/CHANGELOG.md > release_notes.md + + cat >> release_notes.md << 'EOL' + + See the [changelog](https://sift-stack.github.io/sift/python/latest/changelog/) for all releases. Offline archives are available for download for multiple platforms. Offline archives include wheels for all dependencies including build extras, e.g. openssl, development, and build. diff --git a/.github/workflows/python_release_prep.yaml b/.github/workflows/python_release_prep.yaml new file mode 100644 index 0000000000..c49d78ba75 --- /dev/null +++ b/.github/workflows/python_release_prep.yaml @@ -0,0 +1,72 @@ +name: Python Release Prep + +# Sets the next version in python/pyproject.toml, renders the changelog fragments in +# python/changelog.d into python/CHANGELOG.md and opens a pull request. Merging that pull request +# starts the Python Release Tag workflow. + +on: + workflow_dispatch: + inputs: + version: + description: Optional explicit X.Y.Z. Skips the automatic bump from the fragment types. + type: string + required: false + +permissions: + contents: write + pull-requests: write + +jobs: + prep: + name: Open release prep PR + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Compute next version + id: version + env: + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + version=$(python3 python/scripts/release_version.py --version "$VERSION_OVERRIDE") + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Next version: $version" + + - name: Set version and render changelog + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cd python + uv version --no-sync "$VERSION" + uvx towncrier@26.9.0 build --yes --version "$VERSION" --date "$(date '+%B %-d, %Y')" + + - name: Open pull request + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + BASE_BRANCH: ${{ github.event.repository.default_branch }} + run: | + branch="release/python-v${VERSION}" + title="Python(chore): v${VERSION} release prep" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$branch" + git add -A python/pyproject.toml python/uv.lock python/CHANGELOG.md python/changelog.d + git commit -m "$title" + # A rerun for the same version replaces the branch and updates its open pull request. + git push --force origin "$branch" + if [[ "$(gh pr view "$branch" --json state -q .state 2>/dev/null)" == "OPEN" ]]; then + echo "Updated the open pull request for ${branch}." + exit 0 + fi + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$branch" \ + --title "$title" \ + --body "Sets the version to ${VERSION} and renders its changelog fragments. Review the version and entries before merging. Merging this pull request tags \`python/v${VERSION}\` and publishes it. See \`python/changelog.d/README.md\`." diff --git a/.github/workflows/python_release_tag.yaml b/.github/workflows/python_release_tag.yaml new file mode 100644 index 0000000000..0ac8119aa8 --- /dev/null +++ b/.github/workflows/python_release_tag.yaml @@ -0,0 +1,54 @@ +name: Python Release Tag + +# Tags a Python release when python/pyproject.toml on the default branch has a version with no tag +# yet, then starts the PyPI Publish workflow on that tag. The Python Release Prep workflow opens the +# pull request that sets the version. + +on: + push: + branches: + - main + paths: + - 'python/pyproject.toml' + - 'python/CHANGELOG.md' + +permissions: + contents: write + actions: write + +concurrency: + group: python-release-tag + cancel-in-progress: false + +jobs: + tag: + name: Create tag and start publish + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # A tag pushed with the default token does not start other workflows, so this starts the + # publish workflow explicitly. It requires a tag ref. + - name: Create tag and start publish + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="python/v$(python3 python/scripts/release_version.py --current)" + if [[ -n "$(git ls-remote --tags origin "refs/tags/${tag}")" ]]; then + # A rerun after a failed publish request starts publish for the existing tag. + if [[ -n "$(gh run list --workflow python_release.yaml --branch "$tag" --limit 1 --json databaseId -q '.[].databaseId')" ]]; then + echo "Tag ${tag} already exists and its publish has started. Nothing to release." + exit 0 + fi + echo "Tag ${tag} already exists but was never published. Starting publish." + else + # See python/changelog.d/README.md. + if ! python3 python/scripts/release_version.py --leftover; then + echo "Error: the fragments above are not in the changelog for ${tag}. Not tagging." + exit 1 + fi + git tag "$tag" "$GITHUB_SHA" + git push origin "refs/tags/${tag}" + fi + gh workflow run python_release.yaml --ref "$tag" diff --git a/.gitignore b/.gitignore index b1b5f3756a..1fd06e7fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ python/docs/sift_py ### C++ template # Prerequisites *.d +!changelog.d # Compiled Object files *.slo diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 09f0529e38..14f810a193 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). + + ## [v0.23.1] - October 8, 2026 - Test-results steps updated out of order now have their statuses corrected before the report is finalized, with a `SiftReportLifecycleWarning`. ([#821](https://github.com/sift-stack/sift/pull/821)) diff --git a/python/changelog.d/+release-automation.chore.md b/python/changelog.d/+release-automation.chore.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/changelog.d/README.md b/python/changelog.d/README.md new file mode 100644 index 0000000000..28cc9311ba --- /dev/null +++ b/python/changelog.d/README.md @@ -0,0 +1,51 @@ +# Changelog fragments + +Each pull request that changes `python/` adds one fragment file to this directory. A release +renders the fragments into `CHANGELOG.md` and deletes them. Do not edit `CHANGELOG.md` by hand. + +## Add a fragment + +Name the file `..md`. From `python/`, run: + +```bash +uvx towncrier create 830.feat.md --content "Added \`client.webhooks\`." +``` + +| Type | Use for | Version bump | +| ---------- | -------------------------------- | ------------ | +| `breaking` | A change that breaks callers | major | +| `feat` | New user-facing behavior | minor | +| `fix` | A bug fix users can see | patch | +| `chore` | No user-visible change | none | + +The version bump follows the highest type in the release. While the major version is 0, a +`breaking` change bumps the minor version. To release a different version, pass `version` to the +release workflow. + +Write the entry for a user of the library. Start with what changed, in one sentence. The +renderer adds the PR link. A fragment can have more paragraphs and code blocks after the first +sentence. They are indented under the bullet. + +## PRs with no user-visible change + +Add an empty file named `.chore.md`. It is never rendered. A release made only of +`chore` fragments is rejected. + +## No PR number yet + +Name the file `+..md`. The entry has no PR link. + +## Release + +Run the Python Release Prep workflow. It computes the next version from the current version in +`pyproject.toml` and the fragment types, sets it in `pyproject.toml` and `uv.lock`, renders +`CHANGELOG.md`, and opens a pull request. After that PR merges, the Python Release Tag workflow +creates the `python/vX.Y.Z` tag and starts the publish workflow. + +If another change to `python/` merges while the release pull request is open, that change would +ship without its entry. The changelog check fails the release pull request in that case. Run +Python Release Prep again. For the same version, it replaces the release branch and updates the +open pull request. If the new changes raise the version, it opens a new pull request, so close the +old one. If the release pull request merges anyway, Python Release Tag +refuses to tag. Move the leftover entries into the new version's section of `CHANGELOG.md`, delete +their fragments, and merge that change to release. diff --git a/python/changelog.d/template.md.jinja b/python/changelog.d/template.md.jinja new file mode 100644 index 0000000000..0286408769 --- /dev/null +++ b/python/changelog.d/template.md.jinja @@ -0,0 +1,14 @@ +{% if render_title %} +## [v{{ versiondata.version }}] - {{ versiondata.date }} + +{% endif %} +{% for category in ["breaking", "feat", "fix"] %} +{% for text, values in sections[""].get(category, {}).items() %} +{% set lines = text.strip().split("\n", 1) %} +- {{ lines[0] }}{% if values %} ({{ values|join(", ") }}){% endif %}{{ "" }} +{% if lines|length > 1 %} +{{ lines[1] }} +{% endif %} +{% endfor %} +{% endfor %} +{{ "\n" }} diff --git a/python/docs/changelog.md b/python/docs/changelog.md new file mode 100644 index 0000000000..786b75d5a3 --- /dev/null +++ b/python/docs/changelog.md @@ -0,0 +1 @@ +--8<-- "CHANGELOG.md" diff --git a/python/mkdocs.yml b/python/mkdocs.yml index 589c7d58da..5ab46e45a7 100644 --- a/python/mkdocs.yml +++ b/python/mkdocs.yml @@ -73,6 +73,7 @@ nav: - Running Modes: guides/pytest_plugin/running_modes.md - Report Structure: guides/pytest_plugin/report_structure.md - Pass/Fail Behavior: guides/pytest_plugin/pass_fail_behavior.md + - Changelog: changelog.md # - Guides: # - Logging # - Error Handling diff --git a/python/pyproject.toml b/python/pyproject.toml index 8b8f011704..58ea9a274b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -414,6 +414,35 @@ ignore_errors = true module = "mcap.writer" follow_imports = "skip" +[tool.towncrier] +# Changelog entries are fragments in ``changelog.d``. See ``changelog.d/README.md``. +directory = "changelog.d" +filename = "CHANGELOG.md" +start_string = "\n" +template = "changelog.d/template.md.jinja" +issue_format = "[#{issue}](https://github.com/sift-stack/sift/pull/{issue})" + +[[tool.towncrier.type]] +directory = "breaking" +name = "Breaking" +showcontent = true + +[[tool.towncrier.type]] +directory = "feat" +name = "Features" +showcontent = true + +[[tool.towncrier.type]] +directory = "fix" +name = "Fixes" +showcontent = true + +# An empty ``chore`` fragment marks a PR with no user-visible change. It is never rendered. +[[tool.towncrier.type]] +directory = "chore" +name = "Chores" +showcontent = false + [tool.setuptools.packages.find] where = ["lib"] exclude = ["sift_client._tests", "sift_client._tests.*"] diff --git a/python/scripts/release_version.py b/python/scripts/release_version.py new file mode 100644 index 0000000000..1ccad5fc2c --- /dev/null +++ b/python/scripts/release_version.py @@ -0,0 +1,78 @@ +"""Print the next Python release version, from the version in `python/pyproject.toml` and the +fragment types in `python/changelog.d`. Run from the repository root with Python 3.11+. Uses only +the standard library.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +import tomllib + +PYPROJECT = tomllib.loads(Path("python/pyproject.toml").read_text()) +TOWNCRIER = PYPROJECT["tool"]["towncrier"] +FRAGMENTS_DIR = Path("python") / TOWNCRIER["directory"] +FRAGMENT_TYPES = {t["directory"] for t in TOWNCRIER["type"]} +NOT_FRAGMENTS = {"README.md", Path(TOWNCRIER["template"]).name} +STABLE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def current_version() -> str: + return PYPROJECT["project"]["version"] + + +def fragments() -> list[Path]: + return sorted(p for p in FRAGMENTS_DIR.iterdir() if p.is_file() and p.name not in NOT_FRAGMENTS) + + +def fragment_type(path: Path) -> str: + # ..md, or ...md when towncrier deduplicates a name. + parts = path.name.removesuffix(".md").split(".") + if len(parts) > 2 and parts[-1].isdigit(): + parts = parts[:-1] + if len(parts) < 2 or parts[-1] not in FRAGMENT_TYPES: + sys.exit(f"Bad fragment name {path.name}. Use ..md with a known type.") + return parts[-1] + + +def next_version(explicit: str) -> str: + types = {fragment_type(path) for path in fragments()} - {"chore"} + if not types: + sys.exit("No changelog fragments to release. Only `chore` fragments or none found.") + if explicit: + if not STABLE.match(explicit): + sys.exit(f"--version {explicit} is not X.Y.Z.") + return explicit + match = STABLE.match(current_version()) + if not match: + sys.exit(f"Current version {current_version()} is not X.Y.Z. Pass --version.") + major, minor, patch = (int(part) for part in match.groups()) + if "breaking" in types and major >= 1: + return f"{major + 1}.0.0" + if types & {"breaking", "feat"}: + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--version", default="", help="Explicit X.Y.Z. Skips the automatic bump.") + mode.add_argument("--current", action="store_true", help="Print the current version.") + mode.add_argument( + "--leftover", + action="store_true", + help="List unreleased fragments and exit 1 if there are any.", + ) + args = parser.parse_args() + if args.current: + print(current_version()) + elif args.leftover: + leftover = fragments() + for path in leftover: + print(path) + sys.exit(1 if leftover else 0) + else: + print(next_version(args.version))