From b6f21be5487df52043b5d86efe83daf271483d5d Mon Sep 17 00:00:00 2001 From: Joe Harris <57046+joeharris76@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:36:34 -0400 Subject: [PATCH] fix(release): fold the -s ours history-alignment merge into release-cut main and develop have unrelated roots (Phase 4 filter-merge) and diverge permanently: main carries one squash commit per release that develop never sees, and the 2026-04-27 amendment removed A4 step 6 (rebase develop onto main). A vX.Y.Z branch cut cleanly from develop is therefore not mergeable into main -- GitHub cannot compute a merge ref, so the release PR sits at CONFLICTING and *no CI runs at all*: neither validate-base nor release-required-result ever trigger. Every cut before v0.3.1 (#711, #712, #1029, #1043, #1072) worked around this by hand. release-cut now performs the merge itself, between the "Release vX.Y.Z" commit and the push. -s ours records origin/main as a second parent while preserving the curated release tree byte-for-byte; release-finalize's squash-merge then collapses the branch to a single commit, so the merge never reaches main's release-only ledger. Verified: after the merge, merge-base(origin/main, HEAD) == origin/main, so GitHub's three-dot PR diff is exactly the release delta. The recipe guards the merge by asserting the tree hash is unchanged across it. A -s ours merge cannot change the tree by construction, so the guard is really an assertion that the strategy is still ours -- any other strategy drags main-only content back into the curated tree, silently un-curating the release. The guard is also a no-op-safe check: if origin/main is already an ancestor, git reports "Already up to date", the tree is unchanged, and the cut proceeds. --no-verify: git merge fires pre-merge-commit (not pre-commit). That hook is not in default_install_hook_types today, so this is defensive -- but curation has already deleted .pre-commit-config.yaml and the repo:local hook entrypoints by this point, so installing it later would otherwise break every cut here. Ordering is load-bearing: the merge must follow generate_changelog_entry.py --since-ref origin/main, which needs main to still be a non-ancestor, and must precede the push, or the PR is born CONFLICTING again. The new regression test pins all three positions plus the strategy flags. Coordination with #1027 (rename main -> release): this change uses the literal origin/main spelling that the rest of the Makefile already uses, so that PR's textual rename sweep picks these lines up with the existing ones. Expect a mechanical conflict in the release-cut header comment (step renumbering). Co-Authored-By: Claude Opus 4.8 --- Makefile | 33 +++++++++++++- _project/decisions/single-repo-migration.md | 14 ++++-- docs/operations/release-guide.md | 13 ++++-- tests/unit/test_release_infrastructure.py | 48 +++++++++++++++++++++ 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index bfb7a2065..2c81aac8e 100644 --- a/Makefile +++ b/Makefile @@ -1062,8 +1062,9 @@ RELEASE_REQUIRED_CONTEXTS := validate-base release-required-result # 4. $EDITOR opens CHANGELOG.md for hand-curation (skipped if EDITOR unset). # 5. Curate: git rm dev-only/deferred paths (per A3 in single-repo-migration.md). # 6. Commit "Release v$(VERSION)" (bump + changelog + curation in one squash-friendly commit). -# 7. Push, open PR vs main. -# 8. Sweep stale v* branches on origin (option-c lifecycle). +# 7. Merge origin/main with `-s ours` so the PR is mergeable and CI can run. +# 8. Push, open PR vs main. +# 9. Sweep stale v* branches on origin (option-c lifecycle). # Pre-conditions: on develop, clean tree. # Usage: make release-cut VERSION=X.Y.Z release-cut: @@ -1121,6 +1122,34 @@ release-cut: @# every release cut. Nothing is skipped that matters: develop's PRs @# already ran these hooks before this content was curated. git commit --no-verify -m "Release v$(VERSION)" + @# Align release histories. main and develop have unrelated roots (Phase 4 + @# filter-merge) and diverge permanently: main carries one squash commit per + @# release that develop never sees, and A4 step 6 (rebase develop onto main) + @# was removed by the 2026-04-27 amendment. So a branch cut cleanly from + @# develop is NOT mergeable into main — GitHub cannot compute a merge ref, the + @# PR sits at CONFLICTING, and *no CI runs at all*: neither validate-base nor + @# release-required-result ever trigger. Every cut before v0.3.1 (#711, #712, + @# #1029, #1043, #1072) fixed this by hand. + @# + @# `-s ours` records origin/main as a second parent while keeping the curated + @# release tree byte-for-byte; release-finalize's squash-merge then collapses + @# the branch to one commit, so this merge never reaches main's release-only + @# ledger. Run AFTER the changelog step: --since-ref origin/main computes the + @# patch delta against a main that is not yet an ancestor of this branch. + @# + @# --no-verify: merge fires pre-merge-commit (not pre-commit). That hook is not + @# in default_install_hook_types today, so this is defensive — but curation has + @# already deleted .pre-commit-config.yaml and the repo:local hook entrypoints, + @# so adding pre-merge-commit later would otherwise break every cut here. + @echo "==> Aligning release histories: merging origin/main into v$(VERSION) (strategy: ours)" + @RELEASE_TREE=$$(git rev-parse 'HEAD^{tree}') && \ + git merge -s ours --no-verify --allow-unrelated-histories origin/main \ + -m "Merge main into v$(VERSION) (strategy: ours) to align release histories" && \ + MERGED_TREE=$$(git rev-parse 'HEAD^{tree}') && \ + if [ "$$RELEASE_TREE" != "$$MERGED_TREE" ]; then \ + echo "ERROR: alignment merge changed the curated release tree ($$RELEASE_TREE -> $$MERGED_TREE)" >&2; \ + exit 1; \ + fi git push -u origin v$(VERSION) gh pr create --base main --head v$(VERSION) --title "Release v$(VERSION)" --body-file .github/RELEASE_PR_TEMPLATE.md @# Option-c lifecycle: delete any prior release branches on origin (loop sweeps stale entries). diff --git a/_project/decisions/single-repo-migration.md b/_project/decisions/single-repo-migration.md index a89a2b660..e963684d5 100644 --- a/_project/decisions/single-repo-migration.md +++ b/_project/decisions/single-repo-migration.md @@ -272,7 +272,8 @@ Bookkeeping consequences, decided explicitly rather than left implicit: ### Two release-flow gaps this release exposed -1. **`release-cut` does not align release histories.** Because the 2026-04-27 +1. **`release-cut` did not align release histories.** *(Closed — folded into + `release-cut`; see the note below.)* Because the 2026-04-27 amendment removed A4 step 6 (`rebase develop onto main`), `main` and `develop` diverge permanently — `main` carries one squash commit per release that `develop` never sees, and the two have unrelated roots since the Phase 4 @@ -289,8 +290,15 @@ Bookkeeping consequences, decided explicitly rather than left implicit: `-s ours` preserves the curated release tree byte-for-byte and only records `main` as a second parent. `release-finalize`'s squash-merge then collapses the branch into a single commit on `main`, so this merge never pollutes - `main`'s release-only ledger. **This remains a manual step**; folding it into - `release-cut` is the obvious follow-up. + `main`'s release-only ledger. + + **Now automated.** `release-cut` performs this merge itself, between the + `Release vX.Y.Z` commit and the push, and guards it by asserting the tree is + unchanged across the merge (a non-`ours` strategy would drag `main`-only + content back into the curated tree). Pinned by + `tests/unit/test_release_infrastructure.py::TestReleaseInfrastructure::test_release_cut_aligns_release_histories_before_pushing`. + Ordering matters: the merge must follow `generate_changelog_entry.py + --since-ref origin/main`, which needs `main` to still be a non-ancestor. 2. **`validate-base` bootstrap under-restored its helpers.** The trusted-base bootstrap path (taken while `main` lacks `scripts/release_readiness_check.py`) diff --git a/docs/operations/release-guide.md b/docs/operations/release-guide.md index f97cfa4c4..7928489af 100644 --- a/docs/operations/release-guide.md +++ b/docs/operations/release-guide.md @@ -97,9 +97,16 @@ must not be bypassed with an undocumented local change. For v0.3.0, `landing/` and `docs/blog/` stay in the release tree so `/prompts/` and promoted release posts ship; `results-explorer/`, `results-data/`, and explorer/results-data workflows do not. -5. Commits a single `Release vX.Y.Z` commit on `vX.Y.Z`, pushes, and - opens a PR against `main`. -6. Sweeps prior `v*` branches on origin (option-c lifecycle: keep until +5. Commits a single `Release vX.Y.Z` commit on `vX.Y.Z`. +6. Merges `origin/main` into `vX.Y.Z` with `-s ours + --allow-unrelated-histories`. `main` and `develop` have unrelated roots and + diverge permanently, so without this the release PR is unmergeable, GitHub + cannot compute a merge ref, and **no CI runs at all**. `-s ours` keeps the + curated release tree byte-for-byte (the merge is guarded on that) and only + records `main` as a second parent, which `release-finalize`'s squash-merge + then collapses away. +7. Pushes and opens a PR against `main`. +8. Sweeps prior `v*` branches on origin (option-c lifecycle: keep until superseded, then auto-delete on the next `release-cut`). ### What `release-finalize` does diff --git a/tests/unit/test_release_infrastructure.py b/tests/unit/test_release_infrastructure.py index 78c57e13a..ba3b8bbb4 100644 --- a/tests/unit/test_release_infrastructure.py +++ b/tests/unit/test_release_infrastructure.py @@ -604,6 +604,54 @@ def test_release_cut_commit_skips_hooks_after_curation_removes_them(self): f"release commit must skip hooks (curation already deleted the hook config): {commit_match.group(1)}" ) + def test_release_cut_aligns_release_histories_before_pushing(self): + """release-cut must merge `origin/main` with `-s ours` before it pushes. + + `main` and `develop` have unrelated roots (Phase 4 filter-merge) and + diverge permanently — `main` gains one squash commit per release that + `develop` never sees, and A4 step 6 (rebase develop onto main) was + removed by the 2026-04-27 amendment. A `vX.Y.Z` branch cut cleanly from + `develop` is therefore not mergeable into `main`: GitHub cannot compute + a merge ref, the PR sits at CONFLICTING, and *no CI runs at all* — + neither `validate-base` nor `release-required-result` ever trigger. + Every cut before v0.3.1 fixed this by hand (#711/#712/#1029/#1043/#1072). + """ + recipe = _make_target_recipe("release-cut") + # `\t@?git merge`, not `\t.*git merge`: recipe comments are `\t@# ...` lines + # that precede the command, so a loose anchor would match a comment first. + merge_match = re.search(r"^\t@?git merge (.+?)$", recipe, re.MULTILINE) + assert merge_match, "release-cut must merge origin/main to make the release PR mergeable" + + merge_args = merge_match.group(1).split() + # `-s ours` keeps the curated release tree byte-for-byte and only records + # origin/main as a second parent; any other strategy drags main-only + # content back into the release tree, silently un-curating the release. + assert "-s" in merge_args and merge_args[merge_args.index("-s") + 1] == "ours", ( + f"alignment merge must use `-s ours` to preserve the curated tree: {merge_match.group(1)}" + ) + assert "--allow-unrelated-histories" in merge_args, ( + "main and develop have unrelated roots; the merge needs --allow-unrelated-histories" + ) + assert "origin/main" in merge_args, "the alignment merge must target the fetched origin/main" + + # A `-s ours` merge cannot change the tree by construction, so the guard is + # really an assertion that the strategy above is still `ours`. It must stay. + assert "alignment merge changed the curated release tree" in recipe, ( + "expected a post-merge guard asserting the curated release tree is unchanged" + ) + + # Ordering: changelog (--since-ref origin/main) needs main to still be a + # non-ancestor; the merge must land on top of the release commit; and the + # push must carry the merge, or the PR is born CONFLICTING again. + commit_match = re.search(r"^\tgit commit .*Release v\$\(VERSION\)", recipe, re.MULTILINE) + push_match = re.search(r"^\tgit push -u origin v\$\(VERSION\)", recipe, re.MULTILINE) + assert commit_match, "expected the `Release v$(VERSION)` commit line in release-cut" + assert push_match, "expected `git push -u origin v$(VERSION)` in release-cut" + changelog_idx = recipe.index("generate_changelog_entry.py") + assert changelog_idx < commit_match.start() < merge_match.start() < push_match.start(), ( + "alignment merge must run after the `Release v$(VERSION)` commit and before the push" + ) + def test_release_cut_branch_sweep_matches_only_release_branches(self): """The Option-c stale-branch sweep must filter full refs, not `ls-remote 'v*'` alone.