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
5 changes: 3 additions & 2 deletions .github/scripts/init-merge-candidate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 19 additions & 2 deletions .github/scripts/test-init-merge-candidate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand All @@ -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
Expand Down Expand Up @@ -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"
6 changes: 6 additions & 0 deletions .github/scripts/test-merge-candidate-workflows.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
22 changes: 22 additions & 0 deletions .github/scripts/test-verify-merge-candidate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
14 changes: 13 additions & 1 deletion .github/scripts/verify-merge-candidate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
15 changes: 13 additions & 2 deletions .github/workflows/prepare-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
16 changes: 11 additions & 5 deletions docs/binary-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading