Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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).
Expand Down
14 changes: 11 additions & 3 deletions _project/decisions/single-repo-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`)
Expand Down
13 changes: 10 additions & 3 deletions docs/operations/release-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/test_release_infrastructure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading