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
44 changes: 44 additions & 0 deletions .github/workflows/python_changelog_check.yaml
Original file line number Diff line number Diff line change
@@ -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 <PR>.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
49 changes: 46 additions & 3 deletions .github/workflows/python_release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
wei-sift marked this conversation as resolved.
pull-requests: read
uses: ./.github/workflows/python_build.yaml
secrets: inherit

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
72 changes: 72 additions & 0 deletions .github/workflows/python_release_prep.yaml
Original file line number Diff line number Diff line change
@@ -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\`."
54 changes: 54 additions & 0 deletions .github/workflows/python_release_tag.yaml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ python/docs/sift_py
### C++ template
# Prerequisites
*.d
!changelog.d

# Compiled Object files
*.slo
Expand Down
2 changes: 2 additions & 0 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

<!-- towncrier release notes start -->

## [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))
Expand Down
Empty file.
51 changes: 51 additions & 0 deletions python/changelog.d/README.md
Original file line number Diff line number Diff line change
@@ -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 `<PR number>.<type>.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 `<PR number>.chore.md`. It is never rendered. A release made only of
`chore` fragments is rejected.

## No PR number yet

Name the file `+<short-name>.<type>.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.
14 changes: 14 additions & 0 deletions python/changelog.d/template.md.jinja
Original file line number Diff line number Diff line change
@@ -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" }}
1 change: 1 addition & 0 deletions python/docs/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--8<-- "CHANGELOG.md"
1 change: 1 addition & 0 deletions python/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<!-- towncrier release notes start -->\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.*"]
Expand Down
Loading
Loading