Skip to content
Merged
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
81 changes: 57 additions & 24 deletions .github/workflows/back-merge.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -15,15 +15,27 @@
# 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>-<commit> 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;
# - or the secret RELEASEFLOW_TOKEN, a fine-grained personal access token, which opens it as the token's owner.
# 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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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 }}
Expand All @@ -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

Expand All @@ -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"
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<from>-<commit>`, 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
Expand Down Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions ReleaseFlow/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 / <repository>`.
- 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/<from>-<commit>`; by default, the main branch into develop, as before.

## [1.0.1] - 2026-10-20

Expand Down
23 changes: 20 additions & 3 deletions ReleaseFlow/test/back_merge.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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")
Expand All @@ -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
Loading