From 2eb0cb6435c771efa66ce82c40f78ae412c234b5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:29:41 -0600 Subject: [PATCH 1/2] ci(release): pin package tags to release targets --- .github/workflows/build.yml | 12 ++++- .github/workflows/publish-release.yml | 10 +++-- docs/releases.md | 4 +- scripts/release.py | 34 ++++++++++++++ tests/unit/test_release_helpers.py | 65 +++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_release_helpers.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3340c5b..4ba4e23 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -456,15 +456,23 @@ jobs: wrapper_track="${aio_track}" else changelog_version="$(python3 scripts/release.py latest-changelog-version 2>/dev/null || true)" - release_commit_pattern="^chore\\(release\\): $(python3 -c 'import re, sys; print(re.escape(sys.argv[1]))' "${changelog_version}")( \\(#[0-9]+\\))?$" case "${changelog_version}" in "${upstream_version}"-aio.*) candidate_revision="${changelog_version##*.}" - if [[ "${candidate_revision}" =~ ^[0-9]+$ ]] && printf '%s\n' "${commit_message}" | grep -Eq "${release_commit_pattern}"; then + release_target="$(python3 scripts/release.py find-release-target-commit "${changelog_version}" 2>/dev/null || true)" + if [[ "${candidate_revision}" =~ ^[0-9]+$ && "${release_target}" == "${GITHUB_SHA}" ]]; then aio_track="aio-v${candidate_revision}" release_package_tag="${upstream_version}-${aio_track}" version_label="${release_package_tag}" wrapper_track="${aio_track}" + else + release_commit_pattern="^chore\\(release\\): $(python3 -c 'import re, sys; print(re.escape(sys.argv[1]))' "${changelog_version}")( \\(#[0-9]+\\))?$" + if [[ "${candidate_revision}" =~ ^[0-9]+$ ]] && printf '%s\n' "${commit_message}" | grep -Eq "${release_commit_pattern}"; then + aio_track="aio-v${candidate_revision}" + release_package_tag="${upstream_version}-${aio_track}" + version_label="${release_package_tag}" + wrapper_track="${aio_track}" + fi fi ;; esac diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 0c03a30..7f7ec09 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -27,12 +27,14 @@ jobs: echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}" release_commit="$(python3 scripts/release.py find-release-commit "${release_version}")" echo "release_commit=${release_commit}" >> "${GITHUB_OUTPUT}" - echo "Matched release commit ${release_commit} for ${release_version}" + release_target="$(python3 scripts/release.py find-release-target-commit "${release_version}")" + echo "release_target=${release_target}" >> "${GITHUB_OUTPUT}" + echo "Matched release commit ${release_commit} and target ${release_target} for ${release_version}" - name: Require successful CI for release commit env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_COMMIT: ${{ steps.version.outputs.release_commit }} + RELEASE_COMMIT: ${{ steps.version.outputs.release_target }} WORKFLOW_SELECTOR: build.yml run: | runs_json="$(gh run list \ @@ -84,7 +86,7 @@ jobs: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.version.outputs.release_version }} - RELEASE_COMMIT: ${{ steps.version.outputs.release_commit }} + RELEASE_COMMIT: ${{ steps.version.outputs.release_target }} GITHUB_REPOSITORY: ${{ github.repository }} run: | if git rev-parse "${RELEASE_VERSION}" >/dev/null 2>&1; then @@ -110,7 +112,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.version.outputs.release_version }} RELEASE_NOTES: ${{ steps.notes.outputs.release_notes }} - RELEASE_COMMIT: ${{ steps.version.outputs.release_commit }} + RELEASE_COMMIT: ${{ steps.version.outputs.release_target }} run: | export GITHUB_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN}}" diff --git a/docs/releases.md b/docs/releases.md index 3507d7b..e3ac266 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -25,6 +25,6 @@ Release commits also publish the immutable packaging line tag, for example `v0.6 1. Trigger **Prepare Release / Sure-AIO** from `main`. 2. The workflow computes the next `upstream-aio.N` version, updates `CHANGELOG.md`, syncs the XML `` block, and opens a release PR. 3. Review and merge that PR into `main`. -4. Wait for the `CI / Sure-AIO` run on the release commit to finish green. That same `main` push also publishes the updated package tags automatically. +4. Wait for the `CI / Sure-AIO` run on the release target commit to finish green. That same `main` push also publishes the updated package tags automatically. 5. Trigger **Publish Release / Sure-AIO** from `main`. -6. The workflow verifies CI on the exact release commit, creates the Git tag if needed, and publishes the GitHub Release. +6. The workflow verifies CI on the exact release target commit, creates the Git tag if needed, and publishes the GitHub Release. diff --git a/scripts/release.py b/scripts/release.py index 4f569af..c7b75a3 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -134,6 +134,35 @@ def find_release_commit(version: str) -> str: ) +def git_is_ancestor(ancestor: str, descendant: str) -> bool: + return ( + git_completed("merge-base", "--is-ancestor", ancestor, descendant).returncode + == 0 + ) + + +def find_release_target_commit(version: str) -> str: + release_commit = find_release_commit(version) + head = git_output("rev-parse", "HEAD").strip() + + if release_commit == head: + return release_commit + + if not git_is_ancestor(release_commit, head): + raise SystemExit( + f"Release commit {release_commit} for {version} is not reachable from HEAD." + ) + + first_parent_commits = git_output( + "rev-list", "--first-parent", "--reverse", "HEAD" + ).splitlines() + for candidate in first_parent_commits: + if git_is_ancestor(release_commit, candidate): + return candidate + + return release_commit + + def main() -> None: parser = argparse.ArgumentParser(description="Release helpers for sure-aio.") subparsers = parser.add_subparsers(dest="command", required=True) @@ -166,6 +195,8 @@ def main() -> None: commit_parser = subparsers.add_parser("find-release-commit") commit_parser.add_argument("version") + target_parser = subparsers.add_parser("find-release-target-commit") + target_parser.add_argument("version") args = parser.parse_args() @@ -192,6 +223,9 @@ def main() -> None: if args.command == "find-release-commit": print(find_release_commit(args.version)) return + if args.command == "find-release-target-commit": + print(find_release_target_commit(args.version)) + return raise SystemExit(f"Unknown command: {args.command}") diff --git a/tests/unit/test_release_helpers.py b/tests/unit/test_release_helpers.py new file mode 100644 index 0000000..6bb5170 --- /dev/null +++ b/tests/unit/test_release_helpers.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from subprocess import ( # nosec B404 - tests construct return objects only + CompletedProcess, +) + +import pytest + +from scripts import release + + +def test_find_release_target_commit_returns_squash_release_commit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_git_output(*args: str) -> str: + if args == ("log", "--format=%H\t%s", "HEAD"): + return "release-sha\tchore(release): v1.0.0-aio.3\n" + if args == ("rev-parse", "HEAD"): + return "release-sha\n" + raise AssertionError(f"unexpected git_output args: {args}") + + monkeypatch.setattr(release, "git_output", fake_git_output) + + assert ( + release.find_release_target_commit("v1.0.0-aio.3") == "release-sha" + ) # nosec B101 + + +def test_find_release_target_commit_returns_merge_commit_after_intervening_main_commit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_git_output(*args: str) -> str: + if args == ("log", "--format=%H\t%s", "HEAD"): + return "\n".join( + [ + "later-sha\tfix(release): later workflow fix", + "merge-sha\tMerge pull request #43 from JSONbored/release/v1.0.0-aio.3", + "main-sha\tfix(ci): intervening main change", + "release-sha\tchore(release): v1.0.0-aio.3", + ] + ) + if args == ("rev-parse", "HEAD"): + return "later-sha\n" + if args == ("rev-list", "--first-parent", "--reverse", "HEAD"): + return "main-sha\nmerge-sha\nlater-sha\n" + raise AssertionError(f"unexpected git_output args: {args}") + + def fake_git_completed(*args: str) -> CompletedProcess[str]: + ancestor_pairs = { + ("release-sha", "later-sha"), + ("release-sha", "merge-sha"), + } + if args[:2] == ("merge-base", "--is-ancestor"): + return CompletedProcess( + args=args, + returncode=0 if (args[2], args[3]) in ancestor_pairs else 1, + ) + raise AssertionError(f"unexpected git_completed args: {args}") + + monkeypatch.setattr(release, "git_output", fake_git_output) + monkeypatch.setattr(release, "git_completed", fake_git_completed) + + assert ( + release.find_release_target_commit("v1.0.0-aio.3") == "merge-sha" + ) # nosec B101 From e8864dd8454014e6e1b501c9f790fb240f605050 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:22:40 -0600 Subject: [PATCH 2/2] ci(release): fetch history for release tag lookup --- .github/workflows/build.yml | 2 ++ tests/unit/test_workflow_release_checkout.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/unit/test_workflow_release_checkout.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ba4e23..d2b2f61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -389,6 +389,8 @@ jobs: packages: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 diff --git a/tests/unit/test_workflow_release_checkout.py b/tests/unit/test_workflow_release_checkout.py new file mode 100644 index 0000000..205abc9 --- /dev/null +++ b/tests/unit/test_workflow_release_checkout.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pathlib import Path + + +def test_publish_checkout_fetches_full_history_before_release_target_lookup() -> None: + workflow = Path(".github/workflows/build.yml").read_text() + release_lookup = ( + 'release_target="$(python3 scripts/release.py find-release-target-commit' + ) + release_lookup_index = workflow.index(release_lookup) + checkout_index = workflow.rfind("uses: actions/checkout@", 0, release_lookup_index) + + assert checkout_index != -1 # nosec B101 + + checkout_block = workflow[checkout_index:release_lookup_index] + + assert "fetch-depth: 0" in checkout_block # nosec B101