diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80223c7..baa9838 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -387,7 +387,7 @@ jobs: run: exit 1 publish: - if: ${{ (needs.detect-changes.outputs.build_related == 'true' || needs.detect-changes.outputs.xml_related == 'true') && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-changes.outputs.publish_requested == 'true' && needs.integration-tests.result == 'success' }} + if: ${{ (needs.detect-changes.outputs.build_related == 'true' || needs.detect-changes.outputs.xml_related == 'true') && github.ref == 'refs/heads/main' && needs.integration-tests.result == 'success' && ((github.event_name == 'push' && needs.detect-changes.outputs.publish_requested == 'true') || github.event_name == 'workflow_dispatch') }} needs: - detect-changes - validate-template @@ -448,7 +448,6 @@ jobs: - name: Compute image tags id: prep env: - RELEASE_TAG_OVERRIDE: "" DOCKERHUB_ENABLED: ${{ steps.dockerhub.outputs.enabled }} DOCKERHUB_IMAGE_NAME: ${{ steps.dockerhub.outputs.image_name }} run: | @@ -463,19 +462,21 @@ jobs: version_label="${upstream_version}" wrapper_track="main" wrapper_version="" - if [[ -n "${RELEASE_TAG_OVERRIDE}" ]]; then - release_package_tag="${RELEASE_TAG_OVERRIDE}" - 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.*) + + changelog_version="$(python3 scripts/release.py latest-changelog-version 2>/dev/null || true)" + case "${changelog_version}" in + "${upstream_version}"-aio.*) + release_target="$(python3 scripts/release.py find-release-target-commit "${changelog_version}" 2>/dev/null || true)" + if [[ "${release_target}" == "${GITHUB_SHA}" || "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + release_package_tag="${changelog_version}" + else + release_commit_pattern="^chore\\(release\\): $(python3 -c 'import re, sys; print(re.escape(sys.argv[1]))' "${changelog_version}")( \\(#[0-9]+\\))?$" if printf '%s\n' "${commit_message}" | grep -Eq "${release_commit_pattern}"; then release_package_tag="${changelog_version}" fi - ;; - esac - fi + fi + ;; + esac if [[ -n "${release_package_tag}" ]]; then version_label="${release_package_tag}" wrapper_track="${release_package_tag}" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index cfac187..6c2bd9b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -27,7 +27,9 @@ 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}" changelog_version="$(python3 scripts/release.py latest-changelog-version)" if [[ "${changelog_version}" != "${release_version}" ]]; then echo "CHANGELOG top entry ${changelog_version} does not match ${release_version}" >&2 @@ -37,7 +39,7 @@ jobs: - 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 \ @@ -89,7 +91,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 @@ -115,7 +117,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 c494419..75a87eb 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -25,6 +25,8 @@ When Docker Hub credentials are configured, the same tag set is pushed to Docker 1. Trigger **Prepare Release / Mem0-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 / Mem0-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 / Mem0-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 / Mem0-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. + +If the latest release image tag must be repaired after the release merge, manually run **CI / Mem0-AIO** from `main`. The workflow derives the release tag from the top `CHANGELOG.md` entry and still runs the normal integration gate before publishing it. diff --git a/scripts/release.py b/scripts/release.py index a4b0a59..8aeb846 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -155,6 +155,38 @@ 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", "HEAD").splitlines() + if release_commit in first_parent_commits: + return release_commit + + first_parent_path = git_output( + "rev-list", "--first-parent", "--reverse", f"{release_commit}..HEAD" + ).splitlines() + if first_parent_path: + return first_parent_path[0] + + return release_commit + + def main() -> None: parser = argparse.ArgumentParser(description="Release helpers for AIO repos.") subparsers = parser.add_subparsers(dest="command", required=True) @@ -191,6 +223,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() if args.command == "upstream-version": print(read_upstream_version(args.dockerfile, args.upstream_config)) @@ -210,6 +244,8 @@ def main() -> None: print(latest_changelog_version(args.changelog)) elif args.command == "find-release-commit": print(find_release_commit(args.version)) + elif args.command == "find-release-target-commit": + print(find_release_target_commit(args.version)) else: print(extract_release_notes(args.version, args.changelog)) diff --git a/tests/unit/test_release_helpers.py b/tests/unit/test_release_helpers.py new file mode 100644 index 0000000..9fd5481 --- /dev/null +++ b/tests/unit/test_release_helpers.py @@ -0,0 +1,63 @@ +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): v2.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("v2.0.0-aio.3") == "release-sha" + ) # nosec B101 + + +def test_find_release_target_commit_returns_merge_commit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_git_output(*args: str) -> str: + if args == ("log", "--format=%H\t%s", "HEAD"): + return "\n".join( + [ + "merge-sha\tMerge pull request #43 from JSONbored/release/v2.0.0-aio.3", + "release-sha\tchore(release): v2.0.0-aio.3", + ] + ) + if args == ("rev-parse", "HEAD"): + return "merge-sha\n" + if args == ("rev-list", "--first-parent", "HEAD"): + return "merge-sha\nprevious-main-sha\n" + if args == ( + "rev-list", + "--first-parent", + "--reverse", + "release-sha..HEAD", + ): + return "merge-sha\n" + raise AssertionError(f"unexpected git_output args: {args}") + + def fake_git_completed(*args: str) -> CompletedProcess[str]: + if args == ("merge-base", "--is-ancestor", "release-sha", "merge-sha"): + return CompletedProcess(args=args, returncode=0) + 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("v2.0.0-aio.3") == "merge-sha" + ) # nosec B101