From d1511e2ebb345334d1451dfb47703adb69534740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20O=E2=80=99Mara?= Date: Tue, 20 Oct 2026 01:04:05 +1100 Subject: [PATCH] Back-merge between any two branches The back-merge's inputs `from` and `into` bring any branch into any other by pull request, such as a maintenance branch into the main branch, on pushes to that branch; they default to the main branch into develop, as before. From the main branch, the pull request's branch is still back-merge/vX.Y.Z; from any other, back-merge/-, keeping the prefix that ReleaseFlow's changelog-entry exemption recognises. --- .github/workflows/back-merge.yml | 81 ++++++++++++++++++++++---------- README.md | 23 +++++++++ ReleaseFlow/CHANGELOG.md | 1 + ReleaseFlow/test/back_merge.jl | 23 +++++++-- 4 files changed, 101 insertions(+), 27 deletions(-) diff --git a/.github/workflows/back-merge.yml b/.github/workflows/back-merge.yml index 9f67f40..c7a4fa4 100644 --- a/.github/workflows/back-merge.yml +++ b/.github/workflows/back-merge.yml @@ -1,5 +1,5 @@ -# Back-merge, for gitflow: once a release or hotfix merges into the main branch, open the pull request that brings it back into develop. -# Called from the repository's own workflow, on pushes to the main branch: +# Back-merge: once a branch moves, open the pull request that brings it into another; for gitflow, by default, once a release or hotfix merges into the main branch, the pull request that brings it back into develop. +# Called from the repository's own workflow, on pushes to the branch that it brings, under gitflow the main branch: # # on: # push: @@ -15,7 +15,19 @@ # RELEASEFLOW_APP_CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} # RELEASEFLOW_APP_PRIVATE_KEY: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} # -# The pull request comes from a branch back-merge/vX.Y.Z at the main branch's commit, so that a conflict with develop is resolved on that branch, by merging develop into it, without pushing to the main branch. +# With `from` and `into`, it brings any branch into any other, such as a maintenance branch into the main branch, on pushes to the maintenance branch: +# +# on: +# push: +# branches: [release-1.x] +# jobs: +# back-merge: +# uses: BioJulia/Workflows/.github/workflows/back-merge.yml@v1 +# with: +# from: release-1.x +# into: master +# +# The pull request comes from a branch at the commit that it brings: back-merge/vX.Y.Z from the main branch, named after its release, and back-merge/- from any other, so that a conflict is resolved on that branch, by merging the branch that it goes into into it, without pushing to either. # Pull requests opened with the default GITHUB_TOKEN don't start workflows, so their required checks would never report. # Another token avoids that, with read and write access to pull requests, while the job's own token creates the branch; where GitHub refuses the job's token that branch, because its workflow files differ, the other token creates it, and then needs read and write access to contents and workflows too: # - a GitHub App's, created for each run from the secrets RELEASEFLOW_APP_CLIENT_ID and RELEASEFLOW_APP_PRIVATE_KEY, which opens the pull request as the app's bot; @@ -23,7 +35,7 @@ # Without either, the default token opens the pull request, which says to close and reopen it, so that the checks run. # Either way, the repository must allow GitHub Actions to create pull requests, in Settings → Actions → General. # -# The job runs in the deployment environment `environment`, `releaseflow` unless given: keep the secrets there, as environment secrets, and limit the environment to the main branch, so that a workflow on any other branch can't read them. +# The job runs in the deployment environment `environment`, `releaseflow` unless given: keep the secrets there, as environment secrets, and limit the environment to the branches that it brings, so that a workflow on any other branch can't read them. name: "Reusable: Back-merge" on: @@ -41,8 +53,16 @@ on: description: The package's directory, relative to the repository's root, whose Project.toml gives the version, for a package in a subdirectory. type: string default: '' + from: + description: The branch to bring; the main branch unless given. + type: string + default: '' + into: + description: The branch to bring it into; the develop branch unless given. + type: string + default: '' environment: - description: The deployment environment that holds the secrets, limited to the main branch. + description: The deployment environment that holds the secrets, limited to the branches that it brings. type: string default: releaseflow secrets: @@ -67,14 +87,14 @@ jobs: pull-requests: write # The environment's secrets reach only jobs on the branches that the environment allows; GitHub uses them in place of secrets of the same name that the caller passes. environment: ${{ inputs.environment }} - # One back-merge at a time, so that two pushes to the main branch don't race to open the same pull request. + # One back-merge at a time between two branches, so that two pushes don't race to open the same pull request. concurrency: - group: back-merge-${{ github.repository }} + group: back-merge-${{ github.repository }}-${{ inputs.from || inputs.main-branch }}-${{ inputs.into || inputs.develop-branch }} cancel-in-progress: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.main-branch }} + ref: ${{ inputs.from || inputs.main-branch }} fetch-depth: 0 # Secrets can't be tested in a step's condition, so this step tells whether a GitHub App is given. - id: app-given @@ -88,7 +108,7 @@ jobs: with: client-id: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} - - name: Open the pull request from the main branch into develop + - name: Open the pull request that brings one branch into another env: # The pull request's token: the app's, or the personal one, or the default one, whose pull requests start no workflows. GH_TOKEN: ${{ steps.app.outputs.token || secrets.RELEASEFLOW_TOKEN || github.token }} @@ -99,20 +119,33 @@ jobs: RELEASEFLOW_MAIN_BRANCH: ${{ inputs.main-branch }} RELEASEFLOW_DEVELOP_BRANCH: ${{ inputs.develop-branch }} RELEASEFLOW_PROJECT: ${{ inputs.project }} + FROM: ${{ inputs.from }} + INTO: ${{ inputs.into }} run: | set -euo pipefail - project_file="${RELEASEFLOW_PROJECT:+${RELEASEFLOW_PROJECT%/}/}Project.toml" - version=$(sed -n 's/^version = "\(.*\)"$/\1/p' "$project_file") - if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error title=Back-merge::$project_file on $RELEASEFLOW_MAIN_BRANCH has no version of the form X.Y.Z, so the back-merge branch can't be named." - exit 1 - fi - branch="back-merge/v$version" + from="${FROM:-$RELEASEFLOW_MAIN_BRANCH}" + into="${INTO:-$RELEASEFLOW_DEVELOP_BRANCH}" sha=$(git rev-parse HEAD) + # From the main branch, the branch is named after the release, whose version Project.toml gives; from any other, after that branch and its commit. + if [ "$from" = "$RELEASEFLOW_MAIN_BRANCH" ]; then + project_file="${RELEASEFLOW_PROJECT:+${RELEASEFLOW_PROJECT%/}/}Project.toml" + version=$(sed -n 's/^version = "\(.*\)"$/\1/p' "$project_file") + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error title=Back-merge::$project_file on $from has no version of the form X.Y.Z, so the back-merge branch can't be named." + exit 1 + fi + branch="back-merge/v$version" + title="Merge $from (v$version) into $into" + reason="after the release or hotfix of v$version, as gitflow does" + else + branch="back-merge/${from//\//-}-${sha:0:7}" + title="Merge $from into $into" + reason="at its commit ${sha:0:7}" + fi - # Nothing to bring back if develop already has the main branch's commit. - if git merge-base --is-ancestor "$sha" "origin/$RELEASEFLOW_DEVELOP_BRANCH"; then - echo "$RELEASEFLOW_DEVELOP_BRANCH already has $RELEASEFLOW_MAIN_BRANCH's commit $sha; there's nothing to bring back." + # Nothing to bring if the other branch already has the commit. + if git merge-base --is-ancestor "$sha" "origin/$into"; then + echo "$into already has $from's commit $sha; there's nothing to bring." exit 0 fi @@ -134,17 +167,17 @@ jobs: exit 1 fi - open=$(gh pr list --repo "$REPOSITORY" --head "$branch" --base "$RELEASEFLOW_DEVELOP_BRANCH" --state open --json number --jq length) + open=$(gh pr list --repo "$REPOSITORY" --head "$branch" --base "$into" --state open --json number --jq length) if [ "$open" != "0" ]; then - echo "A pull request from $branch into $RELEASEFLOW_DEVELOP_BRANCH is open already." + echo "A pull request from $branch into $into is open already." exit 0 fi - body="This brings $RELEASEFLOW_MAIN_BRANCH back into $RELEASEFLOW_DEVELOP_BRANCH after the release or hotfix of v$version, as gitflow does. - If it conflicts with $RELEASEFLOW_DEVELOP_BRANCH, merge $RELEASEFLOW_DEVELOP_BRANCH into $branch, resolve the conflicts there, and push." + body="This brings $from into $into $reason. + If it conflicts with $into, merge $into into $branch, resolve the conflicts there, and push." if [ "$HAS_TOKEN" != "true" ]; then body="$body It was opened with the default token, which doesn't start workflows, so close and reopen it to run the checks; a GitHub App or the secret RELEASEFLOW_TOKEN avoids this." fi - gh pr create --repo "$REPOSITORY" --head "$branch" --base "$RELEASEFLOW_DEVELOP_BRANCH" --title "Merge $RELEASEFLOW_MAIN_BRANCH (v$version) into $RELEASEFLOW_DEVELOP_BRANCH" --body "$body" + gh pr create --repo "$REPOSITORY" --head "$branch" --base "$into" --title "$title" --body "$body" diff --git a/README.md b/README.md index 4059adc..e0a7f27 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,27 @@ jobs: RELEASEFLOW_TOKEN: ${{ secrets.RELEASEFLOW_TOKEN }} ``` +With `from` and `into`, the back-merge brings any branch into any other, such as a maintenance branch into the main branch, from a caller that runs on pushes to the maintenance branch: + +```yaml +on: + push: + branches: [release-1.x] +jobs: + back-merge: + uses: BioJulia/Workflows/.github/workflows/back-merge.yml@v1 + permissions: + contents: write + pull-requests: write + with: + from: release-1.x + into: master +``` + +From the main branch, the pull request's branch is `back-merge/vX.Y.Z`, named after the release; from any other, `back-merge/-`, such as `back-merge/release-1.x-abc1234`. +The `releaseflow` environment then also allows the branch that it brings. +Under gitflow, ReleaseFlow's source branch check allows a `back-merge/*` branch only into develop, so a merge into the main branch suits a repository that doesn't require the gitflow checks there. + And the dependents' tests, in `.github/workflows/Dependents.yml`, which run the tests of the packages that depend on this one with the pull request's branch in its place: ```yaml @@ -142,6 +163,8 @@ A package can follow the major tag, `v1`, or pin a release, such as `v1.0.0`; De |:--|:--|:--|:--| | `main-branch` | `main` | all | The branch of releases. | | `develop-branch` | `develop` | gitflow, back-merge | The branch of unreleased work. | +| `from` | the main branch | back-merge | The branch to bring. | +| `into` | the develop branch | back-merge | The branch to bring it into. | | `skip-label` | `skip changelog` | gitflow, oneflow, trunk | The label that exempts a pull request from the changelog entry. | | `check-links` | `true` | gitflow, oneflow, trunk | Whether to check the changelog's comparison links. | | `require-entry` | `true` | trunk | Whether a pull request that isn't a release must add a changelog entry, for a changelog without an unreleased section. | diff --git a/ReleaseFlow/CHANGELOG.md b/ReleaseFlow/CHANGELOG.md index 91e0bc8..54d997b 100644 --- a/ReleaseFlow/CHANGELOG.md +++ b/ReleaseFlow/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - The reusable workflow `tag.yml`, which moves each major tag, such as `v1`, to the newest release of its major version, `vX.Y.Z`, whichever made the tag, TagBot or a maintainer, and whenever, for a repository that callers follow by its major tag; it never moves a major tag backwards, leaves pre-releases out, and reports what it did for each major tag as a notice on the run and a line of the job's summary. - This repository moves `v1` with it, through `Self-Tag.yml`, once TagBot tags a release `vX.Y.Z`, with `tag_prefix: NO_PREFIX`, in place of `ReleaseFlow-vX.Y.Z`, and with the ReleaseFlow app's token, which the ruleset on `v*` tags lets through; so the workflows' callers get a release once ReleaseFlow's is registered. - The reusable workflow `dependents.yml`, which runs the tests of the packages that depend on this one, each checked out at its default branch, with the pull request's branch in its place, as the checks `dependents / `. +- The back-merge's inputs `from` and `into`, which bring any branch into any other, such as a maintenance branch into the main branch, from a branch `back-merge/-`; by default, the main branch into develop, as before. ## [1.0.1] - 2026-10-20 diff --git a/ReleaseFlow/test/back_merge.jl b/ReleaseFlow/test/back_merge.jl index 75d92e7..3e6b691 100644 --- a/ReleaseFlow/test/back_merge.jl +++ b/ReleaseFlow/test/back_merge.jl @@ -48,12 +48,12 @@ end git(work, "fetch", "-q", "origin") log = joinpath(directory, "log") - function step(; has_token = "true", open = "0", project = "", refuse_default = false) + function step(; has_token = "true", open = "0", project = "", refuse_default = false, from = "", into = "") write(log, "") environment = Dict( "PATH" => "$(bin):$(ENV["PATH"])", "LOG" => log, "ORIGIN" => origin, "GH_TOKEN" => has_token == "true" ? "token" : "default", "HAS_TOKEN" => has_token, "DEFAULT_TOKEN" => "default", "REFUSE_DEFAULT" => refuse_default ? "1" : "", - "REPOSITORY" => "Example/Toy.jl", "RELEASEFLOW_MAIN_BRANCH" => "master", "RELEASEFLOW_DEVELOP_BRANCH" => "develop", "OPEN_PRS" => open, "RELEASEFLOW_PROJECT" => project, + "REPOSITORY" => "Example/Toy.jl", "RELEASEFLOW_MAIN_BRANCH" => "master", "RELEASEFLOW_DEVELOP_BRANCH" => "develop", "OPEN_PRS" => open, "RELEASEFLOW_PROJECT" => project, "FROM" => from, "INTO" => into, ) output = IOBuffer() process = run(pipeline(Cmd(addenv(`bash $(script)`, environment); dir = work, ignorestatus = true); stdout = output, stderr = output)) @@ -93,7 +93,7 @@ end branch!(work, "master", "master") git(work, "fetch", "-q", "origin") result = step() - @test result.code == 0 && occursin("there's nothing to bring back", result.output) && isempty(result.calls) + @test result.code == 0 && occursin("there's nothing to bring", result.output) && isempty(result.calls) # A package in a subdirectory names the branch from its own Project.toml. commit!(work, "Subdirectory", "lib/Toy/Project.toml" => project("0.3.0"), "subdirectory.txt" => "x") git(work, "push", "-q", "origin", "master") @@ -104,4 +104,21 @@ end commit!(work, "Bad version", "Project.toml" => replace(project("0.1.0"), "0.1.0" => "0.1")) result = step() @test result.code == 1 && occursin("Project.toml on master has no version of the form X.Y.Z", result.output) + # From any other branch, such as a maintenance branch into master, the branch is named after the branch and its commit. + branch!(work, "maintenance/1.x", "origin/master") + maintenance = commit!(work, "A fix for 1.x", "maintenance.txt" => "fix") + git(work, "push", "-q", "origin", "maintenance/1.x") + git(work, "fetch", "-q", "origin") + result = step(; from = "maintenance/1.x", into = "master") + branch = "back-merge/maintenance-1.x-$(first(maintenance, 7))" + @test result.code == 0 + @test git(origin, "rev-parse", "refs/heads/$(branch)") == maintenance + create = only(filter(call -> startswith(call, "gh pr create"), result.calls)) + @test occursin("--head $(branch) --base master --title Merge maintenance/1.x into master", create) + @test occursin("This brings maintenance/1.x into master at its commit $(first(maintenance, 7)).", read(log, String)) + # Nothing to bring once master has the commit. + git(work, "push", "-q", "origin", "maintenance/1.x:master") + git(work, "fetch", "-q", "origin") + result = step(; from = "maintenance/1.x", into = "master") + @test result.code == 0 && occursin("master already has maintenance/1.x's commit", result.output) && isempty(result.calls) end