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
102 changes: 97 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
build:
name: Build source distribution and wheels
uses: ./.github/workflows/build.yml
if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, vars.RELEASE_PR_BRANCH || 'create-pull-request') && github.repository == 'darvid/python-hyperscan'
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.repository == 'darvid/python-hyperscan' && startsWith(github.event.pull_request.head.ref, vars.RELEASE_PR_BRANCH || 'create-pull-request/patch'))
permissions:
contents: read
actions: write
Expand Down Expand Up @@ -45,7 +45,36 @@ jobs:

- name: Check if release is needed
id: release_check
env:
PR_MERGED: ${{ github.event.pull_request.merged }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
RELEASE_PR_BRANCH: ${{ vars.RELEASE_PR_BRANCH || 'create-pull-request/patch' }}
REPOSITORY: ${{ github.repository }}
run: |
if [[ "${REPOSITORY}" != "darvid/python-hyperscan" ]]; then
echo "Repository ${REPOSITORY} is not eligible for release automation"
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi

if [[ "${PR_MERGED}" != "true" ]]; then
echo "Pull request not merged, skipping release"
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi

if [[ -n "${RELEASE_PR_BRANCH}" ]]; then
case "${PR_HEAD_REF}" in
"${RELEASE_PR_BRANCH}"*)
;;
*)
echo "Head ref ${PR_HEAD_REF} does not match expected release branch prefix ${RELEASE_PR_BRANCH}"
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
fi

# Check if HEAD already has a release version tag (prevents redundant releases)
if git describe --exact-match --tags HEAD --match "v*" 2>/dev/null; then
EXISTING_TAG=$(git describe --exact-match --tags HEAD --match "v*" 2>/dev/null)
Expand All @@ -69,13 +98,76 @@ jobs:
fi
fi

- name: Install git-cliff
if: steps.release_check.outputs.should_release == 'true'
uses: taiki-e/install-action@v2
with:
tool: git-cliff

- name: Collect release metadata
if: steps.release_check.outputs.should_release == 'true'
id: release_meta
run: |
set -euo pipefail
VERSION=$(python3 - <<'PY'
import pathlib
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
with pathlib.Path("pyproject.toml").open("rb") as fh:
data = tomllib.load(fh)
print(data["project"]["version"])
PY
)
CURRENT_RELEASE=$(git rev-list HEAD --grep '^Release ' --max-count=1 || true)
if [[ -z "${CURRENT_RELEASE}" ]]; then
echo "Unable to locate the current release commit (matching '^Release ')" >&2
exit 1
fi
PREVIOUS_RELEASE=$(git rev-list HEAD --grep '^Release ' --max-count=1 --skip=1 || true)
if [[ -z "${PREVIOUS_RELEASE}" ]]; then
ROOT=$(git rev-list --max-parents=0 HEAD | tail -n 1)
RANGE="${ROOT}..${CURRENT_RELEASE}"
else
RANGE="${PREVIOUS_RELEASE}..${CURRENT_RELEASE}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "range=${RANGE}" >> "$GITHUB_OUTPUT"
echo "Current release commit: ${CURRENT_RELEASE}"
echo "Previous release commit: ${PREVIOUS_RELEASE:-${RANGE%%..*}}"

- name: Generate release notes
if: steps.release_check.outputs.should_release == 'true'
env:
RANGE: ${{ steps.release_meta.outputs.range }}
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
run: |
set -euo pipefail
if [[ -z "${RANGE}" ]]; then
echo "Commit range for release notes is empty" >&2
exit 1
fi
git cliff "${RANGE}" --config cliff.toml --tag "v${RELEASE_VERSION}" --output release-notes.md
if ! grep -q '^- ' release-notes.md; then
: > release-notes.md
echo "No user-facing changes detected; publishing empty release notes."
fi
echo "Release notes preview:"
cat release-notes.md

- name: Publish to GitHub Releases
if: steps.release_check.outputs.should_release == 'true'
uses: python-semantic-release/publish-action@v9.21.1
uses: softprops/action-gh-release@v2
with:
inputs: ./dist
github_token: ${{ secrets.GITHUB_TOKEN }}
tag: latest
tag_name: v${{ steps.release_meta.outputs.version }}
name: v${{ steps.release_meta.outputs.version }}
body_path: release-notes.md
files: |
dist/*
generate_release_notes: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Publish to PyPI
if: steps.release_check.outputs.should_release == 'true'
Expand Down
53 changes: 51 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ jobs:
id: release
uses: python-semantic-release/python-semantic-release@v9.10.1
with:
force: "patch"
github_token: ${{ secrets.GITHUB_TOKEN }}
changelog: "false"
root_options: "-v --noop"
Expand Down Expand Up @@ -131,11 +130,61 @@ jobs:
# Create new branch
git switch -c "${RELEASE_PR_BRANCH}"

- name: Install git-cliff
if: needs.check_release.outputs.is_release_needed == 'true'
uses: taiki-e/install-action@v2
with:
tool: git-cliff

- name: Generate changelog entry
if: needs.check_release.outputs.is_release_needed == 'true'
env:
RELEASE_VERSION: ${{ needs.check_release.outputs.release_version }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_VERSION}" ]]; then
echo "Release version was not detected" >&2
exit 1
fi

last_release_commit="$(git rev-list HEAD --grep '^Release ' --max-count=1 || true)"
if [[ -n "${last_release_commit}" ]]; then
if [[ "${last_release_commit}" == "$(git rev-parse HEAD)" ]]; then
parent_commit="$(git rev-parse "${last_release_commit}"^ 2>/dev/null || true)"
if [[ -n "${parent_commit}" ]]; then
range="${parent_commit}..HEAD"
else
root_commit="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
range="${root_commit}..HEAD"
fi
else
range="${last_release_commit}..HEAD"
fi
else
root_commit="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
range="${root_commit}..HEAD"
fi

git cliff "${range}" --config cliff.toml --tag "v${RELEASE_VERSION}" --output release-notes.md

if ! grep -q '^- ' release-notes.md; then
echo "No user-facing changes detected; release notes will remain empty."
fi

echo "Generated release notes:"
cat release-notes.md

tmp_file="$(mktemp)"
cat release-notes.md CHANGELOG.md > "${tmp_file}"
mv "${tmp_file}" CHANGELOG.md

git add CHANGELOG.md
rm release-notes.md

- name: Semantic release
uses: python-semantic-release/python-semantic-release@v9.10.1
if: needs.check_release.outputs.is_release_needed == 'true'
with:
force: "patch"
changelog: "false"
github_token: ${{ secrets.GITHUB_TOKEN }}
ssh_public_signing_key: ${{ secrets.CI_SSH_PUBLIC_KEY }}
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ cmake/ # CMake configuration

### Release Process
- **Semantic release** with emoji commits
- **git-cliff** generates changelog entries and GitHub release notes
- **GitHub Actions** for build/test/publish
- **Binary wheels** for multiple platforms
- Version sync between pyproject.toml and _version.py
Expand Down Expand Up @@ -135,4 +136,4 @@ We **avoid using HS_FLAG_UTF8** due to well-documented Hyperscan/Vectorscan bugs
- **intel/hyperscan#163**: Severe performance issues with UTF-8 + case-insensitive flags

### Best Practice
Unicode patterns work correctly **without HS_FLAG_UTF8** when PCRE has proper UTF-8 support. The runtime flag triggers stricter validation that often fails on valid patterns, while the underlying PCRE engine handles Unicode correctly when built with UTF-8 support.
Unicode patterns work correctly **without HS_FLAG_UTF8** when PCRE has proper UTF-8 support. The runtime flag triggers stricter validation that often fails on valid patterns, while the underlying PCRE engine handles Unicode correctly when built with UTF-8 support.
56 changes: 56 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
[git]
conventional_commits = true
filter_commits = true
tag_pattern = "^v\\d+\\.\\d+\\.\\d+"
merge_commit = false
skip_commit = [
"^Release",
"^Merge pull request",
"^0\\.\\d+\\.\\d+",
]
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactors" },
{ message = "^build", skip = true },
{ message = "^ci", skip = true },
{ message = "^docs?", group = "Docs" },
{ message = "^test", group = "Tests" },
{ message = "^revert", group = "Reverts" },
{ message = "^chore", skip = true },
{ message = "^style", skip = true },
{ message = ".*", group = "Other", default = true },
]

[changelog]
header = ""
footer = ""
trim = true
body = """
{% if version -%}
## {{ version }} — {{ timestamp | date(format="%Y-%m-%d") }}
{%- else -%}
## Unreleased
{%- endif %}
{%- for group, group_commits in commits | group_by(attribute="group") -%}
{%- if group_commits | length > 0 %}
### {{ group }}
{%- for commit in group_commits -%}
{%- if commit.parsed %}
{%- set scope = commit.parsed.scope | default(value="") -%}
{%- set description = commit.parsed.description | trim -%}
{%- else %}
{%- set scope = "" -%}
{%- set description = commit.message | trim -%}
{%- endif %}
{%- if scope != "" -%}
{%- set scope = scope | replace(from="_", to=" ") | title -%}
{%- endif %}
- {{ description | replace(from="\n", to=" ") | trim }}{% if scope != "" %} ({{ scope }}){% endif %}{% if commit.breaking %} **BREAKING**{% endif %}{% if commit.pull_request %} (#{{ commit.pull_request }}){% endif %}
{%- endfor %}

{%- endif %}
{%- endfor %}
{{ "\n" }}
"""
6 changes: 6 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Release Workflow

- Releases are prepared by `python-semantic-release` for version bumps while `git-cliff` (`cliff.toml`) builds the changelog entry and release notes.
- The automation computes the commit range between the previous `Release x` merge commit and the current head. Local dry runs can mirror this with `git cliff <previous-release>..HEAD --config cliff.toml --tag v<next-version> --output release-notes.md`.
- If the range has no user-visible commits, the workflow publishes a `- No user-facing changes.` note instead of letting GitHub generate filler text.
- During publishing, `softprops/action-gh-release` uploads artifacts with the generated notes so the GitHub release description and PyPI metadata stay in sync.
77 changes: 77 additions & 0 deletions tools/update_release_notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail

if ! command -v git >/dev/null 2>&1; then
echo "git is required" >&2
exit 1
fi

if ! command -v git-cliff >/dev/null 2>&1; then
echo "git-cliff is required (install via cargo or ./install.sh)" >&2
exit 1
fi

if ! command -v gh >/dev/null 2>&1; then
echo "GitHub CLI (gh) is required" >&2
exit 1
fi

REPO="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')"

COUNT="${1:-5}"
if ! [[ "${COUNT}" =~ ^[0-9]+$ ]]; then
echo "Usage: $0 [count]" >&2
exit 1
fi

REQUEST_LIMIT=$((COUNT + 1))
mapfile -t TAGS < <(gh release list --limit "${REQUEST_LIMIT}" --json tagName --jq '.[].tagName' 2>/dev/null || true)

if [[ "${#TAGS[@]}" -eq 0 ]]; then
mapfile -t TAGS < <(git tag --list 'v[0-9]*' --sort=-creatordate | head -n "${REQUEST_LIMIT}")
fi

if [[ "${#TAGS[@]}" -eq 0 ]]; then
echo "No release tags were found" >&2
exit 1
fi

ROOT_COMMIT="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
TEMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "${TEMP_DIR}"
}
trap cleanup EXIT

for ((i = 0; i < COUNT && i < ${#TAGS[@]}; i++)); do
tag="${TAGS[$i]}"
current_commit="$(git rev-list -n 1 "${tag}")"
prev_tag="${TAGS[$((i + 1))]:-}"
range="${ROOT_COMMIT}..${tag}"

if [[ -n "${prev_tag}" ]]; then
range="${prev_tag}..${tag}"
fi

note_file="${TEMP_DIR}/${tag}.md"
git cliff "${range}" --config cliff.toml --tag "${tag}" --output "${note_file}"

if ! grep -q '^- ' "${note_file}"; then
: >"${note_file}"
echo "No user-facing changes detected; leaving ${tag} release notes empty."
fi

if ! gh release view "${tag}" >/dev/null 2>&1; then
echo "Skipping ${tag}: release not found on GitHub"
continue
fi

release_id="$(gh release view "${tag}" --json databaseId --jq '.databaseId')"

echo "Updating ${tag} using ${note_file}"
if [[ -s "${note_file}" ]]; then
jq -n --arg body "$(cat "${note_file}")" '{body: $body}' | gh api --method PATCH "repos/${REPO}/releases/${release_id}" --input - >/dev/null
else
jq -n '{body: ""}' | gh api --method PATCH "repos/${REPO}/releases/${release_id}" --input - >/dev/null
fi
done
Loading