diff --git a/.github/labels.yml b/.github/labels.yml index 450d915b..efea1910 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -61,3 +61,16 @@ - name: ci/skip-changelog color: ededed description: No changelog fragment required for this pull request + +# Release Drafter uses exactly one of these labels on each change PR. +- name: "changes/major" + description: "Release with breaking changes" + color: "b60205" + +- name: "changes/minor" + description: "Release with new features" + color: "0e8a16" + +- name: "changes/patch" + description: "Release with fixes or maintenance" + color: "fbca04" diff --git a/.github/version-drafter.yml b/.github/version-drafter.yml new file mode 100644 index 00000000..c73f7cc9 --- /dev/null +++ b/.github/version-drafter.yml @@ -0,0 +1,8 @@ +--- +# Only explicit release-intent labels drive semantic version bumps. +major-labels: + - "changes/major" +minor-labels: + - "changes/minor" +patch-labels: + - "changes/patch" diff --git a/.github/workflows/auto-bump.yml b/.github/workflows/auto-bump.yml new file mode 100644 index 00000000..1c8bb9d5 --- /dev/null +++ b/.github/workflows/auto-bump.yml @@ -0,0 +1,329 @@ +--- +# yamllint disable rule:truthy +name: Auto bump version + +on: + # A human dispatches this workflow when main is ready to ship. + workflow_dispatch: + inputs: + version: + description: >- + Version to release, e.g. 1.4.0. Leave empty to calculate it from the + labels on the pull requests merged since the last release. + required: false + type: string + +concurrency: + group: auto-bump + cancel-in-progress: false + +jobs: + # --------------------------------------------------------------------------- + # Bump: calculate the next version, assemble the changelog, open a release PR + # + # Nothing is pushed to `main` directly and no tag is created here. The + # version bump and the assembled changelog land as a reviewable pull + # request; merging it is the act that authorises the release, and + # release-publish.yml then creates the tag and the GitHub Release. + # + # The version is computed in its own step whose sole output is a version + # string. That is the seam for a later migration onto the shared + # `release-prepare` workflow, which accepts exactly that as + # `bump-strategy: manual` + `version:`. + # --------------------------------------------------------------------------- + bump: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.resolved.outputs.version }} + steps: + # A dispatcher can select any ref, so check the branch before checkout. + - name: Check the branch + env: + RELEASE_BRANCH: main + run: | + set -euo pipefail + if [ "${GITHUB_REF_NAME}" != "${RELEASE_BRANCH}" ]; then + MSG="Releases are prepared from '${RELEASE_BRANCH}', not" + MSG="${MSG} '${GITHUB_REF_NAME}'. Re-run this workflow with" + MSG="${MSG} '${RELEASE_BRANCH}' selected as the branch." + echo "::error::${MSG}" + exit 1 + fi + + - uses: actions/checkout@v7 + with: + token: ${{ secrets.GH_INFRAHUB_BOT_TOKEN }} + fetch-depth: 0 + # The version guard below refuses a version that is already + # tagged, which it can only do if the tags are here. `fetch-depth: + # 0` happens to bring them today, but asking for them is the + # contract, spelled out the way release-publish.yml spells it out. + fetch-tags: true + + - name: Require an explicit version for the first release + if: inputs.version == '' + run: | + set -euo pipefail + if ! git tag -l 'v*' | grep -q .; then + echo "::error::No release tag exists yet. Dispatch with an explicit version for the first release." + exit 1 + fi + + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync --group dev --frozen + + # One version string, from one of two sources. Empty input means the + # labels on the merged pull requests decide it, which is the default and + # the usual case; a value means whoever dispatched the run decided it, + # the way infrahub and infrahub-sdk-python decide every release. Both + # converge here, so everything downstream — and a later move onto the + # shared `release-prepare` workflow, whose `bump-strategy: manual` + # accepts exactly this — is identical either way. + - name: Calculate next version from PR labels + id: next + if: inputs.version == '' + uses: patrickjahns/version-drafter-action@v1.3.1 + env: + GITHUB_TOKEN: ${{ secrets.GH_INFRAHUB_BOT_TOKEN }} + + - name: Resolve the version + id: resolved + env: + INPUT_VERSION: ${{ inputs.version }} + DRAFTED_VERSION: ${{ steps.next.outputs.next-version }} + run: | + set -euo pipefail + + # Read through the environment rather than interpolated into the + # script body: the input is free text typed into the Actions UI. + VERSION="${INPUT_VERSION:-${DRAFTED_VERSION}}" + VERSION="${VERSION#v}" + + # Validate the version with packaging before using it in the tag. + VERSION="${VERSION}" uv run --no-project --with packaging python - <<'PY' + import os + import sys + + from packaging.version import InvalidVersion, Version + + raw = os.environ["VERSION"] + + try: + version = Version(raw) + except InvalidVersion: + problem = "is not a PEP 440 version" + else: + problem = "" + # The tag and the CHANGELOG heading assume + # X.Y.Z, so 1.4 and 1.4.0.1 are refused even though PEP 440 + # accepts both, and so are epochs and local versions, which + # parse but describe something that cannot be published. + if len(version.release) != 3: + problem = "does not have exactly three release segments" + elif version.epoch or version.local: + problem = "carries an epoch or a local version segment" + # One spelling has to reach the tag and CHANGELOG.md alike, + # so the version must be + # typed the way PEP 440 normalises it: 1.4.0-rc1 and 1.04.0 + # both parse, but under a different name than was typed. + elif str(version) != raw: + problem = f"is normalised by PEP 440 to '{version}'" + + if problem: + print( + f"::error::'{raw}' {problem} — refusing to prepare a" + " release from it. Dispatch again with a three-part" + " PEP 440 version, such as 1.4.0, 1.4.0rc1 or" + " 1.4.0.post1." + ) + sys.exit(1) + PY + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Refuse a version that is not a step forward + env: + VERSION: ${{ steps.resolved.outputs.version }} + run: | + set -euo pipefail + CURRENT=$(git tag -l 'v*' --sort=-v:refname | head -n 1) + CURRENT="${CURRENT#v}" + echo "Current: ${CURRENT}" + echo "Next: ${VERSION}" + + # Compare against the most recent released tag. + CURRENT="${CURRENT}" uv run --no-project --with packaging python - <<'PY' + import os + import sys + + from packaging.version import Version + + current_raw = os.environ["CURRENT"] + version = Version(os.environ["VERSION"]) + + # An explicit first release has no current tag to compare against. + # The duplicate-tag guard below still runs in that case. + if not current_raw: + sys.exit(0) + + current = Version(current_raw) + if version == current: + print( + f"::error::{version} is already the current version —" + " there is nothing new to release. Merge the pull requests" + " you want in this release first, or dispatch again with" + " an explicit version." + ) + sys.exit(1) + + if version < current: + print( + f"::error::{version} is older than the current version" + f" {current} — preparing it would publish behind the" + " one already shipped. Dispatch again with a version" + f" above {current}." + ) + sys.exit(1) + PY + + # Refuse reuse of any existing tag, even after a rollback. + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + MSG="v${VERSION} has already been released — a tag for it" + MSG="${MSG} exists. Dispatch again with a version that has not" + MSG="${MSG} been released yet." + echo "::error::${MSG}" + exit 1 + fi + + echo "Release version: ${CURRENT} -> ${VERSION}" + + - name: Assemble the changelog + env: + VERSION: ${{ steps.resolved.outputs.version }} + run: | + set -euo pipefail + + # A release must say what changed. Fail loudly rather than tag a + # version whose notes are empty — the same rule the shared + # changelog-towncrier composite enforces, with no opt-out. + # + # Count only what towncrier will actually render, using the same + # pattern changelog-check.yml applies to every pull request: a + # direct child of changelog/ named ..md, plus the + # optional counter towncrier appends when a name collides. A stray + # README.md is ignored at release time, so counting it here would + # let this guard pass on a release whose notes are empty. + TYPES="security|removed|deprecated|added|changed|fixed|housekeeping" + FRAGMENT="^changelog/[^/]+\.(${TYPES})(\.[0-9]+)?\.md$" + + shopt -s nullglob + KEPT=() + for f in changelog/*.md; do + if ! [[ "$f" =~ $FRAGMENT ]]; then + echo "Ignoring ${f}: towncrier does not read it as a fragment." + continue + fi + # An empty fragment still counts towards the total below, so the + # release would proceed and towncrier would render the file as a + # heading with nothing under it. Name the file and stop: only the + # author knows what it was meant to say. + if ! grep -q '[^[:space:]]' "$f"; then + MSG="News fragment ${f} has no content — it would render as an" + MSG="${MSG} empty entry. Describe the change in it, or delete" + MSG="${MSG} the file." + echo "::error::${MSG}" + exit 1 + fi + KEPT+=("$f") + done + if [ ${#KEPT[@]} -eq 0 ]; then + MSG="No news fragments in changelog/ — refusing to cut" + MSG="${MSG} v${VERSION} with an empty changelog." + MSG="${MSG} Add a fragment (housekeeping is fine) and re-run." + echo "::error::${MSG}" + exit 1 + fi + + uv run towncrier build --version "${VERSION}" --yes + + - name: Open the release pull request + env: + GH_TOKEN: ${{ secrets.GH_INFRAHUB_BOT_TOKEN }} + VERSION: ${{ steps.resolved.outputs.version }} + run: | + set -euo pipefail + export BRANCH="release/v${VERSION}" + + git config user.name "opsmill-bot" + git config user.email "opsmill-bot@users.noreply.github.com" + git switch -c "${BRANCH}" + + # Stage only what the bump and changelog assembly are allowed to + # touch. `git add -A` would sweep up stray files dropped by + # third-party tools during dependency installation. + git add CHANGELOG.md changelog/ + git commit -m "chore(release): v${VERSION}" + + # Dispatching again for the same version refreshes the same + # branch rather than opening a second PR for it. Plain + # --force rather than --force-with-lease: checkout does not fetch + # this branch's remote ref, so a lease has nothing to compare + # against. The branch is bot-owned and regenerated every run. + git push --force origin "${BRANCH}" + + # A run that computes a different version opens a different branch, + # so an earlier release PR can still be open. Merging that one after + # this one would walk the version files back and let release-publish + # tag a version older than the one already released — supersede it + # rather than let the two race. `gh pr list` returns only 30 rows + # unless asked otherwise, which would leave the oldest release pull + # requests open and racing — bound it high enough that the listing + # is the complete set. + STALE=$( + gh pr list --state open --base main --limit 200 \ + --json number,headRefName \ + --jq '.[] + | select(.headRefName | startswith("release/v")) + | select(.headRefName != env.BRANCH) + | .number' + ) + for PR in ${STALE}; do + echo "Closing release PR #${PR}, superseded by ${BRANCH}." + gh pr close "${PR}" \ + --comment "Superseded by the release pull request for v${VERSION}." + done + + # Only an *open* pull request counts as one already prepared: + # `gh pr view ` resolves closed and merged ones too, so a + # release PR closed without merging would otherwise be mistaken for + # the current one and leave the version stranded on the branch with + # nothing to merge. + OPEN_PR=$( + gh pr list --head "${BRANCH}" --state open --json number \ + --jq '.[0].number // empty' + ) + if [ -n "${OPEN_PR}" ]; then + echo "Release PR #${OPEN_PR} for ${BRANCH} already open — updated in place." + exit 0 + fi + + gh pr create \ + --base main \ + --head "${BRANCH}" \ + --title "chore(release): v${VERSION}" \ + --label "ci/skip-changelog" \ + --body "$(cat <- + python scripts/check_release_labels.py + --author-login "${PR_AUTHOR_LOGIN}" + --head-repository "${PR_HEAD_REPOSITORY}" + --labels-json "${PR_LABELS_JSON}" + --head-ref "${PR_HEAD_REF}" + --repository "${REPOSITORY}" + --title="${PR_TITLE}" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 00000000..b5dac0fa --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,193 @@ +--- +# yamllint disable rule:truthy +name: Publish release + +# Fires when a release pull request lands on `main`: creates the tag and +# publishes the GitHub Release whose body is the changelog section humans +# wrote, rather than a generated list of pull-request titles. +# +# Publishing the GitHub Release triggers the existing binary, image and Homebrew workflow. +on: + push: + branches: [main] + release: + types: [published] + +permissions: + contents: read + +jobs: + publish: + name: Tag and publish + if: github.event_name == 'push' + runs-on: ubuntu-latest + concurrency: + # GitHub keeps only one pending run per group. A branch-wide group could + # replace a pending release merge with an unrelated later push. + group: release-publish-${{ github.sha }} + cancel-in-progress: false + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Decide whether this push is a release + id: decide + env: + GH_TOKEN: ${{ secrets.GH_INFRAHUB_BOT_TOKEN }} + run: | + set -euo pipefail + + # Only a merged release PR authorises publication. The repository's + # pyproject version describes helper tooling, not the release. + BRANCH=$( + gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" \ + --jq '[.[] | select(.merged_at != null) + | select(.user.login == "opsmill-bot") + | select(.head.repo.full_name == env.GITHUB_REPOSITORY) + | .head.ref + | select(startswith("release/v"))] | first // empty' + ) + if [ -z "${BRANCH}" ]; then + echo "No merged release/v* pull request; nothing to publish." + echo "publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + VERSION="${BRANCH#release/v}" + if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?(\.post[0-9]+)?(\.dev[0-9]+)?$ ]]; then + echo "::error::Invalid release version in PR branch: ${BRANCH}" + exit 1 + fi + if gh release view "v${VERSION}" >/dev/null 2>&1; then + echo "v${VERSION} is already published." + echo "publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + TAG_COMMIT=$(git rev-list -n 1 "v${VERSION}") + if [ "${TAG_COMMIT}" != "${GITHUB_SHA}" ]; then + echo "::error::v${VERSION} already tags a different commit." + exit 1 + fi + fi + + echo "v${VERSION} authorised by ${BRANCH}." + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - uses: astral-sh/setup-uv@v7 + if: steps.decide.outputs.publish == 'true' + + - name: Classify the release version + id: classify + if: steps.decide.outputs.publish == 'true' + env: + VERSION: ${{ steps.decide.outputs.version }} + run: | + set -euo pipefail + uv run --no-project --with packaging python - <<'PY' + import os + + from packaging.version import Version + + version = Version(os.environ["VERSION"]) + is_prerelease = version.is_prerelease or version.is_devrelease + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"prerelease={str(is_prerelease).lower()}\n") + PY + + - name: Extract the changelog section + if: steps.decide.outputs.publish == 'true' + env: + VERSION: ${{ steps.decide.outputs.version }} + run: | + set -euo pipefail + + # Everything from the first release heading after the towncrier + # marker up to (but excluding) the next one — i.e. the section this + # release just added. + awk ' + // { seen = 1; next } + seen && /^## / { if (started) exit; started = 1 } + started { print } + ' CHANGELOG.md > /tmp/release-body.md + + if [ ! -s /tmp/release-body.md ]; then + echo "::error::Could not extract a changelog section for v${VERSION} from CHANGELOG.md." + exit 1 + fi + + if ! head -n 1 /tmp/release-body.md | grep -Fq "/tree/v${VERSION})"; then + MSG="The newest changelog section does not mention ${VERSION};" + MSG="${MSG} refusing to publish mismatched notes." + echo "::error::${MSG}" + exit 1 + fi + + echo "Release body:" + cat /tmp/release-body.md + + - name: Create the tag and the GitHub Release + if: steps.decide.outputs.publish == 'true' + env: + GH_TOKEN: ${{ secrets.GH_INFRAHUB_BOT_TOKEN }} + VERSION: ${{ steps.decide.outputs.version }} + IS_PRERELEASE: ${{ steps.classify.outputs.prerelease }} + run: | + set -euo pipefail + + if [ "${IS_PRERELEASE}" = "true" ]; then + RELEASE_FLAGS=(--prerelease) + else + RELEASE_FLAGS=() + fi + echo "v${VERSION} prerelease=${IS_PRERELEASE}" + + # The release.published reconciliation job selects Latest after + # publication. This push job may overlap another release run, so a + # pre-create snapshot cannot safely decide which release is newest. + gh release create "v${VERSION}" \ + --target "${GITHUB_SHA}" \ + --title "v${VERSION}" \ + --latest=false \ + "${RELEASE_FLAGS[@]}" \ + --notes-file /tmp/release-body.md + + reconcile-latest: + name: Mark highest stable release as Latest + if: github.event_name == 'release' + runs-on: ubuntu-latest + concurrency: + # Every published release triggers a full reconciliation. If GitHub + # replaces a pending run, its replacement sees all published releases. + group: release-latest + cancel-in-progress: false + permissions: + contents: write + steps: + - name: Reconcile Latest + env: + GH_TOKEN: ${{ secrets.GH_INFRAHUB_BOT_TOKEN }} + run: | + set -euo pipefail + + # List every published release, not just the most recent page: + # a backport may have been published after the highest version. + HIGHEST=$( + gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' \ + | awk '/^v[0-9]+\.[0-9]+\.[0-9]+(\.post[0-9]+)?$/' \ + | sort -V \ + | tail -n 1 + ) + if [ -z "${HIGHEST}" ]; then + echo "No stable release to mark Latest." + exit 0 + fi + + echo "Marking ${HIGHEST} as Latest." + gh release edit "${HIGHEST}" --latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc51f9c1..13d6e4d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,22 @@ jobs: id: version run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + - name: Choose image tags + id: tags + env: + REGISTRY: ${{ secrets.HARBOR_HOST }} + VERSION: ${{ steps.version.outputs.VERSION }} + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + { + echo 'tags<> "$GITHUB_OUTPUT" + - name: Build and push multi-arch image uses: docker/build-push-action@v7 with: @@ -76,12 +92,11 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | VERSION=${{ github.event.release.tag_name }} - tags: | - ${{ secrets.HARBOR_HOST }}/opsmill/infrahub-backup:${{ steps.version.outputs.VERSION }} - ${{ secrets.HARBOR_HOST }}/opsmill/infrahub-backup:latest + tags: ${{ steps.tags.outputs.tags }} homebrew: needs: publish + if: ${{ !github.event.release.prerelease }} name: Bump Homebrew formula runs-on: ubuntu-24.04 steps: diff --git a/AGENTS.md b/AGENTS.md index 017bfece..d1d6c9d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,19 +38,22 @@ All three tools share common internal application logic but expose different com ### Changelog The changelog is assembled by [towncrier](https://towncrier.readthedocs.io/) from news fragments in -`changelog/`, so every change carries its own entry instead of everyone editing `CHANGELOG.md`. -Internal and tooling work goes under `housekeeping`. CI enforces this on pull requests targeting -`main` (the `Changelog / News fragment present` check); a change that genuinely needs no entry -opts out with the `ci/skip-changelog` label. Add a fragment in the same PR as the change: +`changelog/`, so changes included in release notes carry their own entries instead of editing +`CHANGELOG.md`. Internal and tooling work included in release notes goes under `housekeeping`. +The pull-request fragment check is currently muted, in line with the other ecosystem repositories. +Add a fragment in the same PR as the change when it belongs in the release notes: - `uv run towncrier create -c "Fixed the thing" 42.fixed.md` - one fragment per change, named `..md`. Without an issue or PR number, use a descriptive slug prefixed with `+`, e.g. `+retention-dry-run.added.md`. - Types: `security`, `removed`, `deprecated`, `added`, `changed`, `fixed`, `housekeeping`. - `uv run towncrier build --draft --version X.Y.Z` - preview the rendered changelog. -- `uv run towncrier build --version X.Y.Z` - assemble `CHANGELOG.md` at release time (consumes the - fragments). The version is always passed explicitly: releases are versioned from git tags, not - from `pyproject.toml`. +- Run `auto-bump.yml` on `main` to prepare a release pull request. It assembles + `CHANGELOG.md` from the fragments and uses the latest Git tag, not the helper + `pyproject.toml` version, to choose the next release version. +- Review and merge the release pull request to publish. `release-publish.yml` + creates the GitHub Release, which starts the existing binary, image, and + Homebrew publishing workflow. ### Nix Vendor Hash diff --git a/changelog/+reviewable-release-pr.housekeeping.md b/changelog/+reviewable-release-pr.housekeeping.md new file mode 100644 index 00000000..def37d7a --- /dev/null +++ b/changelog/+reviewable-release-pr.housekeeping.md @@ -0,0 +1 @@ +Releases are prepared as reviewable pull requests before a merge publishes binaries, images, and the Homebrew formula. diff --git a/docs/docs/release-notes/index.mdx b/docs/docs/release-notes/index.mdx new file mode 100644 index 00000000..29d157dd --- /dev/null +++ b/docs/docs/release-notes/index.mdx @@ -0,0 +1,6 @@ +--- +title: Release notes +description: Releases and changes to infrahub-backup. +--- + +Release notes are published with each [GitHub release](https://github.com/opsmill/infrahub-backup/releases). diff --git a/docs/sidebars.ts b/docs/sidebars.ts index c98c9034..5a3dab4b 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -34,6 +34,11 @@ const sidebars: SidebarsConfig = { ], }, 'self-update', + { + type: 'category', + label: 'Release notes', + items: ['release-notes/index'], + }, { type: 'category', label: 'Reference', diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..e3d9df4d --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository maintenance scripts.""" diff --git a/scripts/check_release_labels.py b/scripts/check_release_labels.py new file mode 100644 index 00000000..9936f29f --- /dev/null +++ b/scripts/check_release_labels.py @@ -0,0 +1,72 @@ +"""Validate the explicit release bump label selected for a pull request.""" + +# ruff: noqa: INP001 + +from __future__ import annotations + +import argparse +import json +import re +import sys +from typing import cast + +BUMP_LABELS = frozenset({"changes/major", "changes/minor", "changes/patch"}) +RELEASE_PR_PREFIX = "chore(release):" +RELEASE_PR_AUTHOR = "opsmill-bot" +# The `release/v` branch auto-bump.yml opens: a normalised three-part +# PEP 440 version, optionally with a pre-, post- or dev-release segment. +RELEASE_BRANCH = re.compile(r"release/v\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?") + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line parser.""" + parser = argparse.ArgumentParser() + parser.add_argument("--labels-json", required=True) + parser.add_argument("--title", required=True) + parser.add_argument("--head-ref", required=True) + parser.add_argument("--author-login", required=True) + parser.add_argument("--head-repository", required=True) + parser.add_argument("--repository", required=True) + return parser + + +def main() -> int: + """Validate that a normal pull request has exactly one release label.""" + args = build_parser().parse_args() + + # The bot account, head repository and branch identify a generated release PR; the + # title is matched by prefix only, so a maintainer editing it (re-running + # this check on `edited`) does not drop the exemption. + if ( + args.author_login == RELEASE_PR_AUTHOR + and args.head_repository == args.repository + and RELEASE_BRANCH.fullmatch(args.head_ref) + and args.title.startswith(RELEASE_PR_PREFIX) + ): + sys.stdout.write("Skipping label check for generated release pull request.\n") + return 0 + + try: + raw_labels = json.loads(args.labels_json) + except json.JSONDecodeError as exc: + sys.stderr.write(f"Invalid labels JSON: {exc}\n") + return 1 + + if not isinstance(raw_labels, list) or not all(isinstance(label, str) for label in raw_labels): + sys.stderr.write("Labels JSON must be an array of strings.\n") + return 1 + + labels = cast("list[str]", raw_labels) + selected = sorted(BUMP_LABELS.intersection(labels)) + if len(selected) != 1: + choices = ", ".join(sorted(BUMP_LABELS)) + found = ", ".join(selected) if selected else "none" + sys.stderr.write(f"Pull requests must have exactly one release bump label ({choices}); found: {found}.\n") + return 1 + + sys.stdout.write(f"Release bump label: {selected[0]}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_labels.py b/tests/test_release_labels.py new file mode 100644 index 00000000..8be5987f --- /dev/null +++ b/tests/test_release_labels.py @@ -0,0 +1,88 @@ +"""Regression tests for the release label merge gate.""" + +from __future__ import annotations + +import io +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import patch + +from scripts.check_release_labels import main + + +class ReleaseLabelTests(unittest.TestCase): + """Exercise ordinary PRs and the trusted generated-release exemption.""" + + def check_labels( + self, + labels_json: str, + *, + author: str = "contributor", + head_ref: str = "feature/example", + head_repository: str = "opsmill/infrahub-backup", + title: str = "Example change", + ) -> tuple[int, str, str]: + """Run the checker with a representative pull-request payload.""" + args = [ + "check_release_labels.py", + "--labels-json", + labels_json, + "--author-login", + author, + "--head-ref", + head_ref, + "--head-repository", + head_repository, + "--repository", + "opsmill/infrahub-backup", + f"--title={title}", + ] + stdout = io.StringIO() + stderr = io.StringIO() + with patch("sys.argv", args), redirect_stdout(stdout), redirect_stderr(stderr): + result = main() + return result, stdout.getvalue(), stderr.getvalue() + + def test_one_bump_label_passes(self) -> None: + result, output, error = self.check_labels('["changes/patch"]') + self.assertEqual(result, 0) + self.assertIn("changes/patch", output) + self.assertEqual(error, "") + + def test_no_bump_label_fails(self) -> None: + result, _, error = self.check_labels("[]") + self.assertEqual(result, 1) + self.assertIn("found: none", error) + + def test_multiple_bump_labels_fail(self) -> None: + result, _, error = self.check_labels('["changes/patch", "changes/minor"]') + self.assertEqual(result, 1) + self.assertIn("changes/minor, changes/patch", error) + + def test_generated_release_pr_is_exempt(self) -> None: + result, output, error = self.check_labels( + "[]", author="opsmill-bot", head_ref="release/v1.2.3", title="chore(release): v1.2.3" + ) + self.assertEqual(result, 0) + self.assertIn("Skipping label check", output) + self.assertEqual(error, "") + + def test_similar_human_pr_is_not_exempt(self) -> None: + result, _, error = self.check_labels("[]", head_ref="release/v1.2.3", title="chore(release): v1.2.3") + self.assertEqual(result, 1) + self.assertIn("found: none", error) + + def test_forked_release_pr_is_not_exempt(self) -> None: + result, _, error = self.check_labels( + "[]", + author="opsmill-bot", + head_ref="release/v1.2.3", + head_repository="someone/infrahub-backup", + title="chore(release): v1.2.3", + ) + self.assertEqual(result, 1) + self.assertIn("found: none", error) + + +if __name__ == "__main__": + unittest.main()