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
65 changes: 65 additions & 0 deletions .ci/cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# git-cliff configuration for commit-level GitHub release notes.
# https://git-cliff.org/docs/configuration

[changelog]
# Match GitHub's generated-notes layout while keeping individual commit subjects.
# Deliberately use commit.message rather than remote.pr_title so one PR can produce several bullets.
# GitHub metadata is still used for authors, PR links, and first-time contributors when available.
body = """
## What's Changed
{% for commit in commits %}
* {{ commit.message | split(pat="\n") | first | trim }}\
{% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%}
{% if commit.remote.pr_number %} in \
[#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \
{%- endif %}
{%- endfor -%}

{%- if github -%}
{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
{% raw %}\n{% endraw -%}
### New Contributors
{%- endif %}\
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %} in \
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endfor -%}
{%- endif -%}

{% if version %}
{% if previous.version %}
**Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}

{%- macro remote_url() -%}
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
{%- endmacro -%}
"""
# Remove whitespace introduced by formatting the Tera template for readability.
trim = true
footer = """
<!-- generated by git-cliff -->
"""

[git]
# These two settings include every commit without requiring Conventional Commit syntax.
# Subjects such as "my.module: fix parsing" are useful release notes but are not conventional commits.
conventional_commits = false
filter_unconventional = false
# Treat a multi-line commit as one change rather than turning each body line into another entry.
split_commits = false
# Remove PR suffixes added to some commit subjects because the template adds richer PR links separately.
commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }]
# Keep commits even though this configuration does not classify them with commit_parsers.
filter_commits = false
# Only version-like v-prefixed tags delimit releases.
tag_pattern = "^v[0-9]+.*"
# Process release tags chronologically rather than by graph topology.
topo_order = false
# Present changes in development order, matching GitHub's usual generated-notes order.
sort_commits = "oldest"
157 changes: 157 additions & 0 deletions .ci/prepare-github-release
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Prepare commit-level release notes and prefill GitHub's release form."""

from __future__ import annotations

import argparse
import re
import webbrowser
from datetime import date
from pathlib import Path
from subprocess import check_call, check_output
from urllib.parse import urlencode

GITHUB_REMOTE = re.compile(r'github\.com(?::|/)(?P<repository>[^/]+/[^/]+?)(?:\.git)?$')
RELEASE_TAG_PATTERN = 'v[0-9]*'


def command_output(*command: str, cwd: Path) -> str:
return check_output(command, cwd=cwd, text=True).strip()


def git_output(*arguments: str, root: Path) -> str:
return command_output('git', *arguments, cwd=root)


def github_repository(*, root: Path) -> str:
remote = git_output('remote', 'get-url', 'origin', root=root)
match = GITHUB_REMOTE.search(remote)
assert match is not None, remote
return match.group('repository')


def default_tag(*, previous_tag: str | None) -> str:
today = f'{date.today():%Y%m%d}'
if previous_tag is None:
return f'v0.1.{today}'

error = f"can't infer the next tag from {previous_tag!r}; rerun with an explicit --tag"
assert previous_tag.startswith('v'), error
components = previous_tag.removeprefix('v').split('.')
assert len(components) == 3, error
major, minor, previous_suffix = components
assert major.isdigit(), error
assert minor.isdigit(), error
assert previous_suffix.isdigit(), error
return f'v{major}.{int(minor) + 1}.{today}'


def fetch_latest_release_tag(*, root: Path) -> str | None:
remote_tags = git_output(
'ls-remote',
'--tags',
'--refs',
'--sort=-version:refname',
'origin',
RELEASE_TAG_PATTERN,
root=root,
).splitlines()
if len(remote_tags) == 0:
return None

_, ref = remote_tags[0].split(maxsplit=1)
tag_prefix = 'refs/tags/'
assert ref.startswith(tag_prefix), ref
check_call(['git', 'fetch', '--no-tags', 'origin', f'{ref}:{ref}'], cwd=root)
return ref.removeprefix(tag_prefix)


def fetch_default_branch(*, root: Path) -> tuple[str, str]:
lines = git_output('ls-remote', '--symref', 'origin', 'HEAD', root=root).splitlines()
assert len(lines) == 2, lines

symbolic_ref_prefix = 'ref: '
branch_line = lines[0]
assert branch_line.startswith(symbolic_ref_prefix), branch_line
branch_ref, head = branch_line.removeprefix(symbolic_ref_prefix).split()
assert head == 'HEAD', head

commit, head = lines[1].split()
assert head == 'HEAD', head

branch_ref_prefix = 'refs/heads/'
assert branch_ref.startswith(branch_ref_prefix), branch_ref
branch = branch_ref.removeprefix(branch_ref_prefix)
remote_ref = f'refs/remotes/origin/{branch}'
check_call(['git', 'fetch', '--no-tags', 'origin', f'+{branch_ref}:{remote_ref}'], cwd=root)
assert git_output('rev-parse', remote_ref, root=root) == commit, remote_ref
return remote_ref, commit


def generate_notes(*, commit_range: str, repository: str, root: Path, tag: str) -> str:
# REVIEW: why is this necessary?
# Answer: It isn't needed for public repositories; use GitHub's unauthenticated API.
return check_output(
[
'nix', 'run', 'nixpkgs#git-cliff',
'--',
'--config', str(root / '.ci' / 'cliff.toml'),
'--unreleased',
'--tag', tag,
'--github-repo', repository,
commit_range,
],
cwd=root,
text=True,
).strip() # fmt: skip


def main() -> None:
parser = argparse.ArgumentParser(
description="Generate commit-level notes and a prefilled GitHub release form without creating a tag or release.",
)
parser.add_argument(
'--tag',
help='release tag; defaults to v0.1.YYYYMMDD initially or the next minor version thereafter',
)
parser.add_argument('--title', help="release title; defaults to '<tag>: rolling release'")
parser.add_argument('--target', help='Git ref for the release; defaults to the remote default branch')
parser.add_argument('--no-open', action='store_false', dest='open_browser', help='only print the release form URL')
args = parser.parse_args()

root = Path(__file__).absolute().parent.parent
previous_tag = fetch_latest_release_tag(root=root)
tag = args.tag if args.tag is not None else default_tag(previous_tag=previous_tag)
assert git_output('tag', '--list', tag, root=root) == '', tag

if args.target is None:
target_ref, target = fetch_default_branch(root=root)
else:
target_ref = args.target
target = git_output('rev-parse', target_ref, root=root)

commit_range = target if previous_tag is None else f'{previous_tag}..{target}'
commit_count = int(git_output('rev-list', '--count', commit_range, root=root))
assert commit_count > 0, commit_range

repository = github_repository(root=root)
title = args.title if args.title is not None else f'{tag}: rolling release'
notes = generate_notes(commit_range=commit_range, repository=repository, root=root, tag=tag)
query = urlencode({'tag': tag, 'target': target, 'title': title, 'body': notes})
release_url = f'https://github.com/{repository}/releases/new?{query}'

print(f'Tag: {tag}')
print(f'Title: {title}')
print(f'Target: {target} ({target_ref})')
print()
print(notes)
print()
print('Release form:')
print(release_url)

if args.open_browser:
assert webbrowser.open(release_url), release_url


if __name__ == '__main__':
main()
3 changes: 2 additions & 1 deletion .ci/run
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ set -eu
cd "$(dirname "$0")"
cd .. # git root

if ! command -v sudo; then
# projects might need sudo to install OS specific stuff below.
if ! command -v sudo >/dev/null; then
# CI or Docker sometimes doesn't have it, so useful to have a dummy
function sudo {
"$@"
Expand Down
50 changes: 38 additions & 12 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
name: CI
on:
push:
branches: '*'
# Deliberate: we want CI on pushed branches without having to open a PR.
branches: '**'
tags: 'v[0-9]+.*' # only trigger on 'release' tags for PyPi
# Ideally I would put this in the pypi job... but github syntax doesn't allow for regexes there :shrug:

# Needed to trigger on others' PRs.
# Note that people who fork it need to go to "Actions" tab on their fork and click "I understand my workflows, go ahead and enable them".
# NOTE: together with the push trigger above, same-repo branches with an open PR get double CI runs.
# Keeping both anyway: push covers branches without a PR, this one is needed for fork PRs,
# and the runs aren't identical -- pull_request checks the merge commit against the base branch, push checks the branch head.
pull_request:

# Needed to trigger workflows manually.
Expand All @@ -24,13 +28,23 @@ on:
- cron: '31 18 * * 5' # run every Friday


permissions:
contents: read # repos created before 2023 have write permissions as default. Protects against compromised actions.


jobs:
build:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest] # windows-latest
python-version: ['3.12', '3.13', '3.14']
platform:
- ubuntu-latest
- macos-latest
# - windows-latest
python-version:
- &minimum_python '3.12'
- '3.13'
- '3.14'
# vvv just an example of excluding stuff from matrix
# exclude: [{platform: macos-latest, python-version: '3.6'}]

Expand All @@ -40,12 +54,14 @@ jobs:
# continue-on-error: ${{ matrix.platform == 'windows-latest' }}

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
submodules: recursive
fetch-depth: 0 # nicer to have all git history when debugging/for tests

- uses: astral-sh/setup-uv@v8.1.0
# setup-uv doesn't publish floating major/minor tags, so use an exact full tag.
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: ${{ matrix.python-version }}
enable-cache: false # we don't have lock files during initial CI checkout, so can't use them as cache key
Expand All @@ -71,8 +87,8 @@ jobs:
runs-on: ubuntu-latest

permissions:
# necessary for Trusted Publishing
id-token: write
contents: read # necessary to repeat since 'permissions:' replace global ones, not augment
id-token: write # necessary for Trusted Publishing
env:
# always deploy merged master to test pypi
# always deploy tags to release pypi
Expand All @@ -83,21 +99,31 @@ jobs:
name: ${{ github.ref_type == 'tag' && 'pypi' || 'testpypi' }}
url: https://${{ github.ref_type == 'tag' && 'pypi.org' || 'test.pypi.org' }}/project/${{ steps.meta.outputs.pypi_name }}/
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
submodules: recursive
fetch-depth: 0 # pull all commits to correctly infer vcs version

- uses: astral-sh/setup-uv@v8.1.0
# setup-uv doesn't publish floating major/minor tags, so use an exact full tag.
- uses: astral-sh/setup-uv@v9.0.0
with:
python-version: '3.12'
python-version: *minimum_python
enable-cache: false # we don't have lock files during initial CI checkout, so can't use them as cache key

- name: 'release ${{ steps.meta.outputs.pypi_name }} to ${{ env.TARGET }}'
- name: 'release to ${{ env.TARGET }}'
run: .ci/release ${{ env.TARGET == 'testpypi' && '--use-test-pypi' || '' }}

# NOTE: name/version are extracted from the built wheel -- build backend is the source of truth for name normalization.
# So this has to run after the release step (which builds the wheel),
# and the release step name above can't include the package name (step names render when the step starts).
- id: meta
name: 'release summary'
shell: bash
run: |
pypi_name=$(unzip -p dist/*.whl '*.dist-info/METADATA' | awk '/^Name:/ {print $2; exit}')
metadata=$(unzip -p dist/*.whl '*.dist-info/METADATA')
pypi_name=$(awk '/^Name:/ {print $2; exit}' <<< "$metadata")
version=$(awk '/^Version:/ {print $2; exit}' <<< "$metadata")
echo "pypi_name=$pypi_name" >> "$GITHUB_OUTPUT"
host=$([ "$TARGET" = 'pypi' ] && echo 'pypi.org' || echo 'test.pypi.org')
echo "released [$pypi_name $version](https://$host/project/$pypi_name/$version/) to $TARGET" >> "$GITHUB_STEP_SUMMARY"
Loading