diff --git a/.github/scripts/init-merge-candidate.sh b/.github/scripts/init-merge-candidate.sh index 053a080146..58ded6d1b9 100755 --- a/.github/scripts/init-merge-candidate.sh +++ b/.github/scripts/init-merge-candidate.sh @@ -70,8 +70,9 @@ if ! [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ && "$HEAD_SHA" =~ ^[0-9a-f]{40}$ && \ echo "init-merge-candidate: candidate commit identities must be 40-char lowercase hex" >&2 exit 2 fi -if [ "$MERGE_METHOD" != "squash" ] && [ "$MERGE_METHOD" != "rebase" ]; then - echo "init-merge-candidate: merge method must be squash or rebase" >&2 +if [ "$MERGE_METHOD" != "squash" ] && [ "$MERGE_METHOD" != "rebase" ] && + [ "$MERGE_METHOD" != "merge" ]; then + echo "init-merge-candidate: merge method must be squash, rebase, or merge" >&2 exit 2 fi diff --git a/.github/scripts/test-init-merge-candidate.sh b/.github/scripts/test-init-merge-candidate.sh index 73bb224298..dabadb583f 100755 --- a/.github/scripts/test-init-merge-candidate.sh +++ b/.github/scripts/test-init-merge-candidate.sh @@ -100,7 +100,7 @@ CANONICAL="$TMP_ROOT/canonical.toml" printf 'abi_version = 39\n' > "$CANONICAL" TAG=merge-candidate-abi-v39-pr-1-run-2-attempt-1 run_init() { - local store="$1" fail="${2:-}" + local store="$1" fail="${2:-}" merge_method="${3:-squash}" GH_INIT_STORE="$store" GH_INIT_TAG="$TAG" GH_INIT_UPLOAD_LOG="$store/uploads.log" \ GITHUB_API_RETRY_DELAY_SECONDS=0 GITHUB_REPOSITORY=example/repo \ CANDIDATE_INIT_FAIL_AFTER_ASSET="$fail" STATE_LOCK_SCRIPT="$LOCK" \ @@ -112,7 +112,7 @@ run_init() { --head-sha 2222222222222222222222222222222222222222 \ --synthetic-merge-sha 3333333333333333333333333333333333333333 \ --synthetic-tree-sha 4444444444444444444444444444444444444444 \ - --merge-method squash --pr-commit-count 1 --run-id 2 --run-attempt 1 + --merge-method "$merge_method" --pr-commit-count 1 --run-id 2 --run-attempt 1 } for fail_asset in candidate.json base-index.toml index.toml; do @@ -146,4 +146,21 @@ if run_init "$store" >"$store/mismatch.out" 2>"$store/mismatch.err"; then fi grep -q 'different bytes' "$store/mismatch.err" +store="$TMP_ROOT/merge-method" +mkdir -p "$store" +printf '100\n' > "$store/.next-id" +: > "$store/uploads.log" +run_init "$store" "" merge >"$store/merge.out" +jq -e '.merge_method == "merge"' "$store/candidate.json" >/dev/null + +store="$TMP_ROOT/invalid-method" +mkdir -p "$store" +printf '100\n' > "$store/.next-id" +: > "$store/uploads.log" +if run_init "$store" "" octopus >"$store/out" 2>"$store/err"; then + echo "candidate init accepted an unsupported merge method" >&2 + exit 1 +fi +grep -q 'must be squash, rebase, or merge' "$store/err" + echo "merge candidate initialization tests passed" diff --git a/.github/scripts/test-merge-candidate-workflows.sh b/.github/scripts/test-merge-candidate-workflows.sh index 70006b642f..f730e8c04b 100755 --- a/.github/scripts/test-merge-candidate-workflows.sh +++ b/.github/scripts/test-merge-candidate-workflows.sh @@ -110,6 +110,12 @@ fi if grep -Eq -- '--target-tag[[:space:]]+"?binaries-abi-v' "$PREPARE"; then fail "Prepare merge still has a pre-merge canonical index writer" fi +grep -Fq "contains(github.event.pull_request.labels.*.name, 'preserve-head-commit') && 'merge'" \ + "$PREPARE" || fail "Prepare merge does not select merge-commit verification for preserve-head-commit" +grep -Fq 'batched-changes and preserve-head-commit are mutually exclusive' "$PREPARE" || \ + fail "Prepare merge does not reject conflicting history-method labels" +grep -Fq 'expected_parents="$base_sha $head_sha"' "$VERIFY_SCRIPT" || \ + fail "merge-commit activation does not bind the exact prepared base and head parents" index_writer_count=$(grep -c 'bash scripts/index-update.sh' "$PREPARE") candidate_target_count=$(grep -c -- '--target-tag "${{ needs.preflight.outputs.target_tag }}"' "$PREPARE") diff --git a/.github/scripts/test-verify-merge-candidate.sh b/.github/scripts/test-verify-merge-candidate.sh index f1e0768a44..67a318a5d4 100755 --- a/.github/scripts/test-verify-merge-candidate.sh +++ b/.github/scripts/test-verify-merge-candidate.sh @@ -244,4 +244,26 @@ if run_verify >"$TMP_ROOT/method.out" 2>"$TMP_ROOT/method.err"; then fi grep -q 'rebase result contains merge commits' "$TMP_ROOT/method.err" +# A preserve-head merge accepts only the exact prepared base and head as its +# ordered parents. This is the history shape that keeps a published head SHA +# reachable without weakening the tested-tree check above. +jq '.merge_method = "merge"' "$CANDIDATE_JSON" > "$TMP_ROOT/merge-candidate.json" +mv "$TMP_ROOT/merge-candidate.json" "$CANDIDATE_JSON" +jq '.merge_method = "merge"' "$READY_JSON" > "$TMP_ROOT/merge-ready.json" +mv "$TMP_ROOT/merge-ready.json" "$READY_JSON" +run_verify >/dev/null + +WRONG_HEAD_MERGE=$(printf 'wrong merge head\n' | \ + git -C "$REPO" commit-tree "$SYNTHETIC_TREE_SHA" -p "$BASE_SHA" -p "$FIRST_HEAD_SHA") +git -C "$REPO" update-ref refs/remotes/origin/main "$WRONG_HEAD_MERGE" +jq --arg merge "$WRONG_HEAD_MERGE" '.mergeCommit.oid = $merge' "$PR_JSON" \ + > "$TMP_ROOT/wrong-head-merge-pr.json" +mv "$TMP_ROOT/wrong-head-merge-pr.json" "$PR_JSON" +if run_verify >"$TMP_ROOT/merge-parent.out" 2>"$TMP_ROOT/merge-parent.err"; then + echo "expected a merge with the wrong head parent to fail" >&2 + exit 1 +fi +grep -q 'do not match prepared base and head' "$TMP_ROOT/merge-parent.err" +[ "$(cat "$TMP_ROOT/terminal-reason")" = merge-parent-mismatch ] + echo "merge candidate verification tests passed" diff --git a/.github/scripts/verify-merge-candidate.sh b/.github/scripts/verify-merge-candidate.sh index e01eaac809..5f888516c6 100755 --- a/.github/scripts/verify-merge-candidate.sh +++ b/.github/scripts/verify-merge-candidate.sh @@ -85,7 +85,8 @@ if ! jq -e ' (.head_sha | test("^[0-9a-f]{40}$")) and (.synthetic_merge_sha | test("^[0-9a-f]{40}$")) and (.synthetic_tree_sha | test("^[0-9a-f]{40}$")) and - (.merge_method == "squash" or .merge_method == "rebase") and + (.merge_method == "squash" or .merge_method == "rebase" or + .merge_method == "merge") and (.pr_commit_count | type == "number" and . > 0 and floor == .) and (.abi_version | type == "number" and . > 0 and floor == .) and (.candidate_tag | type == "string" and length > 0) and @@ -263,6 +264,17 @@ case "$merge_method" in "rebased merge contains $merged_commit_count commits after the prepared base, expected $pr_commit_count" fi ;; + merge) + merged_parents=$(git show -s --format=%P "$merge_commit_sha") + expected_parents="$base_sha $head_sha" + # WHY: tree equality alone would let a lookalike commit activate package + # bytes while the reviewed/published PR head remained unreachable. Exact + # parent order proves both the prepared base and head entered main. + if [ "$merged_parents" != "$expected_parents" ]; then + terminal_fail merge-parent-mismatch \ + "merge commit parents $merged_parents do not match prepared base and head $expected_parents" + fi + ;; esac echo "verified merge candidate $candidate_tag at merge commit $merge_commit_sha" diff --git a/.github/workflows/prepare-merge.yml b/.github/workflows/prepare-merge.yml index 7b38739601..195f2d678e 100644 --- a/.github/workflows/prepare-merge.yml +++ b/.github/workflows/prepare-merge.yml @@ -11,7 +11,9 @@ name: Prepare merge # tested tree merged does `activate-merge-candidate.yml` copy its # content-addressed archives and replace the canonical ABI index once. PRs # labeled `batched-changes` must be rebase-merged so their granular commits land -# on main; all other PRs must use the default squash merge behavior. +# on main. A `preserve-head-commit` PR must use a merge commit so an exact +# reviewed or published head SHA remains a parent of main. Those labels are +# mutually exclusive; all other PRs use the default squash merge behavior. # # Flow (see docs/plans/2026-05-05-decoupled-package-builds-design.md # §5.5 + docs/plans/2026-04-29-pr-package-builds-design.md §4.2): @@ -36,7 +38,9 @@ on: types: [labeled] env: - PREPARE_MERGE_METHOD: ${{ contains(github.event.pull_request.labels.*.name, 'batched-changes') && 'rebase' || 'squash' }} + BATCHED_CHANGES: ${{ contains(github.event.pull_request.labels.*.name, 'batched-changes') }} + PRESERVE_HEAD_COMMIT: ${{ contains(github.event.pull_request.labels.*.name, 'preserve-head-commit') }} + PREPARE_MERGE_METHOD: ${{ contains(github.event.pull_request.labels.*.name, 'preserve-head-commit') && 'merge' || contains(github.event.pull_request.labels.*.name, 'batched-changes') && 'rebase' || 'squash' }} GIT_FETCH_RETRY_FUNC: | git_fetch_retry() { local attempt=1 @@ -103,6 +107,13 @@ jobs: id: synthesize run: | set -euo pipefail + # WHY: each label selects a different post-merge history proof. + # Silently preferring one would let the operator perform the other + # method and leave an otherwise valid candidate terminally rejected. + if [ "$BATCHED_CHANGES" = true ] && [ "$PRESERVE_HEAD_COMMIT" = true ]; then + echo "::error::batched-changes and preserve-head-commit are mutually exclusive" + exit 1 + fi work="$RUNNER_TEMP/synthesize-merge" bundle_dir="$RUNNER_TEMP/synthetic-pr-merge" diff --git a/docs/binary-releases.md b/docs/binary-releases.md index ba50e37daf..7957be1d9d 100644 --- a/docs/binary-releases.md +++ b/docs/binary-releases.md @@ -240,12 +240,18 @@ live PR head still matches the event head, and no review has an outstanding `CHANGES_REQUESTED` decision. The label's persistent state is not authority; each new head needs a fresh label event or exact-head review. Prepare merge posts `merge-gate=success` and leaves the merge to a maintainer; Actions never -enables auto-merge. PRs labeled -`batched-changes` must be rebase-merged, while other PRs must be squash-merged. +enables auto-merge. PRs labeled `batched-changes` must be rebase-merged. A PR +labeled `preserve-head-commit` must be merged with a merge commit whose ordered +parents are the prepared base and the exact PR head. That bounded mode keeps an +exact reviewed or publication-pinned head SHA reachable from `main`; repository +merge commits may remain disabled outside its merge window. The two +history-method labels are mutually exclusive. Other PRs must be squash-merged. The exact merge method is part of `candidate.json` and a different method fails -closed during activation. This is repository process policy, not tamper-proof -two-person authorization: same-repository writers are trusted to change the -workflow and helper code through the normal review process. +closed during activation. Tree equality is required for every method, so a +lookalike merge commit cannot substitute another head. This is repository +process policy, not tamper-proof two-person authorization: same-repository +writers are trusted to change the workflow and helper code through the normal +review process. The write-authorized merge gate executes candidate lifecycle helpers from the exact prepared base commit, not from the pull request head. The pull request is