diff --git a/changelog/18.added.md b/changelog/18.added.md new file mode 100644 index 0000000..4d5f73b --- /dev/null +++ b/changelog/18.added.md @@ -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. diff --git a/data/versions.yaml b/data/versions.yaml index 14dfc16..cad6a6d 100644 --- a/data/versions.yaml +++ b/data/versions.yaml @@ -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' diff --git a/project/tools/version.py b/project/tools/version.py new file mode 100644 index 0000000..040c8d6 --- /dev/null +++ b/project/tools/version.py @@ -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[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()) diff --git a/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/ci.yml.j2 b/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/ci.yml.j2 index 37bf693..c5a3bb1 100644 --- a/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/ci.yml.j2 +++ b/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/ci.yml.j2 @@ -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 diff --git a/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/pr.yml.j2 b/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/pr.yml.j2 index d91c417..f1c8ea9 100644 --- a/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/pr.yml.j2 +++ b/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/pr.yml.j2 @@ -22,5 +22,5 @@ jobs: contents: write id-token: write pages: write - pull-requests: read + pull-requests: write {%- endraw %} diff --git a/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/prepare-release-action.yml.j2 b/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/prepare-release-action.yml.j2 new file mode 100644 index 0000000..e9781d4 --- /dev/null +++ b/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/prepare-release-action.yml.j2 @@ -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 %} diff --git a/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/tag.yml.j2 b/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/tag.yml.j2 index 102798d..78be93c 100644 --- a/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/tag.yml.j2 +++ b/project/{% if 'github.com' in source_url %}.github{% endif %}/workflows/tag.yml.j2 @@ -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 %}