From 580da1bc2f66e9345e9a92649a350717d29c3dd9 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Tue, 8 Sep 2026 17:46:54 +0200 Subject: [PATCH 1/3] ci: back-merge without waiting for a person to merge a PR The old workflow opened a pull request and left the merge to a human. The first one was merged with "squash", which copied main's content into develop as one ordinary commit and dropped its history. Git then had no way to know develop already held those changes, so every later back-merge tried to re-apply 236 commits whose content was already there: 67 conflicting files on a merge that should have been a formality. A back-merge carries no decision, so it no longer waits for one. The workflow fast-forwards develop when develop has nothing of its own, and otherwise makes the merge commit itself and pushes it. A pull request is still opened, but only when the work genuinely cannot be done unattended -- a real conflict, or a branch rule refusing the push -- and its body now says in capitals not to squash it. develop itself was repaired separately: main merged in with its content taken throughout, verified byte-identical to main's tree, which lost nothing because develop carried no work of its own. --- .github/workflows/backmerge.yml | 79 +++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml index e47c8f7..1d4444c 100644 --- a/.github/workflows/backmerge.yml +++ b/.github/workflows/backmerge.yml @@ -1,8 +1,17 @@ -# After anything lands on main, keep develop in sync by opening (or refreshing) -# a pull request that merges main back into develop. +# After anything lands on main, bring develop up to it. # -# A PR rather than a direct push, so the merge is visible and CI runs on it. -# Conflicts are resolved on the PR. +# The merge happens HERE, not in a pull request a person merges. It used to be +# a PR, and that failed in a way worth remembering: the first one was merged +# with "squash", so develop received main's CONTENT as one ordinary commit but +# none of its history. Git then had no idea develop already held those changes, +# and every later back-merge tried to re-apply 236 commits whose content was +# already there -- 67 conflicting files, on a merge that should have been a +# formality. A back-merge needs no review and no human decision, so it no +# longer waits for one. +# +# A pull request is still opened, but only when the merge genuinely cannot be +# done unattended: a real conflict, or a protected branch that refuses the +# push. Those are the cases where a person is actually needed. # # While the project is pre-release the flow is inverted from the eventual one: # work happens on main, and develop follows it. Once releases start, features @@ -16,7 +25,7 @@ on: branches: [main] permissions: - contents: read + contents: write pull-requests: write concurrency: @@ -24,38 +33,80 @@ concurrency: cancel-in-progress: false jobs: - open-backmerge-pr: + back-merge: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Skip if develop already contains main + - name: Decide what is needed id: check run: | set -uo pipefail # Said plainly rather than dying on `fatal: Not a valid object name`, # which is what the first run did before develop existed. if ! git rev-parse --verify --quiet origin/develop >/dev/null; then - echo "needs_pr=false" >> "$GITHUB_OUTPUT" + echo "action=none" >> "$GITHUB_OUTPUT" echo "::notice::there is no develop branch, so there is nothing to back-merge into." exit 0 fi if git merge-base --is-ancestor origin/main origin/develop; then - echo "needs_pr=false" >> "$GITHUB_OUTPUT" + echo "action=none" >> "$GITHUB_OUTPUT" echo "develop already contains main; nothing to do." + elif git merge-base --is-ancestor origin/develop origin/main; then + # The healthy case: develop has nothing of its own, so this is a + # pointer move with no merge commit and no possible conflict. + echo "action=fast-forward" >> "$GITHUB_OUTPUT" + else + echo "action=merge" >> "$GITHUB_OUTPUT" + fi + + - name: Fast-forward develop to main + id: ff + if: steps.check.outputs.action == 'fast-forward' + run: | + set -uo pipefail + if git push origin "origin/main:refs/heads/develop"; then + echo "pushed=true" >> "$GITHUB_OUTPUT" + else + echo "pushed=false" >> "$GITHUB_OUTPUT" + echo "::warning::could not fast-forward develop, probably branch protection; opening a pull request instead." + fi + + - name: Merge main into develop + id: merge + if: steps.check.outputs.action == 'merge' + run: | + set -uo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B develop origin/develop + # --no-ff so the merge is always recorded as one. A back-merge that + # leaves no merge commit is exactly the failure this workflow exists + # to avoid. + if ! git merge --no-ff origin/main -m "chore: back-merge main into develop"; then + git merge --abort || true + echo "pushed=false" >> "$GITHUB_OUTPUT" + echo "::warning::main and develop conflict; opening a pull request for a person to resolve." + exit 0 + fi + if git push origin develop; then + echo "pushed=true" >> "$GITHUB_OUTPUT" else - echo "needs_pr=true" >> "$GITHUB_OUTPUT" + echo "pushed=false" >> "$GITHUB_OUTPUT" + echo "::warning::could not push the merge, probably branch protection; opening a pull request instead." fi - - name: Open or update back-merge PR - if: steps.check.outputs.needs_pr == 'true' + - name: Open or update a back-merge PR, when the merge could not be done here + if: | + steps.ff.outputs.pushed == 'false' || steps.merge.outputs.pushed == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} WORKFLOW_NAME: ${{ github.workflow }} RUN_ID: ${{ github.run_id }} run: | + set -uo pipefail MAIN_SHA="$(git rev-parse --short origin/main)" MAIN_SUBJECT="$(git log -1 --format='%s' origin/main)" @@ -67,13 +118,13 @@ jobs: BODY_FILE="$(mktemp)" cat > "$BODY_FILE" < Date: Tue, 8 Sep 2026 17:50:15 +0200 Subject: [PATCH 2/3] ci: let the back-merge pull request merge itself The previous commit tried to have the workflow push the merge to develop. That cannot work: `develop` is protected and refuses pushes outright -- "Changes must be made through a pull request". The attempt is on record in the run it declined. So the pull request stays; the person goes. The workflow now turns on auto-merge with the MERGE method, and GitHub merges the PR once its required checks pass. Nobody picks a method, so the squash that broke this once cannot happen by accident. Where auto-merge is unavailable the run warns and the PR body says, in capitals, not to squash it. This needed `allow_auto_merge` on the repository, which was off. Permissions drop to `contents: read`, since nothing pushes any more. --- .github/workflows/backmerge.yml | 106 ++++++++++++-------------------- 1 file changed, 38 insertions(+), 68 deletions(-) diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml index 1d4444c..69f1980 100644 --- a/.github/workflows/backmerge.yml +++ b/.github/workflows/backmerge.yml @@ -1,17 +1,19 @@ # After anything lands on main, bring develop up to it. # -# The merge happens HERE, not in a pull request a person merges. It used to be -# a PR, and that failed in a way worth remembering: the first one was merged -# with "squash", so develop received main's CONTENT as one ordinary commit but -# none of its history. Git then had no idea develop already held those changes, -# and every later back-merge tried to re-apply 236 commits whose content was -# already there -- 67 conflicting files, on a merge that should have been a -# formality. A back-merge needs no review and no human decision, so it no -# longer waits for one. +# A pull request that merges ITSELF. `develop` is protected -- pushes to it are +# refused with "Changes must be made through a pull request" -- so the PR is not +# optional. What is optional is the person, and the person was the problem: the +# first back-merge PR was merged with "squash", which copied main's content into +# develop as one ordinary commit and dropped its history. Git then had no way to +# know develop already held those changes, so every later back-merge tried to +# re-apply 236 commits whose content was already there: 67 conflicting files on +# a merge that should have been a formality. # -# A pull request is still opened, but only when the merge genuinely cannot be -# done unattended: a real conflict, or a protected branch that refuses the -# push. Those are the cases where a person is actually needed. +# So the workflow opens the PR and turns on auto-merge with the MERGE method. +# GitHub merges it once the required checks pass, nobody chooses a method, and +# a squash cannot happen by accident. Auto-merge has to be enabled on the +# repository for this (`allow_auto_merge`); if it is off, the run says so and +# leaves the PR for a person, who must merge it with a merge commit. # # While the project is pre-release the flow is inverted from the eventual one: # work happens on main, and develop follows it. Once releases start, features @@ -25,7 +27,9 @@ on: branches: [main] permissions: - contents: write + # Read is enough: nothing here pushes. The merge is GitHub'''s, done on the + # pull request once its checks pass. + contents: read pull-requests: write concurrency: @@ -54,53 +58,12 @@ jobs: if git merge-base --is-ancestor origin/main origin/develop; then echo "action=none" >> "$GITHUB_OUTPUT" echo "develop already contains main; nothing to do." - elif git merge-base --is-ancestor origin/develop origin/main; then - # The healthy case: develop has nothing of its own, so this is a - # pointer move with no merge commit and no possible conflict. - echo "action=fast-forward" >> "$GITHUB_OUTPUT" else - echo "action=merge" >> "$GITHUB_OUTPUT" + echo "action=pr" >> "$GITHUB_OUTPUT" fi - - name: Fast-forward develop to main - id: ff - if: steps.check.outputs.action == 'fast-forward' - run: | - set -uo pipefail - if git push origin "origin/main:refs/heads/develop"; then - echo "pushed=true" >> "$GITHUB_OUTPUT" - else - echo "pushed=false" >> "$GITHUB_OUTPUT" - echo "::warning::could not fast-forward develop, probably branch protection; opening a pull request instead." - fi - - - name: Merge main into develop - id: merge - if: steps.check.outputs.action == 'merge' - run: | - set -uo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B develop origin/develop - # --no-ff so the merge is always recorded as one. A back-merge that - # leaves no merge commit is exactly the failure this workflow exists - # to avoid. - if ! git merge --no-ff origin/main -m "chore: back-merge main into develop"; then - git merge --abort || true - echo "pushed=false" >> "$GITHUB_OUTPUT" - echo "::warning::main and develop conflict; opening a pull request for a person to resolve." - exit 0 - fi - if git push origin develop; then - echo "pushed=true" >> "$GITHUB_OUTPUT" - else - echo "pushed=false" >> "$GITHUB_OUTPUT" - echo "::warning::could not push the merge, probably branch protection; opening a pull request instead." - fi - - - name: Open or update a back-merge PR, when the merge could not be done here - if: | - steps.ff.outputs.pushed == 'false' || steps.merge.outputs.pushed == 'false' + - name: Open the back-merge PR and let it merge itself + if: steps.check.outputs.action == 'pr' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} WORKFLOW_NAME: ${{ github.workflow }} @@ -110,15 +73,10 @@ jobs: MAIN_SHA="$(git rev-parse --short origin/main)" MAIN_SUBJECT="$(git log -1 --format='%s' origin/main)" - EXISTING="$(gh pr list --head main --base develop --state open --json number --jq '.[0].number')" - if [ -n "$EXISTING" ]; then - echo "PR #$EXISTING already open for main -> develop; it will auto-update with the new push." - exit 0 - fi - + NUMBER="$(gh pr list --head main --base develop --state open --json number --jq '.[0].number')" BODY_FILE="$(mktemp)" cat > "$BODY_FILE" < Date: Tue, 8 Sep 2026 17:54:12 +0200 Subject: [PATCH 3/3] ci: open dependabot pull requests against develop A dependency bump is ordinary work, and ordinary work enters through `develop` per Branching in CONTRIBUTING.md. Opening them against `main` put them straight onto the release branch. Both ecosystems move, npm and github-actions. The one already open against `main` stays there; Dependabot does not retarget an existing pull request. --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 480e7d6..e3b20e6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,11 @@ # want: a known advisory beats an unaudited release, which is the same # exception `scripts/dependency-age-exceptions.json` records for a human. # +# Both ecosystems target `develop`, not `main`. A dependency bump is ordinary +# work, and ordinary work enters through `develop` -- see Branching in +# CONTRIBUTING.md. Opening these against `main` put them on the release branch +# and left the back-merge to carry them the wrong way round. +# # See .agents/skills/check-dependencies/SKILL.md for the reasoning, and # AGENTS.md for how a dependency change reaches a release. @@ -18,6 +23,7 @@ updates: - package-ecosystem: npm # The workspace root: one pnpm-lock.yaml covers all four manifests. directory: / + target-branch: develop schedule: interval: weekly day: monday @@ -46,6 +52,7 @@ updates: - package-ecosystem: github-actions directory: / + target-branch: develop schedule: interval: weekly day: monday