Skip to content

Commit

Permalink
Improve release automation
Browse files Browse the repository at this point in the history
This commit introduces several helpers:

* A script that reads the most recent released version from CHANGELOG.md
  and can be instructed to guess the next version based on news
  fragments.
* A new workflow that renders the changelog and submits a PR with this
  changelog.
* A new trigger for the release workflow that runs when pull requests
  are closed and checks if it was a release PR as created by the
  workflow described above.

In conclusion, this patch allows to quickly release new versions instead
of having to perform the above steps manually on the CLI.
  • Loading branch information
lkubb committed Oct 8, 2024
1 parent 526a830 commit 7c9b26c
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 12 deletions.
1 change: 1 addition & 0 deletions changelog/18.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improved release automation: Added workflow that builds the changelog and creates/updates a PR on pushes to the default branch. Added trigger for release workflow when this PR is merged.
6 changes: 6 additions & 0 deletions data/versions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ dorny/paths-filter: 'de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2'
# renovate: datasource=git-tags depName=https://github.com/geekyeggo/delete-artifact depType=action
geekyeggo/delete-artifact: '7ee91e82b4a7f3339cd8b14beace3d826a2aac39 # v5.1.0'

# renovate: datasource=git-tags depName=https://github.com/mathieudutour/github-tag-action depType=action
mathieudutour/github-tag-action: 'd28fa2ccfbd16e871a4bdf35e11b3ad1bd56c0c1 # v6.2'

# renovate: datasource=git-tags depName=https://github.com/peter-evans/create-pull-request depType=action
peter-evans/create-pull-request:

# renovate: datasource=git-tags depName=https://github.com/pypa/gh-action-pypi-publish depType=action
pypa/gh-action-pypi-publish: 'c44d2f0e52f028349e3ecafbf7f32561da677277 # v1.10.3'

Expand Down
92 changes: 92 additions & 0 deletions project/tools/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""
Very simple heuristic to generate the next version number
based on the current changelog news fragments.
This looks for the most recent version by parsing the
CHANGELOG.md file and increments a specific part,
depending on the fragment types present and their contents.
Major bumps are caused by:
* files named `.removed.md`
* files named `.breaking.md`
* files containing `BREAKING:`
Minor bumps are caused by:
* files named `.added.md`
Otherwise, only the patch version is bumped.
"""

import re
import sys
from pathlib import Path

PROJECT_ROOT = Path(__file__).parent.parent.resolve()
CHANGELOG_DIR = PROJECT_ROOT / "changelog"
CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md"


class Version:
def __init__(self, version):
match = re.search(r"v?(?P<release>[0-9]+(?:\.[0-9]+)*)", version)
if not match:
raise ValueError(f"Invalid version: '{version}'")
self.release = tuple(int(i) for i in match.group("release").split("."))

@property
def major(self):
return self._ret(0)

@property
def minor(self):
return self._ret(1)

@property
def patch(self):
return self._ret(2)

def __str__(self):
return ".".join(str(i) for i in self.release)

def _ret(self, cnt):
try:
return self.release[cnt]
except IndexError:
return 0


def last_release():
for line in CHANGELOG_FILE.read_text(encoding="utf-8").splitlines():
if line.startswith("## "):
return Version(line.split(" ")[1])
return Version("0.0.0")


def get_next_version(last):
major = minor = False

for fragment in CHANGELOG_DIR.glob("[!.]*"):
name = fragment.name.lower()
if ".added" in name:
minor = True
elif ".breaking" in name or ".removed" in name:
major = True
break
if "breaking:" in fragment.read_text(encoding="utf-8").lower():
major = True
break
if major:
return Version(f"{last.major + 1}.0.0")
if minor:
return Version(f"{last.major}.{last.minor + 1}.0")
return Version(f"{last.major}.{last.minor}.{last.patch + 1}")


if __name__ == "__main__":
try:
if sys.argv[1] == "next":
print(get_next_version(last_release()))
raise SystemExit(0)
except IndexError:
pass
print(last_release())
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,46 @@ jobs:
- pre-commit
uses: ./.github/workflows/docs-action.yml

check-prepare-release:
name: Check if we can prepare release PR
if: >-
github.event_name == 'push' &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
needs:
- docs
- test
{%- endraw %}
runs-on: ubuntu-{{ versions["ubuntu"] }}
{%- raw %}
outputs:
news-fragments-available: ${{ steps.check-available.outputs.available }}

steps:
{%- endraw %}
- uses: actions/checkout@{{ versions["actions/checkout"] }}
{%- raw %}

- name: Check if news fragments are available
id: check-available
run: |
if [ -n "$(find changelog -type f -not -name '.*' -print -quit)" ]; then
echo "available=1" >> "$GITHUB_OUTPUT"
else
echo "available=0" >> "$GITHUB_OUTPUT"
fi

prepare-release:
name: Prepare Release PR
if: ${{ needs.check-prepare-release.outputs.news-fragments-available == '1' }}
needs:
- check-prepare-release
- docs
- test
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/prepare-release-action.yml

deploy-docs:
name: Deploy Docs
uses: ./.github/workflows/deploy-docs-action.yml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ jobs:
contents: write
id-token: write
pages: write
pull-requests: read
pull-requests: write
{%- endraw %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Prepare Release PR

on:
workflow_call:
workflow_dispatch:
inputs:
version:
description: Override the autogenerated version.
required: false
default: ''
type: string

jobs:
update-release:
name: Render changelog and create/update PR
{%- endraw %}
runs-on: ubuntu-{{ versions["ubuntu"] }}
{%- raw %}
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
permissions:
contents: write
pull-requests: write

steps:
- name: Checkout code
{%- endraw %}
uses: actions/checkout@{{ versions["actions/checkout"] }}
{%- raw %}

- name: Set up Python 3.10
{%- endraw %}
uses: actions/setup-python@{{ versions["actions/setup-python"] }}
{%- raw %}
with:
python-version: '3.10'

- name: Install project
run: |
python -m pip install --upgrade pip
python -m pip install -e '.[dev,docs]'

- name: Get next version
if: github.event_name == 'push' || inputs.version == ''
id: next-version
run: echo "version=$(python tools/version.py next)" >> "$GITHUB_OUTPUT"

- name: Update CHANGELOG.md and push to release PR
env:
NEXT_VERSION: ${{ (github.event_name == 'workflow_dispatch' && inputs.version != '') && inputs.version || steps.next-version.outputs.version }}
run: towncrier build --yes --version "${NEXT_VERSION}"

- name: Create/update release PR
{%- endraw %}
uses: peter-evans/create-pull-request@{{ versions["peter-evans/create-pull-request"] }}
{%- raw %}
with:
commit-message: Release v${{ (github.event_name == 'workflow_dispatch' && inputs.version != '') && inputs.version || steps.next-version.outputs.version }}
branch: release/auto
sign-commits: true
title: Release v${{ (github.event_name == 'workflow_dispatch' && inputs.version != '') && inputs.version || steps.next-version.outputs.version }}
body: |
This automated PR builds the latest changelog. When merged, a new release is published automatically.

If you want to rebuild this PR with a custom version or the current date, you can also trigger the corresponding workflow manually in Actions > Prepare Release > Run workflow.

You can still follow the manual release procedure outlined in: https://salt-extensions.github.io/salt-extension-copier/topics/publishing.html
{%- endraw %}
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,105 @@ on:
push:
tags:
- "v*" # Only tags starting with "v" for "v1.0.0", etc.
pull_request:
types:
- closed

jobs:
get_tag_version:
{%- endraw %}
get_version_tag:
name: Extract version from tag
{%- endraw %}
runs-on: ubuntu-{{ versions["ubuntu"] }}
{%- raw %}
{%- raw %}
if: github.event_name == 'push'
outputs:
version: ${{ steps.get_version.outputs.version }}
version: ${{ steps.get_version_tag.outputs.version }}

steps:
- name: Checkout code
{%- endraw %}
{%- endraw %}
uses: actions/checkout@{{ versions["actions/checkout"] }}
{%- raw %}
{%- raw %}

- name: Extract tag name
id: get_version
id: get_version_tag
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"

call_central_workflow:
needs: get_tag_version
- name: Ensure changelog was rendered
run: test "${{ steps.get_version_tag.outputs.version }}" = "$(python tools/version.py)"

get_version_pr:
name: Extract version from merged release PR
{%- endraw %}
runs-on: ubuntu-{{ versions["ubuntu"] }}
{%- raw %}
permissions:
contents: write # To push the new tag. This does not cause a tag event.

# Only trigger this on closed pull requests if:
# - The originating branch is from the same repository as the one running this workflow.
# - The originating branch is called `release/auto`
# - The PR was merged, not just closed.
# - The PR targeted the default branch of the repository this workflow is running from.
if: >-
github.event_name == 'pull_request' &&
github.repository == github.event.pull_request.head.repo.full_name &&
github.head_ref == 'release/auto' &&
github.event.pull_request.merged == true &&
github.base_ref == github.event.repository.default_branch

outputs:
version: ${{ steps.get_version_pr.outputs.version }}

steps:
- name: Checkout code
{%- endraw %}
uses: actions/checkout@{{ versions["actions/checkout"] }}
{%- raw %}

- name: Extract version of merged release PR
id: get_version_pr
run: echo "version=$(python tools/version.py)" >> "$GITHUB_OUTPUT"

- name: Ensure no news fragments are left
run: test -z "$(find changelog -type f -not -path '*/.*' -print -quit)"

- name: Check extracted version matches PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: >-
[[ "$TITLE" == "Release v${{ steps.get_version_pr.outputs.version }}" ]] || exit 1

- name: Create tag for release
{%- endraw %}
uses: mathieudutour/github-tag-action@{{ versions["mathieudutour/github-tag-action"] }}
{%- raw %}
with:
github_token: ${{ github.token }}
custom_tag: ${{ steps.get_version_pr.outputs.version }}

call_central_workflow:
# Only call the central workflow if either of the above jobs report success.
if: >-
always() &&
(
needs.get_version_tag.result == 'success' ||
needs.get_version_pr.result == 'success'
)
needs:
- get_version_tag
- get_version_pr
uses: ./.github/workflows/ci.yml
{%- endraw %}
with:
deploy-docs: {{ (deploy_docs in ["rolling", "release"]) | lower }}
{%- raw %}
release: true
version: ${{ needs.get_tag_version.outputs.version }}
version: ${{ github.event_name == 'push' && needs.get_version_tag.outputs.version || needs.get_version_pr.outputs.version }}
permissions:
contents: write
id-token: write
pages: write
pull-requests: read
pull-requests: write
secrets: inherit
{%- endraw %}

0 comments on commit 7c9b26c

Please sign in to comment.