Skip to content

RFC: Automatic Branching Model #1710

Open

Description

@alganet

RFC: Automatic Branching Model

Summary

This RFC proposes a branching model and set of GitHub Actions workflows that
automate version management, forward-porting, backporting, and releases for
the Respect/Validation library. The goal is to keep multiple minor versions
maintainable at once with minimal manual effort.

Motivation

The project already maintains long-lived branches for each minor version
(3.0, 3.1, …) alongside main. Today, propagating fixes across versions,
creating releases, and keeping main in sync are all manual tasks. As the
number of maintained versions grows, this becomes error-prone and
time-consuming.

An automated branching model would:

  • Let contributors target a single branch without worrying about propagation.
  • Let maintainers backport and forward-port with a label click.
  • Keep main automatically in sync with the latest released version.
  • Make patch releases a one-click operation.
  • Enforce a clear, documented lifecycle for every version branch.

Branching Layout

main  ← always mirrors the latest version branch (read-only sync)
 │
 ├── 3.2  ← latest version branch (active development)
 ├── 3.1  ← previous version (maintained, receives backports)
 ├── 3.0  ← older version (maintained, receives backports)
 └── 2.4  ← legacy version (security fixes only)

Branch roles

Branch Purpose Accepts PRs Automated
main Mirror of latest version branch No Yes
X.Y (latest) Active development for next patch Yes —
X.Y (older) Maintained version, backports only Via bot Yes
X.Y (EOL) No longer maintained No —

Rules

  1. main is read-only. It is force-updated automatically whenever
    the latest version branch receives a push.
  2. All PRs target a version branch, never main.
  3. Contributors target the oldest applicable branch. A bug fix that affects
    3.0, 3.1, and 3.2 should be opened against 3.0. A new feature
    should target the latest version branch.
  4. Forward-porting is automatic. After a commit lands on an older branch,
    the change is cherry-picked upward to every newer maintained branch.
  5. Backporting is label-driven. When a PR merged into a newer branch also
    needs to land on an older one, a maintainer adds a backport:X.Y label
    and the bot creates a cherry-pick PR.
  6. Rebase only. All branches maintain a linear history. PRs are integrated
    via "Rebase and merge" on GitHub. No merge commits are created anywhere in
    the tree.
  7. Major version bumps are manual. This model automates minor and patch
    version management only. Starting a new major version (e.g. 4.0) is a
    rare, high-stakes event that should be handled manually by maintainers,
    including branch creation, CI configuration, documentation updates, and
    migration guides.

Branch Protection

All version branches and main should have branch protection rules enabled:

Rule Version branches main
Require PR reviews Yes No (mirror)
Require status checks to pass Yes No (mirror)
Require linear history Yes Yes
Restrict who can push Yes Yes

TheRespectPanda bot (PANDA_GITHUB_PAT) must be added to the branch
protection bypass list
on every protected branch. The bot needs to push
directly for:

  • Forward-port cherry-picks (clean cases that skip PR creation).
  • Syncing main to the latest version branch.
  • Creating new version branches.
  • Tagging releases.

Without this bypass, every automated push would be blocked by protection
rules, defeating the purpose of the automation.

Workflows

1. Forward-Port Cascade (forward-port.yml)

Trigger: Push to any maintained version branch.

When a commit lands on an older branch (e.g. 3.0), the workflow
cherry-picks the new commits into every newer branch in order:

3.0 → 3.1 → 3.2

Cherry-picking (rather than merging) preserves the linear, rebase-only
history that this project requires. If the cherry-pick is clean, the commits
are pushed directly. If there are conflicts, a branch is created and a draft
PR is opened for manual resolution.

name: Forward-Port Cascade

on:
  push:
    branches: ['[0-9]+.[0-9]+']

jobs:
  forward-port:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Determine newer branches
        id: branches
        run: # ...

      - name: Cherry-pick to newer branches
        env:
          GH_TOKEN: ${{ secrets.PANDA_GITHUB_PAT }}
        run: # ...

2. Backport Bot (backport.yml)

Trigger: A merged PR receives a backport:X.Y label.

Creates a cherry-pick PR targeting the specified older branch. The resulting
PR will be rebased on merge, maintaining linear history.

name: Backport

on:
  pull_request:
    types: [closed, labeled]

jobs:
  backport:
    if: >
      github.event.pull_request.merged == true &&
      contains(join(github.event.pull_request.labels.*.name, ','), 'backport:')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Cherry-pick to target branches
        env:
          GH_TOKEN: ${{ secrets.PANDA_GITHUB_PAT }}
        run: # ...

3. Sync Main (sync-main.yml)

Trigger: Push to the latest version branch.

Keeps main identical to the latest version branch by force-updating it.
Since main is a read-only mirror, this is always safe, no work happens on
main directly.

name: Sync main

on:
  push:
    branches: ['[0-9]+.[0-9]+']

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Sync main to latest version branch
        env:
          GH_TOKEN: ${{ secrets.PANDA_GITHUB_PAT }}
        run: # ...

4. Release (release.yml, revised)

Trigger: Manual dispatch from a version branch, specifying the bump level.

Replaces the current tag-and-pray approach. A maintainer triggers the workflow
from the target branch, selects patch (or minor for a new version line),
and the workflow:

  1. Computes the next tag from the branch's existing tags.
  2. Creates and pushes the tag.
  3. Generates a changelog from commits since the last tag.
  4. Publishes a GitHub Release with the changelog.
name: Release

on:
  workflow_dispatch:
    inputs:
      bump:
        description: 'Version bump level'
        required: true
        type: choice
        options: [patch, minor]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Compute next version
        id: version
        run: # ...

      - name: Create tag
        run: # ...

      - name: Generate changelog
        id: changelog
        run: # ...

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: # ...

5. New Version Branch (new-version.yml)

Trigger: Manual dispatch specifying the new minor version.

This workflow handles minor version branches only (e.g. 3.2, 3.3).
Major version bumps (e.g. 4.0) are intentionally excluded and must be
performed manually (see Rule 7).

When it's time to start a new minor version (e.g. 3.2), this workflow:

  1. Creates the new branch from the current latest version branch.
  2. Force-updates the main mirror to point at the new branch.
  3. Creates the corresponding backport:X.Y label for the previous branch.
  4. Updates the LAST_MINOR_VERSION repository variable.
name: New Version Branch

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'New minor version (e.g. 3.2)'
        required: true
        type: string

jobs:
  create:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Validate input is a minor bump
        run: # ...

      - name: Create branch and update labels
        env:
          GH_TOKEN: ${{ secrets.PANDA_GITHUB_PAT }}
        run: # ...

6. Regional Updates (update-regionals.yml, revised)

The existing workflow already targets LAST_MINOR_VERSION. Under this model,
that variable is updated automatically by the New Version Branch workflow,
so the regional update workflow needs no structural change, only a switch
from secrets.LAST_MINOR_VERSION to vars.LAST_MINOR_VERSION (repository
variables are the correct primitive for non-secret configuration).

The forward-port cascade will then propagate regional updates from the latest
branch to all newer branches automatically. The sync-main workflow will
update main once the latest branch receives the change.

7. TheRespectPanda Commands (extended)

Add new commands to the existing panda.yml:

Command Action
@TheRespectPanda backport X.Y Adds backport:X.Y label to the PR
@TheRespectPanda release patch Triggers release workflow on the PR branch

This lets maintainers drive backports and releases from PR comments without
leaving the conversation.

Contribution Flow

Bug fix (affects multiple versions)

1. Contributor opens PR against the oldest affected branch (e.g. 3.0)
2. CI runs on 3.0
3. Maintainer reviews and rebases the PR
4. forward-port.yml cherry-picks 3.0 → 3.1 → 3.2 automatically
5. sync-main.yml force-updates main from 3.2

Bug fix (affects only latest)

1. Contributor opens PR against the latest version branch (e.g. 3.2)
2. CI runs on 3.2
3. Maintainer reviews and rebases the PR
4. sync-main.yml force-updates main

New feature

1. Contributor opens PR against the latest version branch (e.g. 3.2)
2. CI runs on 3.2
3. Maintainer reviews and rebases the PR
4. sync-main.yml force-updates main

Backport after merge

1. A fix is rebased into 3.2
2. Maintainer adds `backport:3.1` label (or comments @TheRespectPanda backport 3.1)
3. backport.yml cherry-picks and opens a PR against 3.1
4. Maintainer reviews and rebases the backport PR
5. After rebase, forward-port cascade runs 3.1 → 3.2 (usually a no-op)

Patch release

1. Maintainer goes to Actions → Release → Run workflow
2. Selects the target branch (e.g. 3.1) and bump level (patch)
3. Workflow tags 3.1.1, generates changelog, publishes GitHub Release

Starting a new minor version

1. Maintainer goes to Actions → New Version Branch → Run workflow
2. Enters "3.3"
3. Workflow creates 3.3 from 3.2, force-updates main, creates backport:3.2 label

Starting a new major version

This is intentionally manual. Maintainers must:
1. Create the new branch (e.g. 4.0) manually
2. Update .github/maintained-versions.json
3. Update branch protection rules to cover the new branch
4. Add TheRespectPanda to the bypass list on the new branch
5. Create backport labels as needed
6. Update CI matrix if the new major version changes PHP requirements
7. Write migration guides and update documentation

Version Lifecycle

Each version branch moves through these phases:

Phase Receives Duration
Active Features + fixes Until next minor ships
Maintained Bug fixes only Until next-next minor ships
Security Security fixes only Discretion of maintainers
EOL Nothing Branch archived

Example timeline:

3.0  Active → Maintained (when 3.1 ships) → Security (when 3.2 ships) → EOL (when 3.3 ships)
3.1  Active → Maintained (when 3.2 ships) → Security (when 3.3 ships) → ...
3.2  Active → ...

To mark a branch as EOL, a maintainer removes it from the maintained set by
updating .github/maintained-versions.json. The forward-port workflow skips
branches not listed in the manifest.

Configuration

All automation relies on a single source of truth for which branches are
currently maintained. This can be a JSON file committed to the repository:

// .github/maintained-versions.json
{
  "versions": ["3.0", "3.1", "3.2"],
  "latest": "3.2",
  "security-only": ["2.4"]
}

All workflows read this file to determine:

  • Which branches to forward-port into.
  • Which branches accept backport labels.
  • Which branch main mirrors.
  • Which branch receives regional updates.

This replaces the current LAST_MINOR_VERSION secret/variable with a
versioned, auditable, reviewable configuration file.

Proposed Changes to CONTRIBUTING.md

The following sections should be added or updated in CONTRIBUTING.md to
reflect the new branching model.

Add: "Choosing a Target Branch" section (after the opening paragraphs)

## Choosing a Target Branch

This project maintains multiple versions simultaneously. Which branch you
target depends on the nature of your change:

- **Bug fixes**: open your PR against the **oldest affected version branch**
  (e.g. `3.0` if the bug exists in 3.0, 3.1, and 3.2). Automation will
  forward-port the fix to all newer branches after it is merged.
- **New features**: open your PR against the **latest version branch** (check
  which branch `main` currently mirrors: that is always the latest).
- **Documentation fixes**: follow the same rule as bug fixes.

Never open a PR against `main` directly. It is a read-only mirror of the
latest version branch and does not accept PRs.

If you are unsure which branch to target, open your PR against the latest
version branch and note in the description which older versions are affected.
A maintainer will help you retarget if needed.

Add: "Backports" section (after "Choosing a Target Branch")

## Backports

If your fix was merged into a newer branch but also needs to land on an older
version, a maintainer will add a `backport:X.Y` label to your PR. This
triggers an automatic cherry-pick. You do not need to open separate PRs for
each version, the automation handles it.

If the automatic cherry-pick fails due to conflicts, a maintainer will either
resolve the conflicts or ask for your help.

Migration Plan

  1. Create .github/maintained-versions.json with current branches (3.0,
    3.1; latest: 3.1).
  2. Add forward-port.yml and backport.yml workflows.
  3. Add sync-main.yml workflow.
  4. Replace release.yml with the dispatch-based version.
  5. Add new-version.yml workflow.
  6. Update update-regionals.yml to read from the manifest instead of a
    secret.
  7. Extend panda.yml with backport and release commands.
  8. Create backport:x.x labels.
  9. Update CONTRIBUTING.md with the proposed changes above.
  10. Configure branch protection on all version branches and main:
    • Enable "Require linear history" on all protected branches.
    • Add TheRespectPanda to the bypass list on every protected branch.
  11. Set GitHub merge button to allow only "Rebase and merge" (disable
    "Create a merge commit" and "Squash and merge" in repo settings).

Each step can be delivered as an independent PR, merged in order.

Open Questions

  1. Auto-push for clean forward-ports. Should clean forward-port
    cherry-picks be pushed directly (current proposal), or should they always
    open a PR for CI to run first? Direct push is faster but skips CI on the
    target branch. Opening a PR every time is safer but noisier. A middle
    ground: push directly but trigger CI via a separate workflow_run event,
    reverting automatically if it fails.

  2. Forward-port for multi-commit pushes. When multiple commits are pushed
    at once to an older branch (e.g. a force-push after rebase), the
    cherry-pick range before..after may not map cleanly. The workflow may
    need to compare trees rather than replaying individual commits.

Activity

  1. changed the title [-]RFC: Branching Model Updates[/-] [+]RFC: Automatic Branching Model[/+] on Mar 4, 2026
  2. alganet commented on Mar 4, 2026

    @alganet
    MemberAuthor

    @Respect/troubleshooters @Respect/core everyone is invited to discuss (that is always true, but even more important for RFCs!)

  3. henriquemoody commented on Mar 4, 2026

    @henriquemoody
    Member

    Thanks for putting this together, we've been talking about the branching models for a while now.

    If I understood you correctly, the problem this RFC is trying to solve is to reduce the manual labor of dealing with multiple patch versions. Is that really correct?

    Personally, I don't think we should keep multiple patch versions at all, and just the last one, unless we determine that we'll have long lived versions and also define how long is that -- which I would try to avoid as well. I'm in favor of adding patches only to the last version, and fix forward.

    It's good we're talking about this, though, because I assumed that our policy was:

    • Do we have a fix? PATCH version
    • Do we have a new feature? MINOR version
    • Do we break backwards compatibility? MAJOR version

    If we move towards the direction of the RFC, we'll indeed handle multiple PATCH versions, and I'm not in favor of that because even with that automation, maintainability will be harder; even with the cherry-pick there's not guarantee we won't have to do manual work because of conflicts.

    Apart from that, I have to say I'm not a big fan of merge commits, but I think in the case that we're integrating changes from other branches, I think it's really fair (and maybe better) that we have that explicit in the git tree, so a merge commit doesn't sound bad to me.

    Something I'm not sure is about the main branch being force-pushed. Maybe I missed some part of the RFC, but it seems like we're not using it for anything. If it is for us to create the new MAJOR version, we could just create it from the last MINOR version. It doesn't seem like the main branch is necessary in the world you describe. It's necessary on how we're operating now (based on the assumption I described before), but not in this RFC.

  4. alganet commented on Mar 4, 2026

    @alganet
    MemberAuthor

    If I understood you correctly, the problem this RFC is trying to solve is to reduce the manual labor of dealing with multiple patch versions. Is that really correct?

    The goal is to automate. Handling multiple patch versions is what we already do.

    Even if we stick to just the latest minor version, there is still stuff to automate:

    • Releases from the CI instead of tagging from dev machine.
    • Setting up new branches when a new minor version is spawned.
    • Backporting between majors.

    maintainability will be harder; even with the cherry-pick there's not guarantee we won't have to do manual work because of conflicts.

    That's very unlikely if we follow linear history best practices and semantic versioning. It's likely that composer.lock will get some conflicts, but that's also easy to automate.

    This RFC makes this choice easier. If there's anything of value in keeping multiple versions, the burden is lessened by the automations.

    Personally, I also prefer keeping just the latest minor version and instantly EOLing the previous ones.

    Apart from that, I have to say I'm not a big fan of merge commits, but I think in the case that we're integrating changes from other branches, I think it's really fair (and maybe better) that we have that explicit in the git tree

    What do we gain from it? If it's informational only, the tagging should be enough.

    Something I'm not sure is about the main branch being force-pushed [...]. It doesn't seem like the main branch is necessary in the world you describe.

    In this RFC the main is read-only and a token, useless branch. Stuff breaks when you don't have master or main, because many systems assume all repositories will have them. We don't actually need it.


    Please note that backporting stuff from majors (such as dependency and regional updates) is likely to be necessary.

    Right now, 2.x is our most popular version. It will take a while for 3.x to catch on. We changed a lot from 2.x to 3.x and right now most stuff is completely incompatible.

    However, I would prefer if next major versions were less drastic, and backporting would be possible to ease migration efforts for our users. Some automations for that might be handy.

    The kinds of backports are mostly bugfixes (like we did on DateTime) and regional data.


    If we settle on not keeping multiple simultaneous minor branches, I can update the RFC to include only the remaining automations.

  5. henriquemoody commented on Mar 10, 2026

    @henriquemoody
    Member

    What do we gain from it? If it's informational only, the tagging should be enough.

    It's not just informational — it's about traceability that tagging alone can't provide. Consider these two commits:

    Same change, but after cherry-picking, git tag --contains shows them as completely independent. That's a real cost. git cherry-pick -x appends the original hash, but it's metadata in the commit message, not something Git understands structurally. git log --ancestry-path, git merge-base, and similar tools still treat them as unrelated. The "Require linear history" rule in the RFC is what forces cherry-picks over merges, so I think we should be explicit: is linear history worth that cost?

    The goal is to automate. Handling multiple patch versions is what we already do.

    I understand, and I accept that 2.x still being the most popular version makes backporting a real need right now, not a hypothetical. My concern isn't the automation itself — it's that the RFC's lifecycle model
    (Active → Maintained → Security → EOL) implies we know roughly when each phase transitions. "Maintained until next-next minor ships" only works if we have a predictable shipping cadence, which we don't, and I
    don't think we should add one. I think the RFC should be explicit that EOL is at maintainer discretion, and document what criteria maintainers should use to make that call.

  6. alganet commented on Mar 10, 2026

    @alganet
    MemberAuthor

    How would git tag --contains treat a merge commit? Wouldn't that have a completely different hash too?


    I think the RFC should be explicit that EOL is at maintainer discretion, and document what criteria maintainers should use to make that call.

    The "Active -> Maintained -> Security -> EOL" is portrayed as an example timeline. It's non-canonical. Just an example on a hypothetical thing that could happen.

    The RFC scope is about automating version management. It doesn't enforce any particular timeline or cadence. I mentioned that *personally, I prefer EOLing the previous minor once the new one goes live, but that's also non-authoratitative (not my call to make alone).

    What versions are in which state is manually kept by maintained-versions.json. No automation to change it, always human and intentional.

    I want to give us tools to manage versions more easily, not decide how we should keep them.

  7. henriquemoody commented on Mar 11, 2026

    @henriquemoody
    Member

    The RFC scope is about automating version management. It doesn't enforce any particular timeline or cadence

    Agreed, and we're aligned on EOLing the previous minor once the new one goes live. If you're fine with that, we can make that decision now.

    How would git tag --contains treat a merge commit? Wouldn't that have a completely different hash too?

    A merge commit gets a new hash, yes, but the original commits don't, and more importantly, they remain reachable via the merge commit's parent pointers. That's what git merge-base, git log --ancestry-path, and others rely on. Cherry-pick breaks that: the original commit is unreachable from the target branch, so Git treats the two copies as completely unrelated.

    The RFC's own "linear history" rule means clean forward-ports could be fast-forward pushes (git merge --ff) instead of cherry-picks (git cherry-pick). Fast-forward moves the branch pointer without creating any new commits, so hashes are fully preserved, the graph stays connected, and main never needs to be force-pushed as it would simply fast-forward like any other branch. The only caveat is that if branches diverge at any point (which will probably often happen), fast-forward won't be possible and you're back to a merge commit.


    I'm super on board with the rest, though. The maintained-versions.json as the source of truth is clean, and automating releases is long overdue! My only remaining concern is the cherry-pick approach for forward-ports, for the reasons above. I guess, if our next major version changes the code too drastically, we would have to use cherry-picks anyways again, so perhaps the merging strategy could also be included on maintained-versions.json, but I would rather that we avoid using the cherry-pick strategy whenever we can.

  8. alganet commented on Mar 11, 2026

    @alganet
    MemberAuthor

    The only caveat is that if branches diverge at any point (which will probably often happen), fast-forward won't be possible and you're back to a merge commit.

    In practice, it means we will always have merge commits in the history. We have lots of generated content, which almost guarantees that divergence will happen. Maybe I'm missing something here, I'm not super familiar with multi-version merge workflows.

    I agree that force-pushing main is not ideal. We actually don't need that if we abandon the idea of a read-only mirror and use it as read-write (for latest maintaned minor).

    What can we automate right now that doesn't touch these decisions? I'm honestly not comfortable automating merge stuff (I wouldn't even know how to do it), but I can work on the other aspects and perhaps you can contribute the forward automation part.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions