diff --git a/.github/scripts/merge-main-into-release.sh b/.github/scripts/merge-main-into-release.sh index dca7160299..19c2c2d1a4 100644 --- a/.github/scripts/merge-main-into-release.sh +++ b/.github/scripts/merge-main-into-release.sh @@ -1,6 +1,19 @@ #!/usr/bin/env bash -# Usage: merge-main-into-release.sh +# Usage: merge-main-into-release.sh [--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 +# "\t" (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}")" @@ -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." @@ -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." + 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. @@ -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[@]}" +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." @@ -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 diff --git a/.github/scripts/test-merge-main-into-release.sh b/.github/scripts/test-merge-main-into-release.sh index cce4b78e05..e16d8af30c 100644 --- a/.github/scripts/test-merge-main-into-release.sh +++ b/.github/scripts/test-merge-main-into-release.sh @@ -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." @@ -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 diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml index 5c6dcfcf3c..dcb7e16aeb 100644 --- a/.github/workflows/update-release-branch.yml +++ b/.github/workflows/update-release-branch.yml @@ -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 @@ -58,8 +59,35 @@ 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 }} @@ -67,14 +95,15 @@ jobs: 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 @@ -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"