Skip to content
Open
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
7 changes: 5 additions & 2 deletions .ai/PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions .github/scripts/changelog_added.py
Original file line number Diff line number Diff line change
@@ -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:

Check failure on line 17 in .github/scripts/changelog_added.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Path Traversal via command-line argument

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaGdswWqZQVTtsJ5HDt2&open=AaGdswWqZQVTtsJ5HDt2&pullRequest=4112
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]))
4 changes: 2 additions & 2 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions .github/workflows/branch-policy.yml
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 14 in .github/workflows/branch-policy.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this read permission from workflow level to job level.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaGdswPXZQVTtsJ5HDt1&open=AaGdswPXZQVTtsJ5HDt1&pullRequest=4112

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.`);
}
105 changes: 105 additions & 0 deletions .github/workflows/forward-merge.yml
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why we need to retry three times here?
Even if it's necessary, if the 3rd attempt fails, there is no way to alert ourselves of the failure. It just silently runs 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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An edge case, but this might be safer:

Suggested change
git push --force origin "origin/dev:refs/heads/$MERGE_BRANCH"
git push --force-with-lease 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
31 changes: 21 additions & 10 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- dev
- next
- master
pull_request:
workflow_dispatch:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading