Repository navigation
Add next branch workflow (RFD #142) #4112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
|
||
| 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])) | ||
| 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
|
||
|
|
||
| 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.`); | ||
| } | ||
| 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 | ||||||
| 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" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An edge case, but this might be safer:
Suggested change
|
||||||
| 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 | ||||||
There was a problem hiding this comment.
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.