From b20914165df0ca1494a6e302be67d14418d22ef5 Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 2 Nov 2026 14:17:39 -0500 Subject: [PATCH 1/3] Add next branch workflow: forward merge PR and branch policy checks (RFD #142) --- .ai/PERFORMANCE.md | 7 ++- .github/pull_request_template.md | 1 + .github/scripts/changelog_added.py | 49 ++++++++++++++++ .github/workflows/benchmarks.yml | 4 +- .github/workflows/branch-policy.yml | 64 +++++++++++++++++++++ .github/workflows/forward-merge.yml | 86 +++++++++++++++++++++++++++++ .github/workflows/testing.yml | 31 +++++++---- CLAUDE.md | 4 ++ CONTRIBUTING.md | 20 ++++++- 9 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 .github/scripts/changelog_added.py create mode 100644 .github/workflows/branch-policy.yml create mode 100644 .github/workflows/forward-merge.yml diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md index 47d91f8321..4b32850800 100644 --- a/.ai/PERFORMANCE.md +++ b/.ai/PERFORMANCE.md @@ -59,8 +59,11 @@ Commit the new `baseline.json` in the same PR, and say why in the message. To capture the baseline on CI hardware instead (so the machine scale is ~1.0x for subsequent PRs), run the **Performance Benchmarks** workflow via `workflow_dispatch` with `regenerate_baseline` checked, on the branch you want -to refresh (usually the default branch, right after a merge). It measures a -fresh baseline on the runner and opens a PR updating `benchmarks/baseline.json`. +to refresh, right after a merge. It measures a fresh baseline on the runner and +opens a PR into that branch updating `benchmarks/baseline.json`. `dev` and +`next` each carry their own baseline: one regenerated on `dev` reaches `next` +through the forward merge, so only regenerate on `next` after a feature there +changes the numbers. ### Machine-independent gating diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 47887292fa..2c6eb39654 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,7 @@ ## Contributor Checklist +- [ ] This PR targets `dev` (fix or chore) or `next` (feature, new API, breaking change), see [Which branch to target](https://github.com/plotly/dash/blob/dev/CONTRIBUTING.md#which-branch-to-target) - [ ] I have broken down my PR scope into the following TODO tasks - [ ] task 1 - [ ] task 2 diff --git a/.github/scripts/changelog_added.py b/.github/scripts/changelog_added.py new file mode 100644 index 0000000000..c505c71d0f --- /dev/null +++ b/.github/scripts/changelog_added.py @@ -0,0 +1,49 @@ +"""Fail when a PR into dev adds an Unreleased "Added" CHANGELOG entry. + +Features go to next (RFD #142). Usage: changelog_added.py BASE_CHANGELOG HEAD_CHANGELOG +""" + +import re +import sys + +RELEASE = re.compile(r"^## \[") +SECTION = re.compile(r"^#{2,3} (\w+)") + + +def unreleased_added(path): + entries = set() + in_unreleased = False + section = None + with open(path, encoding="utf-8") as f: + for line in f: + if RELEASE.match(line): + if in_unreleased: + break + in_unreleased = line.upper().startswith("## [UNRELEASED]") + continue + if not in_unreleased: + continue + match = SECTION.match(line) + if match: + section = match.group(1).lower() + elif section == "added" and line.strip(): + entries.add(line.strip()) + return entries + + +def main(base, head): + added = sorted(unreleased_added(head) - unreleased_added(base)) + if not added: + return 0 + print( + "::error file=CHANGELOG.md::This PR adds an 'Added' entry, so it is a" + " feature and should target next, not dev. If it is really a fix," + " move the entry under 'Fixed'." + ) + for entry in added: + print(f" {entry}") + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 9558e05f40..1ff25c3517 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -17,7 +17,7 @@ on: regenerate_baseline: description: >- Measure a fresh baseline on the CI runner and open a PR updating - benchmarks/baseline.json (run on the default branch after a merge so + benchmarks/baseline.json (run on dev or next after a merge so the machine scale is ~1.0x for the next PR). type: boolean default: false @@ -134,7 +134,7 @@ jobs: regenerate-baseline: name: Regenerate benchmark baseline # Manual only: dispatch with regenerate_baseline=true, on the branch whose - # baseline you want to refresh (usually the default branch, post-merge). + # baseline you want to refresh (dev or next, post-merge). if: ${{ github.event_name == 'workflow_dispatch' && inputs.regenerate_baseline }} runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 0000000000..53a830378b --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,64 @@ +name: Branch Policy + +# RFD #142: fixes and chores target dev, features target next. + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + branches: + - dev + - next + +permissions: + contents: read + pull-requests: read + +jobs: + no-features-on-dev: + name: No features on dev + if: github.base_ref == 'dev' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check CHANGELOG for new Added entries + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + base=$(git merge-base "$HEAD_SHA" origin/dev) + git show "$base:CHANGELOG.md" > "$RUNNER_TEMP/base.md" + git show "$HEAD_SHA:CHANGELOG.md" > "$RUNNER_TEMP/head.md" + python3 .github/scripts/changelog_added.py "$RUNNER_TEMP/base.md" "$RUNNER_TEMP/head.md" + + forward-merge-clean: + name: Forward merge not conflicted + if: > + github.base_ref == 'next' && + !(github.head_ref == 'dev-into-next' && github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + steps: + - name: Fail while the dev into next PR has conflicts + uses: actions/github-script@v7 + with: + script: | + const {owner, repo} = context.repo; + const [open] = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', base: 'next', head: `${owner}:dev-into-next`, + }); + if (!open) return; + // GitHub computes mergeable lazily, so the first reads can be null. + let pr; + for (let i = 0; i < 10; i++) { + ({data: pr} = await github.rest.pulls.get({owner, repo, pull_number: open.number})); + if (pr.mergeable !== null) break; + await new Promise(r => setTimeout(r, 3000)); + } + if (pr.mergeable === null) { + core.setFailed(`GitHub has not computed whether ${pr.html_url} merges cleanly yet. Re-run this check.`); + } else if (pr.mergeable === false) { + core.setFailed( + `${pr.html_url} has conflicts. Resolve them before merging more into next, then re-run this check.`); + } diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml new file mode 100644 index 0000000000..bc0daedc7a --- /dev/null +++ b/.github/workflows/forward-merge.yml @@ -0,0 +1,86 @@ +name: Forward Merge + +# Keeps next containing dev (RFD #142): each push to dev opens or updates a +# "Merge dev into next" PR from the dev-into-next branch. Conflicts are +# resolved on that branch, never on dev. Merge it with a merge commit only: +# a squash would drop dev's history from next and every later merge conflicts. + +on: + push: + branches: + - dev + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + actions: write + statuses: write + +concurrency: + group: forward-merge + cancel-in-progress: false + +env: + MERGE_BRANCH: dev-into-next + +jobs: + forward-merge: + name: Merge dev into next + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Update the merge branch and PR + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin dev next + + push_failed() { + # GITHUB_TOKEN cannot push commits that change .github/workflows. + echo "::error::Could not push $MERGE_BRANCH. If dev changed a workflow file, a maintainer has to push it: git push origin origin/dev:$MERGE_BRANCH (or merge origin/dev into it)." + exit 1 + } + + if git merge-base --is-ancestor origin/dev origin/next; then + echo "next already contains dev." + exit 0 + fi + + pr=$(gh pr list --base next --head "$MERGE_BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -n "$pr" ] && git fetch origin "$MERGE_BRANCH" && \ + ! git merge-base --is-ancestor "origin/$MERGE_BRANCH" origin/dev; then + # Someone already resolved conflicts on the branch: build on that. + git switch -c "$MERGE_BRANCH" "origin/$MERGE_BRANCH" + if ! git merge --no-edit origin/dev; then + git merge --abort + gh pr comment "$pr" --body "dev moved to $(git rev-parse --short origin/dev) and does not merge cleanly into \`$MERGE_BRANCH\`. Merge \`dev\` into \`$MERGE_BRANCH\` by hand." + exit 0 + fi + git push origin "$MERGE_BRANCH" || push_failed + head=$(git rev-parse HEAD) + # Pushes made with GITHUB_TOKEN do not trigger CI, a dispatch does. + gh workflow run testing.yml --ref "$MERGE_BRANCH" + else + # The branch is dev itself, so the checks already run on dev apply. + git push --force origin "origin/dev:refs/heads/$MERGE_BRANCH" || push_failed + head=$(git rev-parse origin/dev) + fi + + # Required on next, but the branch-policy job never runs for this PR + # because PRs opened with GITHUB_TOKEN do not trigger workflows. + gh api "repos/$GITHUB_REPOSITORY/statuses/$head" \ + -f state=success -f context="Forward merge not conflicted" \ + -f description="This is the forward merge PR" > /dev/null + + if [ -z "$pr" ]; then + gh pr create --base next --head "$MERGE_BRANCH" \ + --title "Merge dev into next" \ + --body "Automated forward merge of \`dev\` into \`next\` (RFD #142). Merge with **Create a merge commit**, never squash. On conflicts, resolve them on \`$MERGE_BRANCH\`, not on \`dev\`. While this PR has conflicts, other PRs into \`next\` are blocked." + fi diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 1a48a305ed..911dbd880d 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -4,6 +4,7 @@ on: push: branches: - dev + - next - master pull_request: workflow_dispatch: @@ -340,7 +341,8 @@ jobs: name: Run Background & Async Callback Tests (Python ${{ matrix.python-version }}) needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.background_cb_changed == 'true' timeout-minutes: 30 runs-on: ubuntu-latest @@ -452,7 +454,8 @@ jobs: name: Run Backend Callback Tests (Python ${{ matrix.python-version }}) needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.backend_cb_changed == 'true' timeout-minutes: 30 runs-on: ubuntu-latest @@ -526,7 +529,8 @@ jobs: name: Table Unit/Lint Tests (Python ${{ matrix.python-version }}) needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.table_paths_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 30 @@ -584,7 +588,8 @@ jobs: # Conditional execution: # OR if the 'changes_filter' job detected changes in 'table_related_paths'. if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.table_paths_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 30 @@ -648,7 +653,8 @@ jobs: name: WebSocket Tests (Python ${{ matrix.python-version }}) needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.websocket_changed == 'true' timeout-minutes: 30 runs-on: ubuntu-latest @@ -709,7 +715,8 @@ jobs: name: Streaming Callback Tests (Python ${{ matrix.python-version }}) needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.streaming_changed == 'true' timeout-minutes: 30 runs-on: ubuntu-latest @@ -871,7 +878,8 @@ jobs: name: HTML Components Tests (Python ${{ matrix.python-version }}, React ${{ matrix.react-version }}) needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.html_paths_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 30 @@ -1026,7 +1034,8 @@ jobs: name: DCC Lint Tests (Python ${{ matrix.python-version }}) needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.dcc_paths_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 30 @@ -1074,7 +1083,8 @@ jobs: name: DCC Integration Tests (Python ${{ matrix.python-version }}, React ${{ matrix.react-version }}, Group ${{ matrix.test-group }}) needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.dcc_paths_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 30 @@ -1168,7 +1178,8 @@ jobs: name: Dash Table Visual Tests needs: [build, changes_filter] if: | - (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) || + (github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/dev", "refs/heads/next"]'), github.ref)) || + github.event_name == 'workflow_dispatch' || needs.changes_filter.outputs.table_paths_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/CLAUDE.md b/CLAUDE.md index bff55ca073..317e69a25e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,10 @@ Deeper reference lives in `.ai/`. Open the one that covers what you're touching, To find code, grep. Don't rely on a hand-kept file map, it goes stale. +## Branches + +Fixes and chores target `dev`, features target `next`. See "Which branch to target" in `CONTRIBUTING.md`. + ## Writing Style Applies to code comments, commit messages, PR descriptions, review comments, and CHANGELOG entries. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97182044d4..f825b81f97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Glad that you decided to make your contribution in Dash. This guide provides instructions to set up and build the Dash repository and describes best practices when contributing to the Dash repository. ### Fork the Dash repository -When contributing to the Dash repository you should always work in your own copy of the Dash repository. Create a fork of the `dev`-branch, to create a copy of the Dash repository in your own GitHub account. See official instructions for [creating a fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) if needed. +When contributing to the Dash repository you should always work in your own copy of the Dash repository. Create a fork, to create a copy of the Dash repository in your own GitHub account. See official instructions for [creating a fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) if needed. Clone the forked repository (either option will work). Replace `` with your user name. ``` @@ -156,7 +156,23 @@ Specific details on making changes and contributing to `dcc`, `html`, and `dash_ ## Git -Use the [GitHub flow](https://guides.github.com/introduction/flow/) when proposing contributions to this repository (i.e. create a feature branch and submit a PR against the default branch). +Use the [GitHub flow](https://guides.github.com/introduction/flow/) when proposing contributions to this repository: create a branch and submit a PR against `dev` or `next`. + +### Which branch to target + +| Branch | Receives | Releases | +| --- | --- | --- | +| `dev` (default) | Fixes, security, dependency bumps, CI, docs, refactors and perf work with no behavior change | Patches `x.y.Z` | +| `next` | Features, new API, deprecations, breaking changes | RCs and minors `x.Y.0` | + +Semver decides: a change that needs a minor version bump goes to `next`, one that needs only a patch bump goes to `dev`. A fix that depends on an unreleased feature goes to `next`. When in doubt, pick `next`. A PR into `dev` that adds an `Added` entry to the CHANGELOG fails the "No features on dev" check. Maintainers retarget feature PRs opened against `dev`. + +Fixes are written once, on `dev`. The Forward Merge workflow keeps a "Merge dev into next" PR open from the `dev-into-next` branch. Merge it with a merge commit, never squash. If it has conflicts, resolve them on `dev-into-next` by merging `origin/next` into it (never on `dev`); until then other PRs into `next` are blocked. Expect this after each patch release, since the version bumps and CHANGELOG heading on `dev` collide with `next`. + +### Releases + +- **Patch:** release from `dev` (`master-x.y.Z` merges `dev`). +- **Minor:** RCs come from `next`, and while one is out `next` only takes RC fixes. On release, merge the open "Merge dev into next" PR, check that `git log origin/next..origin/dev` is empty, then fast-forward `dev` to `next` (`git push origin origin/next:dev`, admins only) and release from `dev` as usual. `dev` and `next` are then identical. ### Organize your commits From 212ac4823da25e227a03a778758053dadbc0ae5d Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 2 Nov 2026 14:56:55 -0500 Subject: [PATCH 2/3] Merge dev into next automatically, open a PR only on conflict --- .github/workflows/forward-merge.yml | 53 ++++++++++++++++++++--------- CONTRIBUTING.md | 4 +-- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index bc0daedc7a..aa70eb40bb 100644 --- a/.github/workflows/forward-merge.yml +++ b/.github/workflows/forward-merge.yml @@ -1,9 +1,11 @@ name: Forward Merge -# Keeps next containing dev (RFD #142): each push to dev opens or updates a -# "Merge dev into next" PR from the dev-into-next branch. Conflicts are -# resolved on that branch, never on dev. Merge it with a merge commit only: -# a squash would drop dev's history from next and every later merge conflicts. +# Keeps next containing dev (RFD #142): each push to dev is merged into next. +# It pushes with the DEV_NEXT_MERGE_KEY deploy key, the only non-admin allowed +# to bypass the next ruleset. When the merge conflicts it opens a +# "Merge dev into next" PR from dev-into-next instead, and conflicts are +# resolved on that branch, never on dev. Merge that PR with a merge commit +# only: a squash would drop dev's history from next. on: push: @@ -12,7 +14,7 @@ on: workflow_dispatch: permissions: - contents: write + contents: read pull-requests: write actions: write statuses: write @@ -33,8 +35,9 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ssh-key: ${{ secrets.DEV_NEXT_MERGE_KEY }} - - name: Update the merge branch and PR + - name: Merge dev into next, or open a PR on conflict env: GH_TOKEN: ${{ github.token }} run: | @@ -42,34 +45,50 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git fetch origin dev next - push_failed() { - # GITHUB_TOKEN cannot push commits that change .github/workflows. - echo "::error::Could not push $MERGE_BRANCH. If dev changed a workflow file, a maintainer has to push it: git push origin origin/dev:$MERGE_BRANCH (or merge origin/dev into it)." - exit 1 - } - if git merge-base --is-ancestor origin/dev origin/next; then echo "next already contains dev." exit 0 fi pr=$(gh pr list --base next --head "$MERGE_BRANCH" --state open --json number --jq '.[0].number // empty') + + # While a conflict PR is open, keep going through it so work done on + # the branch is not bypassed. + if [ -z "$pr" ]; then + conflict= + for attempt in 1 2 3; do + git switch -C next origin/next + if ! git merge --no-edit origin/dev; then + git merge --abort + conflict=1 + break + fi + if git push origin next; then + echo "Merged dev into next." + exit 0 + fi + # Something merged into next meanwhile. + git fetch origin next + done + [ -n "$conflict" ] || exit 1 + fi + if [ -n "$pr" ] && git fetch origin "$MERGE_BRANCH" && \ ! git merge-base --is-ancestor "origin/$MERGE_BRANCH" origin/dev; then # Someone already resolved conflicts on the branch: build on that. - git switch -c "$MERGE_BRANCH" "origin/$MERGE_BRANCH" + git switch -C "$MERGE_BRANCH" "origin/$MERGE_BRANCH" if ! git merge --no-edit origin/dev; then git merge --abort gh pr comment "$pr" --body "dev moved to $(git rev-parse --short origin/dev) and does not merge cleanly into \`$MERGE_BRANCH\`. Merge \`dev\` into \`$MERGE_BRANCH\` by hand." exit 0 fi - git push origin "$MERGE_BRANCH" || push_failed + git push origin "$MERGE_BRANCH" head=$(git rev-parse HEAD) - # Pushes made with GITHUB_TOKEN do not trigger CI, a dispatch does. + # testing.yml does not run on pushes to this branch. gh workflow run testing.yml --ref "$MERGE_BRANCH" else # The branch is dev itself, so the checks already run on dev apply. - git push --force origin "origin/dev:refs/heads/$MERGE_BRANCH" || push_failed + git push --force origin "origin/dev:refs/heads/$MERGE_BRANCH" head=$(git rev-parse origin/dev) fi @@ -82,5 +101,5 @@ jobs: if [ -z "$pr" ]; then gh pr create --base next --head "$MERGE_BRANCH" \ --title "Merge dev into next" \ - --body "Automated forward merge of \`dev\` into \`next\` (RFD #142). Merge with **Create a merge commit**, never squash. On conflicts, resolve them on \`$MERGE_BRANCH\`, not on \`dev\`. While this PR has conflicts, other PRs into \`next\` are blocked." + --body "\`dev\` does not merge cleanly into \`next\` (RFD #142). Resolve the conflicts on \`$MERGE_BRANCH\` by merging \`origin/next\` into it, never on \`dev\`. Merge with **Create a merge commit**, never squash. Until this PR is merged, other PRs into \`next\` are blocked and automatic forward merges pause." fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f825b81f97..292f62234b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,12 +167,12 @@ Use the [GitHub flow](https://guides.github.com/introduction/flow/) when proposi Semver decides: a change that needs a minor version bump goes to `next`, one that needs only a patch bump goes to `dev`. A fix that depends on an unreleased feature goes to `next`. When in doubt, pick `next`. A PR into `dev` that adds an `Added` entry to the CHANGELOG fails the "No features on dev" check. Maintainers retarget feature PRs opened against `dev`. -Fixes are written once, on `dev`. The Forward Merge workflow keeps a "Merge dev into next" PR open from the `dev-into-next` branch. Merge it with a merge commit, never squash. If it has conflicts, resolve them on `dev-into-next` by merging `origin/next` into it (never on `dev`); until then other PRs into `next` are blocked. Expect this after each patch release, since the version bumps and CHANGELOG heading on `dev` collide with `next`. +Fixes are written once, on `dev`. The Forward Merge workflow merges `dev` into `next` on every push to `dev`. When that merge conflicts, it opens a "Merge dev into next" PR from the `dev-into-next` branch instead: resolve the conflicts there by merging `origin/next` into it (never on `dev`), then merge it with a merge commit, never squash. Until then other PRs into `next` are blocked. Expect this after each patch release, since the version bumps and CHANGELOG heading on `dev` collide with `next`. ### Releases - **Patch:** release from `dev` (`master-x.y.Z` merges `dev`). -- **Minor:** RCs come from `next`, and while one is out `next` only takes RC fixes. On release, merge the open "Merge dev into next" PR, check that `git log origin/next..origin/dev` is empty, then fast-forward `dev` to `next` (`git push origin origin/next:dev`, admins only) and release from `dev` as usual. `dev` and `next` are then identical. +- **Minor:** RCs come from `next`, and while one is out `next` only takes RC fixes. On release, make sure no "Merge dev into next" PR is open, check that `git log origin/next..origin/dev` is empty, then fast-forward `dev` to `next` (`git push origin origin/next:dev`, admins only) and release from `dev` as usual. `dev` and `next` are then identical. ### Organize your commits From 311122f4fc15c9be32466311b2a6d705066f0258 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 4 Nov 2026 08:49:40 -0500 Subject: [PATCH 3/3] Forward merge: open a PR on any direct merge failure, lease the branch push, store next build artifacts --- .github/workflows/forward-merge.yml | 33 ++++++++++++++--------------- .github/workflows/testing.yml | 1 + 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml index aa70eb40bb..c9e749707e 100644 --- a/.github/workflows/forward-merge.yml +++ b/.github/workflows/forward-merge.yml @@ -44,6 +44,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git fetch origin dev next + git fetch origin "$MERGE_BRANCH" 2>/dev/null || true + lease=$(git rev-parse -q --verify "origin/$MERGE_BRANCH" || true) if git merge-base --is-ancestor origin/dev origin/next; then echo "next already contains dev." @@ -52,28 +54,25 @@ jobs: pr=$(gh pr list --base next --head "$MERGE_BRANCH" --state open --json number --jq '.[0].number // empty') - # While a conflict PR is open, keep going through it so work done on - # the branch is not bypassed. + # While a PR is open, keep going through it so work done on the + # branch is not bypassed. Any failure to merge directly opens one, so + # it shows up there instead of only as a failed run. if [ -z "$pr" ]; then - conflict= - for attempt in 1 2 3; do - git switch -C next origin/next - if ! git merge --no-edit origin/dev; then - git merge --abort - conflict=1 - break - fi + git switch -C next origin/next + if git merge --no-edit origin/dev; then if git push origin next; then echo "Merged dev into next." exit 0 fi - # Something merged into next meanwhile. - git fetch origin next - done - [ -n "$conflict" ] || exit 1 + reason="Pushing the merge to \`next\` failed, most likely because a PR merged into \`next\` at the same time. Merge this PR if it is clean." + else + git merge --abort + reason="\`dev\` does not merge cleanly into \`next\`. Resolve the conflicts on \`$MERGE_BRANCH\` by merging \`origin/next\` into it, never on \`dev\`." + fi + echo "::warning::Could not merge dev into next directly, opening a PR. $reason" fi - if [ -n "$pr" ] && git fetch origin "$MERGE_BRANCH" && \ + if [ -n "$pr" ] && [ -n "$lease" ] && \ ! git merge-base --is-ancestor "origin/$MERGE_BRANCH" origin/dev; then # Someone already resolved conflicts on the branch: build on that. git switch -C "$MERGE_BRANCH" "origin/$MERGE_BRANCH" @@ -88,7 +87,7 @@ jobs: gh workflow run testing.yml --ref "$MERGE_BRANCH" else # The branch is dev itself, so the checks already run on dev apply. - git push --force origin "origin/dev:refs/heads/$MERGE_BRANCH" + git push --force-with-lease="$MERGE_BRANCH:$lease" origin "origin/dev:refs/heads/$MERGE_BRANCH" head=$(git rev-parse origin/dev) fi @@ -101,5 +100,5 @@ jobs: if [ -z "$pr" ]; then gh pr create --base next --head "$MERGE_BRANCH" \ --title "Merge dev into next" \ - --body "\`dev\` does not merge cleanly into \`next\` (RFD #142). Resolve the conflicts on \`$MERGE_BRANCH\` by merging \`origin/next\` into it, never on \`dev\`. Merge with **Create a merge commit**, never squash. Until this PR is merged, other PRs into \`next\` are blocked and automatic forward merges pause." + --body "Automatic forward merge of \`dev\` into \`next\` failed (RFD #142). $reason Merge with **Create a merge commit**, never squash. Until this PR is merged, other PRs into \`next\` are blocked and automatic forward merges pause." fi diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 911dbd880d..7966f8231c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1359,6 +1359,7 @@ jobs: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev' || + github.ref == 'refs/heads/next' || startsWith(github.ref, 'refs/tags/')) steps: