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
180 changes: 180 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Opens the release PR: "Release vX.Y.Z", which raises pyproject.toml's version (and uv.lock's record of it) on a
# branch release/vX.Y.Z. Merging it releases the version: release.yml runs on the push to main. RELEASING.md
# describes the process.
#
# Run it from the Actions tab (Prepare release, Run workflow), or through the API, as the train controller in
# liberation-data/drivine-release does:
#
# gh workflow run prepare-release.yml -R liberation-data/drivine-python -f bump=minor
#
# A PR opened with GITHUB_TOKEN starts no workflow, so CI doesn't run on it by itself. With a RELEASE_PR_TOKEN secret
# set (a token that can push and open pull requests here), the PR is opened with that, and CI runs.
name: Prepare release

on:
workflow_dispatch:
inputs:
bump:
description: Which part of the version to raise. A prerelease is X.Y.Z-rc.N.
type: choice
options: [patch, minor, major, prerelease]
default: patch
version:
description: An exact version, X.Y.Z or X.Y.Z-suffix, used instead of bump.
type: string
required: false
default: ""
workflow_call:
inputs:
bump:
description: Which part of the version to raise, patch, minor, major or prerelease.
type: string
required: false
default: patch
version:
description: An exact version, X.Y.Z or X.Y.Z-suffix, used instead of bump.
type: string
required: false
default: ""
# The calling job must grant contents: write and pull-requests: write, which the job below asks for.
secrets:
RELEASE_PR_TOKEN:
required: false

permissions:
contents: read

# Named for this repository: a run called from another repository is in that repository's concurrency groups, where
# the train controller may call all three libraries' prepare-release at once.
concurrency:
group: prepare-release-liberation-data/drivine-python
cancel-in-progress: false

env:
# Named rather than read from the run's context: a workflow_call from another repository runs in that repository's.
REPO: liberation-data/drivine-python
DEFAULT_BRANCH: main

jobs:
prepare-release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Has a token for this repository
env:
HAS_PR_TOKEN: ${{ secrets.RELEASE_PR_TOKEN != '' }}
run: |
if [[ "$GITHUB_REPOSITORY" != "$REPO" && "$HAS_PR_TOKEN" != true ]]; then
echo "::error::Called from $GITHUB_REPOSITORY, whose GITHUB_TOKEN can't push to $REPO. Pass a RELEASE_PR_TOKEN secret that can."
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ env.REPO }}
ref: ${{ env.DEFAULT_BRANCH }}
fetch-depth: 0
token: ${{ secrets.RELEASE_PR_TOKEN || github.token }}
- uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
with:
python-version: "3.14"
enable-cache: false
# The new version: the exact one asked for, or pyproject.toml's raised by semver. It must be greater than the
# newest vX.Y.Z tag, and neither its tag nor its branch may exist yet.
- name: Work out the version
id: version
env:
BUMP: ${{ inputs.bump }}
EXACT: ${{ inputs.version }}
run: |
current=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
if [[ -n "$EXACT" ]]; then
version="${EXACT#v}"
else
version=$(python3 scripts/release_version.py bump "$current" "$BUMP")
fi
tag="v$version"
branch="release/$tag"
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::$version isn't X.Y.Z (or X.Y.Z-suffix), so it can't be released."
exit 1
fi
if [[ "$version" == "$current" ]]; then
echo "::error::pyproject.toml's version is $version already. Pick a later version, or tag the commit by hand if merging it didn't release it."
exit 1
fi
if git rev-parse -q --verify "refs/tags/$tag" > /dev/null; then
echo "::error::$tag is already tagged. Pick a later version."
exit 1
fi
newest=$(git tag -l 'v*' | python3 scripts/release_version.py newest)
if [[ -n "$newest" ]] && ! python3 scripts/release_version.py greater "$version" "${newest#v}"; then
echo "::error::$version isn't later than the last release, $newest, so merging it would release nothing. Pick a later version."
exit 1
fi
if git ls-remote --exit-code --heads origin "refs/heads/$branch" > /dev/null; then
echo "::error::The branch $branch already exists. Merge or close its PR and delete the branch, or pick another version."
exit 1
fi
echo "pyproject.toml's version $current becomes $version, on $branch"
{
echo "version=$version"
echo "tag=$tag"
echo "branch=$branch"
echo "since=$newest"
echo "current=$current"
} >> "$GITHUB_OUTPUT"
# uv lock keeps every locked package's version (it upgrades only with --upgrade), so on a uv.lock that is up to
# date it writes the project's own version, as PEP 440 normalizes it, and changes nothing else. One that isn't
# up to date fails here, rather than slipping other changes into the release PR.
- name: Raise the version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
if ! uv lock --check; then
echo "::error::uv.lock on $DEFAULT_BRANCH isn't up to date with pyproject.toml. Run uv lock and merge that first, so the release PR changes the version alone."
exit 1
fi
python3 scripts/release_version.py set pyproject.toml "$VERSION"
uv lock
git diff --stat
- name: Push the branch and open the PR
env:
GH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || github.token }}
HAS_PR_TOKEN: ${{ secrets.RELEASE_PR_TOKEN != '' }}
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
BRANCH: ${{ steps.version.outputs.branch }}
SINCE: ${{ steps.version.outputs.since }}
CURRENT: ${{ steps.version.outputs.current }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -c "$BRANCH"
git add pyproject.toml uv.lock
git commit -m "Release $TAG"
git push origin "refs/heads/$BRANCH"
body="$RUNNER_TEMP/release-pr.md"
{
echo "Bumps the version from $CURRENT to $VERSION. Merging this PR releases $TAG: release.yml publishes it to PyPI and creates the GitHub release. See RELEASING.md."
echo
if [[ -n "$SINCE" ]]; then
echo "Commits since $SINCE:"
echo
git log --oneline --no-decorate "$SINCE..origin/$DEFAULT_BRANCH" | sed 's/^/- /'
else
echo "There is no earlier vX.Y.Z tag."
fi
if [[ "$HAS_PR_TOKEN" != true ]]; then
echo
echo "This PR was opened with GITHUB_TOKEN, so CI hasn't started on it. Push an empty commit to the branch, or close and reopen the PR, to start it."
fi
} > "$body"
url=$(gh pr create --repo "$REPO" --base "$DEFAULT_BRANCH" --head "$BRANCH" --title "Release $TAG" --body-file "$body")
echo "Opened $url" | tee -a "$GITHUB_STEP_SUMMARY"
if [[ "$HAS_PR_TOKEN" != true ]]; then
note="Opened with GITHUB_TOKEN, so CI doesn't run on it by itself. Push an empty commit to $BRANCH, or close and reopen the PR, to start it."
echo "::notice::$note"
echo "$note" >> "$GITHUB_STEP_SUMMARY"
fi
55 changes: 47 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
# Releases drivine to PyPI from a tag. RELEASING.md describes the process.
# Releases drivine to PyPI. RELEASING.md describes the process.
#
# - A push of a tag vX.Y.Z publishes: the tag is checked, the full CI runs, the distributions are built and checked,
# and those exact files are uploaded to PyPI with trusted publishing.
# - A push to main that raises pyproject.toml's version (the merge of a "Release vX.Y.Z" PR) publishes it, and tags
# the commit vX.Y.Z. A push to main that changes pyproject.toml without raising the version publishes nothing.
# - A push of a tag vX.Y.Z, by hand, publishes too: the fallback.
# - Either way, the full CI runs, the distributions are built and checked, and those exact files are uploaded to PyPI
# with trusted publishing.
# - workflow_dispatch, and a pull request that changes the release machinery, are dry runs: everything but the
# publish, with the built files left as the run's artifact to inspect.
name: Release

on:
push:
branches: [main]
tags: ["v*"]
# A push to main runs this only when it changes the version's file. GitHub doesn't apply paths to a tag push.
paths:
- pyproject.toml
workflow_dispatch:
pull_request:
paths:
- .github/workflows/release.yml
- RELEASING.md
- pyproject.toml
- scripts/smoke_wheel.py
- scripts/release_version.py

permissions:
contents: read

# A push (a release) queues behind another on the same ref. A dry run gets a group of its own: GitHub keeps one pending
# run per group, so a dry run queued on main would otherwise cancel a release waiting there.
concurrency:
group: release-${{ github.ref }}
group: ${{ github.event_name == 'push' && format('release-{0}', github.ref) || format('release-dry-run-{0}', github.run_id) }}
cancel-in-progress: false

jobs:
Expand All @@ -30,6 +40,7 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
publish: ${{ steps.version.outputs.publish }}
tag: ${{ steps.version.outputs.tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -56,19 +67,41 @@ jobs:
exit 1
fi
publish=true
elif [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
# A push to main that changed pyproject.toml. It releases the version when that is a new one, greater than
# the newest vX.Y.Z tag; github-release then makes the tag.
version="$declared"
tag="v$version"
newest=$(git tag -l 'v*' | python3 scripts/release_version.py newest)
publish=false
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::pyproject.toml's version $version isn't X.Y.Z (or X.Y.Z-suffix), so it can't be released."
exit 1
elif git rev-parse -q --verify "refs/tags/$tag" > /dev/null; then
echo "::notice::pyproject.toml's version $version is already tagged $tag, so this push releases nothing. A change to pyproject.toml that isn't a version bump does this."
elif [[ -n "$newest" ]] && ! python3 scripts/release_version.py greater "$version" "${newest#v}"; then
echo "::notice::pyproject.toml's version $version isn't later than the last release, $newest, so this push releases nothing."
else
publish=true
fi
else
version="$declared"
tag="v$version"
publish=false
fi
echo "Version $version, publish: $publish"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "publish=$publish" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"

# The full CI, as ci.yml runs it on a push or a pull request.
# The full CI, as ci.yml runs it on a push or a pull request. A push that releases nothing stops at check: ci.yml
# runs on the same push to main by itself.
gate:
needs: check
if: needs.check.outputs.publish == 'true' || github.event_name != 'push'
uses: ./.github/workflows/ci.yml

# A push that releases nothing skips gate, and so this and the jobs after it.
build:
needs: [check, gate]
runs-on: ubuntu-latest
Expand Down Expand Up @@ -96,7 +129,7 @@ jobs:
status=$(curl -sL --retry 3 -o /dev/null -w '%{http_code}' "$url" || true)
if [[ "$status" == 200 ]]; then
if [[ "$PUBLISH" == true ]]; then
echo "::error::PyPI already has drivine $normalized."
echo "::error::PyPI already has drivine $normalized, so this run publishes nothing. If merging its version bump released it, and this is a tag pushed for it by hand afterwards, there is nothing left to do. Otherwise bump the version in a release PR."
exit 1
fi
echo "::warning::PyPI already has drivine $normalized. This dry run goes on, but a tag of it would fail."
Expand Down Expand Up @@ -150,12 +183,18 @@ jobs:
permissions:
contents: write
steps:
# On a push to main, the tag doesn't exist yet: the release makes it, on the commit this run built. A tag made
# with GITHUB_TOKEN starts no workflow, so it doesn't run this again. A tag pushed by hand is already there.
- name: Create the GitHub release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
TAG: ${{ needs.check.outputs.tag }}
VERSION: ${{ needs.check.outputs.version }}
run: |
prerelease=()
if [[ "$VERSION" == *-* ]]; then prerelease=(--prerelease); fi
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --generate-notes --verify-tag "${prerelease[@]}"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --generate-notes --verify-tag "${prerelease[@]}"
else
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --generate-notes "${prerelease[@]}"
fi
Loading
Loading