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
75 changes: 66 additions & 9 deletions .github/scripts/merge-main-into-release.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
#!/usr/bin/env bash
# Usage: merge-main-into-release.sh <main-ref> <release-branch>
# Usage: merge-main-into-release.sh <main-ref> <release-branch> [--commit-conflicts]
# Run from a clean release checkout. The caller owns fetching and pushing.
#
# Exit codes:
# 0 main is merged (or was already an ancestor); HEAD is the result.
# 1 unexpected failure; nothing should be pushed.
# 3 conflicts need human review; the merge was aborted and HEAD is unchanged.
#
# With --commit-conflicts the conflicts are not an error: the merge is committed
# with conflict markers left in the unresolved text files, so the result can be
# pushed to a repair branch. Non-text conflicts (binary, modify/delete) have no
# markers; Git's worktree side is staged and the path is flagged for a human.
# Unresolved paths are written to $RELEASE_SYNC_CONFLICTS_FILE as
# "<kind>\t<path>" (text, binary, deleted-on-main, deleted-on-release) when it
# is set. Never push that commit to release.
set -euo pipefail

MAIN_SHA="$(git rev-parse --verify "${1:?main ref required}^{commit}")"
Expand All @@ -13,6 +26,13 @@ fail() {
exit 1
}

COMMIT_CONFLICTS=0
case "${3:-}" in
"") ;;
--commit-conflicts) COMMIT_CONFLICTS=1 ;;
*) fail "Unknown option: $3" ;;
esac

[[ -z "$(git status --porcelain)" ]] || fail "Release checkout must be clean."
if git rev-parse -q --verify MERGE_HEAD >/dev/null; then
fail "A merge is already in progress."
Expand Down Expand Up @@ -117,16 +137,38 @@ for path in "${conflicts[@]}"; do
fi
done

# Only text conflicts leave markers. For the other kinds Git leaves one side in
# the worktree, so staging it would silently pick that side.
conflict_kind() {
local stages
stages="$(git ls-files -u -- ":(literal)$1" | awk '{print $3}' | sort -u | tr -d '\n')"
if [[ "$stages" != *2* ]]; then echo deleted-on-release; return; fi
if [[ "$stages" != *3* ]]; then echo deleted-on-main; return; fi
if grep -Iq '^<<<<<<< ' -- "$1" 2>/dev/null; then echo text; else echo binary; fi
}

if [[ "${#unresolved[@]}" -ne 0 ]]; then
unresolved_kinds=()
report "" "### Conflicts requiring human review"
for path in "${unresolved[@]}"; do
report "- \`$path\`"
kind="$(conflict_kind "$path")"
unresolved_kinds+=("$kind")
report "- \`$path\` ($kind)"
done
report "" \
"Create a repair branch from \`$BRANCH\`, merge main into it, and resolve these conflicts without discarding release-only work." \
"Land the repair PR with **Create a merge commit**, never squash or rebase. Copying content alone does not repair ancestry." \
"After landing, verify \`git merge-base --is-ancestor $MAIN_SHA origin/$BRANCH\`, then dispatch a fresh workflow run from main."
fail "Automated merge aborted: ${#unresolved[@]} conflicts require human review."
if [[ "$COMMIT_CONFLICTS" -eq 0 ]]; then
report "" \
"Create a repair branch from \`$BRANCH\`, merge main into it, and resolve these conflicts without discarding release-only work." \
"Land the repair PR with **Create a merge commit**, never squash or rebase. Copying content alone does not repair ancestry." \
"After landing, verify \`git merge-base --is-ancestor $MAIN_SHA origin/$BRANCH\`, then dispatch a fresh workflow run from main."
Comment thread
IEvangelist marked this conversation as resolved.
echo "::warning::Automated merge aborted: ${#unresolved[@]} conflicts require human review." >&2
exit 3
fi
if [[ -n "${RELEASE_SYNC_CONFLICTS_FILE:-}" ]]; then
: > "$RELEASE_SYNC_CONFLICTS_FILE"
for i in "${!unresolved[@]}"; do
printf '%s\t%s\n' "${unresolved_kinds[$i]}" "${unresolved[$i]}" >> "$RELEASE_SYNC_CONFLICTS_FILE"
done
fi
fi

# Only these producer-owned paths are mirrored. Other data is hand-authored.
Expand All @@ -143,8 +185,19 @@ for path in "${generated_files[@]}" "${generated_dirs[@]}"; do
fi
done

if [[ "${#unresolved[@]}" -ne 0 ]]; then
# Stage the conflicted files as-is for the repair branch. Text conflicts keep
# their markers; the workflow tells reviewers to decide the other kinds.
git --literal-pathspecs add -A -- "${unresolved[@]}"
Comment thread
IEvangelist marked this conversation as resolved.
fi

[[ -z "$(git ls-files -u)" ]] || fail "Unexpected unmerged entries remain."
if ! markers="$(git diff --cached --check)"; then
# Check everything except the paths intentionally left unresolved.
check_pathspecs=(.)
for path in ${unresolved[@]+"${unresolved[@]}"}; do
check_pathspecs+=(":(exclude,literal)$path")
done
if ! markers="$(git diff --cached --check -- "${check_pathspecs[@]}")"; then
[[ -n "$markers" ]] || fail "Could not check the merged diff."
if grep -q 'conflict marker' <<< "$markers"; then
fail "Conflict markers detected after auto-resolution."
Expand All @@ -159,4 +212,8 @@ read -r commit first_parent second_parent extra <<< "$(git rev-list --parents -n
if [[ "$first_parent" != "$RELEASE_SHA" || "$second_parent" != "$MAIN_SHA" || -n "$extra" ]]; then
fail "Expected a two-parent release/main merge commit; do not push."
fi
report "" "Merged main into release with both parents preserved: \`$commit\`."
if [[ "${#unresolved[@]}" -ne 0 ]]; then
report "" "Committed main merge with ${#unresolved[@]} unresolved conflict(s) for a repair PR: \`$commit\`."
else
report "" "Merged main into release with both parents preserved: \`$commit\`."
fi
88 changes: 85 additions & 3 deletions .github/scripts/test-merge-main-into-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ run_helper() {
}

expect_failure() {
if bash "$HELPER" main release/test > "$TEST_ROOT/output" 2>&1; then
fail "Expected merge failure."
fi
helper_status=0
bash "$HELPER" main release/test > "$TEST_ROOT/output" 2>&1 || helper_status=$?
[[ "$helper_status" -ne 0 ]] || fail "Expected merge failure."
grep -q "$1" "$TEST_ROOT/output" || { cat "$TEST_ROOT/output"; fail "Missing expected diagnostic: $1"; }
[[ "$(git rev-parse HEAD)" == "$release_sha" ]] || fail "Failed merge changed release HEAD."
[[ -z "$(git status --porcelain)" ]] || fail "Failed merge left a dirty checkout."
Expand Down Expand Up @@ -147,11 +147,93 @@ commit_all main
git switch -q release/test
export GITHUB_STEP_SUMMARY="$TEST_ROOT/summary"
expect_failure "conflicts require human review"
[[ "$helper_status" == 3 ]] || fail "Conflicts must exit 3, got $helper_status."
grep -q 'shared.txt' "$GITHUB_STEP_SUMMARY" || fail "Summary omitted conflict path."
grep -q 'never squash or rebase' "$GITHUB_STEP_SUMMARY" || fail "Summary omitted ancestry guidance."
unset GITHUB_STEP_SUMMARY
echo "PASS: unknown conflict aborts with recovery instructions"

# Same fixture: --commit-conflicts keeps both parents and leaves markers for a repair PR.
generated=src/frontend/src/data/samples.json
write_file "$generated" release
commit_all release-generated
git switch -q main
write_file "$generated" main
commit_all main-generated
git switch -q release/test
release_sha="$(git rev-parse HEAD)"
export RELEASE_SYNC_CONFLICTS_FILE="$TEST_ROOT/conflicts"
bash "$HELPER" main release/test --commit-conflicts > "$TEST_ROOT/output" 2>&1 \
|| { cat "$TEST_ROOT/output"; fail "--commit-conflicts failed."; }
unset RELEASE_SYNC_CONFLICTS_FILE
assert_parents
[[ "$(cat "$TEST_ROOT/conflicts")" == $'text\tshared.txt' ]] || fail "Conflict list is wrong."
grep -q '^<<<<<<<' shared.txt || fail "Repair commit lost conflict markers."
[[ -z "$(git status --porcelain)" ]] || fail "Repair commit left a dirty checkout."
git diff --exit-code main HEAD -- "$generated" || fail "Generated data was not mirrored from main."
echo "PASS: --commit-conflicts commits markers with both parents"

new_repo commit-conflicts-clean
write_file release.txt release
commit_all release
release_sha="$(git rev-parse HEAD)"
git switch -q main
write_file main.txt main
commit_all main
git switch -q release/test
rm -f -- "$TEST_ROOT/conflicts"
RELEASE_SYNC_CONFLICTS_FILE="$TEST_ROOT/conflicts" bash "$HELPER" main release/test --commit-conflicts > "$TEST_ROOT/output" 2>&1 \
|| { cat "$TEST_ROOT/output"; fail "--commit-conflicts failed on a clean merge."; }
assert_parents
[[ ! -e "$TEST_ROOT/conflicts" ]] || fail "Clean merge wrote a conflict list."
echo "PASS: --commit-conflicts is a plain merge when nothing conflicts"

new_repo non-text-conflicts
write_file gone-on-main.txt base
write_file gone-on-release.txt base
printf 'base\0bin' > blob.bin
commit_all non-text-base
git switch -q main
git merge -q --ff-only release/test
git rm -q gone-on-main.txt
write_file gone-on-release.txt main-edit
printf 'main\0bin' > blob.bin
commit_all main-non-text
git switch -q release/test
write_file gone-on-main.txt release-edit
git rm -q gone-on-release.txt
printf 'release\0bin' > blob.bin
commit_all release-non-text
release_sha="$(git rev-parse HEAD)"
rm -f -- "$TEST_ROOT/conflicts"
expect_failure "conflicts require human review"
[[ "$helper_status" == 3 ]] || fail "Non-text conflicts must exit 3."
RELEASE_SYNC_CONFLICTS_FILE="$TEST_ROOT/conflicts" bash "$HELPER" main release/test --commit-conflicts > "$TEST_ROOT/output" 2>&1 \
|| { cat "$TEST_ROOT/output"; fail "--commit-conflicts failed on non-text conflicts."; }
assert_parents
sort "$TEST_ROOT/conflicts" > "$TEST_ROOT/conflicts.sorted"
printf 'binary\tblob.bin\ndeleted-on-main\tgone-on-main.txt\ndeleted-on-release\tgone-on-release.txt\n' > "$TEST_ROOT/conflicts.expected"
cmp -s "$TEST_ROOT/conflicts.sorted" "$TEST_ROOT/conflicts.expected" \
|| { cat "$TEST_ROOT/conflicts"; fail "Non-text conflicts were not classified."; }
echo "PASS: --commit-conflicts flags binary and modify/delete conflicts"

# A marker-like line in a non-conflicted file must still be rejected in repair mode.
new_repo marker-in-clean-file
write_file shared.txt release
commit_all release
release_sha="$(git rev-parse HEAD)"
git switch -q main
write_file shared.txt main
write_file stray.txt '<<<<<<< stray'
commit_all main-conflict-and-marker
git switch -q release/test
if bash "$HELPER" main release/test --commit-conflicts > "$TEST_ROOT/output" 2>&1; then
fail "Stray marker in a non-conflicted file was accepted."
fi
grep -q "Conflict markers detected" "$TEST_ROOT/output" || { cat "$TEST_ROOT/output"; fail "Missing marker diagnostic."; }
[[ "$(git rev-parse HEAD)" == "$release_sha" && -z "$(git status --porcelain)" ]] || fail "Rejected repair left changes."
echo "PASS: repair mode still rejects markers outside the conflicted paths"

new_repo fatal-merge
release_sha="$(git rev-parse HEAD)"
git switch -q main
Expand Down
134 changes: 129 additions & 5 deletions .github/workflows/update-release-branch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
repositories: ${{ github.event.repository.name }}
github-api-url: ${{ github.api_url }}
permission-contents: write
permission-pull-requests: write

- name: Find active release branch
id: find-branch
Expand Down Expand Up @@ -58,23 +59,51 @@ jobs:
echo "branch=$branch" >> "$GITHUB_OUTPUT"
fi

- name: Checkout release branch
- name: Check for an open repair PR
id: repair-pr
if: ${{ steps.find-branch.outputs.branch != '' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_REPO: ${{ github.repository }}
BRANCH: ${{ steps.find-branch.outputs.branch }}
shell: bash
run: |
set -euo pipefail

# An open repair PR means a human already owes this release a conflict
# resolution; syncing again would only repeat the same conflict.
# Match the head owner so a fork branch with the same name can't pause syncing.
existing=$(gh api --method GET "repos/$GH_REPO/pulls" \
-f state=open \
-f head="${GH_REPO%%/*}:release-sync/${BRANCH#release/}" \
-f base="$BRANCH" \
--jq '.[0].html_url // empty')

if [[ -n "$existing" ]]; then
echo "::notice::Skipping sync: repair PR $existing is still open."
echo "## Release sync skipped" >> "$GITHUB_STEP_SUMMARY"
echo "Repair PR $existing is open. Resolve and merge it with **Create a merge commit** to resume syncing." >> "$GITHUB_STEP_SUMMARY"
fi
echo "url=$existing" >> "$GITHUB_OUTPUT"

- name: Checkout release branch
if: ${{ steps.find-branch.outputs.branch != '' && steps.repair-pr.outputs.url == '' }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.find-branch.outputs.branch }}
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0

- name: Configure git identity
if: ${{ steps.find-branch.outputs.branch != '' }}
if: ${{ steps.find-branch.outputs.branch != '' && steps.repair-pr.outputs.url == '' }}
shell: bash
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Merge main into release branch
if: ${{ steps.find-branch.outputs.branch != '' }}
id: merge
if: ${{ steps.find-branch.outputs.branch != '' && steps.repair-pr.outputs.url == '' }}
env:
BRANCH: ${{ steps.find-branch.outputs.branch }}
shell: bash
Expand All @@ -87,5 +116,100 @@ jobs:
# Checkout is on release; always run the helper from the pinned main
# commit, including when release has not received the helper yet.
git show "$MAIN_SHA:.github/scripts/merge-main-into-release.sh" > "$RUNNER_TEMP/merge-main-into-release.sh"
bash "$RUNNER_TEMP/merge-main-into-release.sh" "$MAIN_SHA" "$BRANCH"
git push origin "HEAD:refs/heads/$BRANCH"

status=0
# Buffer the helper's summary: its manual-recovery guidance is wrong
# when the repair PR step below handles the conflict automatically.
probe_summary="$RUNNER_TEMP/release-sync-summary.md"
: > "$probe_summary"
GITHUB_STEP_SUMMARY="$probe_summary" \
bash "$RUNNER_TEMP/merge-main-into-release.sh" "$MAIN_SHA" "$BRANCH" || status=$?
if [[ "$status" -ne 3 ]]; then
cat "$probe_summary" >> "$GITHUB_STEP_SUMMARY"
fi

case "$status" in
0)
git push origin "HEAD:refs/heads/$BRANCH"
;;
3)
# Real conflicts: the helper aborted cleanly, so hand off to the repair PR step.
echo "needs-repair=true" >> "$GITHUB_OUTPUT"
echo "main-sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
;;
*)
exit "$status"
;;
esac

- name: Open repair PR for conflicts
if: ${{ steps.merge.outputs.needs-repair == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_REPO: ${{ github.repository }}
BRANCH: ${{ steps.find-branch.outputs.branch }}
MAIN_SHA: ${{ steps.merge.outputs.main-sha }}
shell: bash
run: |
set -euo pipefail

repair_branch="release-sync/${BRANCH#release/}"
conflicts_file="$RUNNER_TEMP/release-sync-conflicts.txt"

# The failed attempt was aborted, so HEAD is still the release tip.
# The repair branch is bot-owned and rolling: reset any stale leftover.
git checkout -B "$repair_branch"
RELEASE_SYNC_CONFLICTS_FILE="$conflicts_file" \
bash "$RUNNER_TEMP/merge-main-into-release.sh" "$MAIN_SHA" "$BRANCH" --commit-conflicts
[[ -s "$conflicts_file" ]] || { echo "::error::Repair merge reported no conflicts."; exit 1; }
git push --force origin "HEAD:refs/heads/$repair_branch"

body_file="$RUNNER_TEMP/release-sync-pr-body.md"
{
echo "## Summary"
echo
echo "Merging \`main\` (\`$MAIN_SHA\`) into \`$BRANCH\` hit conflicts that the release sync can't resolve automatically. This branch holds the merge with the conflicts left for you to resolve."
echo
echo "### Text conflicts (markers left in the file)"
echo
awk -F'\t' '$1 == "text" { print "- `" $2 "`" }' "$conflicts_file"
echo
echo "### Needs a decision (no markers)"
echo
echo "Git can't mark these, so this branch currently keeps one side. Review each and choose deliberately:"
echo
awk -F'\t' '
$1 == "binary" { print "- `" $2 "`: binary file changed on both sides; the release copy is kept. Take main with `git checkout " ENVIRON["MAIN_SHA"] " -- \"" $2 "\"`." }
$1 == "deleted-on-main" { print "- `" $2 "`: deleted on main, modified on release; the release copy is kept. Delete it with `git rm \"" $2 "\"` if the deletion should win." }
$1 == "deleted-on-release" { print "- `" $2 "`: deleted on release, modified on main; the main copy is kept. Delete it with `git rm \"" $2 "\"` if the deletion should win." }
' "$conflicts_file"
echo
echo "To land it:"
echo
echo "1. Check out \`$repair_branch\` and resolve each item above without discarding release-only work."
echo "2. Push the fix, mark the PR ready for review, and wait for checks."
echo "3. Merge with **Create a merge commit**, never squash or rebase. Copying content alone doesn't repair ancestry."
echo
echo "Release sync is paused while this PR is open. After it merges, verify \`git merge-base --is-ancestor $MAIN_SHA origin/$BRANCH\`; the next push to main resumes syncing."
echo
echo "## Third-party links and affiliations"
echo
echo "None"
echo
echo "## Validation"
echo
echo "Automated merge of \`main\` into \`$BRANCH\`. Not validated: conflicts are unresolved."
} > "$body_file"

pr_url=$(gh pr create \
--draft \
--base "$BRANCH" \
--head "$repair_branch" \
--title "Resolve release sync conflicts for $BRANCH" \
--body-file "$body_file")

echo "::warning::Release sync needs human review: $pr_url"
{
echo "## Release sync needs human review"
echo "Opened repair PR: $pr_url"
} >> "$GITHUB_STEP_SUMMARY"
Loading