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..aa70eb40bb --- /dev/null +++ b/.github/workflows/forward-merge.yml @@ -0,0 +1,105 @@ +name: Forward Merge + +# 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: + branches: + - dev + workflow_dispatch: + +permissions: + contents: read + 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 + ssh-key: ${{ secrets.DEV_NEXT_MERGE_KEY }} + + - name: Merge dev into next, or open a PR on conflict + 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 + + 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" + 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" + head=$(git rev-parse HEAD) + # 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" + 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 "\`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/.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..292f62234b 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 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, 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