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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Actions include:
2. `deploy-github-pages` - handles publishing documentation to GitHub Pages for both snapshots & releases.
3. `post-release` - assists in merging tagged changes back to the target branch & bumping to the next development version.
4. `export-gradle-properties` - exposes selected Gradle properties as environment variables.
5. `cascade-merge` - merges a branch forward through an explicit ordered list of downstream branches when there are no conflicts.

## Who can use these actions

Expand Down
25 changes: 25 additions & 0 deletions cascade-merge/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
FROM alpine:3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659

RUN apk add --no-cache git bash ca-certificates

COPY *.sh /

ENTRYPOINT ["/entrypoint.sh"]
127 changes: 127 additions & 0 deletions cascade-merge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<!--
SPDX-License-Identifier: Apache-2.0

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

# `cascade-merge` Action

## Purpose

Automatically merges a source branch into the next downstream branch from a caller-provided ordered list of branches.

This action does not create pull requests. It pushes merge commits directly to downstream branches.

It is intended to be generic and reusable across repositories that maintain multiple long-lived branches.

## Requirements

1. Requires `contents: write` so the action can push merged branches.
2. The repository checkout must retain credentials for pushing to `origin`.

## Inputs

* `branch-order` - required comma-separated or newline-separated ordered list of branches. Example: `7.0.x,7.1.x,8.0.x`
* `source-branch` - optional source branch. If omitted, the action uses the current branch ref.

## Behavior

1. Finds `source-branch` in `branch-order`.
2. Merges it into the next branch in the list.
3. Attempts only the next adjacent downstream merge for the current branch.
4. Pushes the target branch when the merge succeeds without conflicts.
5. Exits with an error if that merge conflicts.
6. If the current branch is not listed in `branch-order`, the action exits successfully without doing anything.
7. If the source-only commit set contains a commit message with `[skip merge]`, the action exits with an error and requires a manual merge.

For example, with `7.0.x,7.1.x,8.0.x`:

1. The action attempts `7.0.x -> 7.1.x`.
2. When the workflow file is merged into `7.1.x`, a later run there can then attempt `7.1.x -> 8.0.x`.
3. Each branch only needs to know the full ordering; the action derives the next merge target from the current branch.

## Example Usage

This action can be defined once per maintained branch and then merged forward with the branch itself.

For example, the same workflow file can exist in `7.0.x`, `7.1.x`, and `8.0.x` with the same `branch-order`. When it runs on `7.0.x`, it attempts only `7.0.x -> 7.1.x`. After that workflow file is merged into `7.1.x`, the same definition runs there and attempts `7.1.x -> 8.0.x`. No workflow edits are required per branch as long as the branch names remain in the ordered list.

```yaml
name: "Cascade Merge: next release branch"

on:
workflow_dispatch:
push:
branches:
- 7.0.x
- 7.1.x
- 8.0.x

jobs:
cascade-merge:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: "🔀 Merge current branch into next downstream branch"
uses: apache/grails-github-actions/cascade-merge@asf
with:
branch-order: |
7.0.x
7.1.x
8.0.x
```

Suggested naming so the workflow intent is obvious in GitHub Actions:

* Workflow name: `Cascade Merge: next release branch`
* Step name: `Merge current branch into next downstream branch`

How this reads in practice:

* On `7.0.x`, the workflow effectively means `7.0.x -> 7.1.x`
* On `7.1.x`, the same workflow effectively means `7.1.x -> 8.0.x`
* On `8.0.x`, there is no downstream branch, so the action exits without merging anything
* On any branch not present in `branch-order`, the action exits without merging anything

To block an automatic merge for a specific change, include `[skip merge]` in the commit message. If any commit that would be merged into the downstream branch contains that marker, the action exits with an error and prints that a manual merge is required.

Example commit message:

```text
docs: update release notes formatting [skip merge]
```

That commit remains on the source branch, but `cascade-merge` will stop before merging it forward automatically.

Equivalent compact input:

```yaml
- name: "🔀 Merge current branch into next downstream branch"
uses: apache/grails-github-actions/cascade-merge@asf
with:
branch-order: 7.0.x,7.1.x,8.0.x
```

Explicitly setting the source branch:

```yaml
- name: "🔀 Cascade merge from 7.0.x"
uses: apache/grails-github-actions/cascade-merge@asf
with:
source-branch: 7.0.x
branch-order: 7.0.x,7.1.x,8.0.x
```
31 changes: 31 additions & 0 deletions cascade-merge/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

name: 'cascade-merge'
description: 'Automatically merges a branch forward through an explicit ordered branch list'
inputs:
branch-order:
description: 'Required comma-separated or newline-separated ordered list of branches to merge through'
required: true
source-branch:
description: 'Optional source branch to start from. Defaults to the current branch ref'
required: false
default: ''
runs:
using: 'docker'
image: 'Dockerfile'
env:
BRANCH_ORDER: ${{ inputs.branch-order }}
SOURCE_BRANCH: ${{ inputs.source-branch }}
117 changes: 117 additions & 0 deletions cascade-merge/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/bin/bash

#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#

set -euo pipefail

set_value_or_error() {
local value="$1"
local default_value="$2"
local variable_name="$3"

if [[ -n "$value" ]]; then
decided_value="$value"
elif [[ -n "$default_value" ]]; then
echo "${variable_name}: Using default value: ${default_value}"
decided_value="$default_value"
else
echo "ERROR: A value for ${variable_name} is required." >&2
exit 1
fi

eval "export ${variable_name}=\"\$decided_value\""
}

echo "::group::Setup"
set_value_or_error "${GITHUB_WORKSPACE:-}" "." "GIT_SAFE_DIR"
set_value_or_error "${GIT_USER_NAME:-}" "${GITHUB_ACTOR:-github-actions}" "GIT_USER_NAME"
set_value_or_error "${SOURCE_BRANCH:-}" "${GITHUB_REF#refs/heads/}" "SOURCE_BRANCH"
set_value_or_error "${BRANCH_ORDER:-}" "" "BRANCH_ORDER"

git config --global --add safe.directory "${GIT_SAFE_DIR}"
git config --global user.email "${GIT_USER_NAME}@users.noreply.github.com"
git config --global user.name "${GIT_USER_NAME}"
git fetch origin --prune
echo "Source Branch: ${SOURCE_BRANCH}"
echo "Branch Order: ${BRANCH_ORDER}"
echo "::endgroup::"

normalized_branch_order="$(printf '%s\n' "${BRANCH_ORDER}" | tr ',\n' ' ')"
read -r -a branch_sequence <<< "${normalized_branch_order}"

if [[ ${#branch_sequence[@]} -eq 0 ]]; then
echo "ERROR: BRANCH_ORDER did not contain any branch names." >&2
exit 1
fi

source_index=-1
for i in "${!branch_sequence[@]}"; do
if [[ "${branch_sequence[$i]}" == "${SOURCE_BRANCH}" ]]; then
source_index=$i
break
fi
done

if [[ ${source_index} -lt 0 ]]; then
echo "SOURCE_BRANCH '${SOURCE_BRANCH}' was not found in BRANCH_ORDER. Nothing to merge."
exit 0
fi

if [[ ${source_index} -eq $((${#branch_sequence[@]} - 1)) ]]; then
echo "No downstream branches found after ${SOURCE_BRANCH}. Nothing to merge."
exit 0
fi

source_branch="${branch_sequence[$source_index]}"
target_branch="${branch_sequence[$((source_index + 1))]}"

if git rev-parse --verify --quiet "${source_branch}" >/dev/null; then
source_ref="${source_branch}"
else
source_ref="origin/${source_branch}"
fi

echo "::group::Merge ${source_branch} into ${target_branch}"
git checkout -B "${target_branch}" "origin/${target_branch}"

skip_merge_commits="$(git log --format='%H %s' "origin/${target_branch}..${source_ref}" | grep '\[skip merge\]' || true)"
if [[ -n "${skip_merge_commits}" ]]; then
echo "Commits marked with [skip merge] were found between ${source_branch} and ${target_branch}:" >&2
printf '%s\n' "${skip_merge_commits}" >&2
echo "ERROR: Manual merge required for ${source_branch} -> ${target_branch}." >&2
echo "::endgroup::"
exit 1
fi

if git merge --no-ff --no-edit -m "[skip ci] Merge ${source_branch} into ${target_branch}" "${source_ref}"; then
if git diff --quiet "origin/${target_branch}" HEAD; then
echo "${target_branch} is already up to date with ${source_branch}."
else
git push origin "${target_branch}"
echo "Merged ${source_branch} into ${target_branch}."
fi
else
git merge --abort || true
echo "ERROR: Merge conflict while merging ${source_branch} into ${target_branch}. Resolve manually." >&2
echo "::endgroup::"
exit 1
fi

echo "::endgroup::"
4 changes: 2 additions & 2 deletions deploy-github-pages/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

name: 'Grails github-pages deployment action'
description: 'Copies documentation to the gh-pages branch'
name: 'deploy-github-pages'
description: 'Publishes documentation to the gh-pages branch'
inputs:
token:
description: 'GitHub token to authenticate the requests'
Expand Down
2 changes: 1 addition & 1 deletion export-gradle-properties/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

name: 'Grails export-gradle-properties action'
name: 'export-gradle-properties'
description: 'Exposes Gradle Properties as Environment Variables'
inputs:
file:
Expand Down
4 changes: 2 additions & 2 deletions post-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

name: 'Grails post-release action'
description: 'Performs some actions after doing a release'
name: 'post-release'
description: 'Performs repository updates after a release'
inputs:
token:
description: 'GitHub token to authenticate the requests'
Expand Down
6 changes: 3 additions & 3 deletions pre-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

name: 'Grails pre-release action'
description: 'Performs some actions before doing a release'
name: 'pre-release'
description: 'Performs repository updates before a release'
inputs:
token:
description: 'GitHub token to authenticate the requests'
Expand All @@ -24,4 +24,4 @@ runs:
using: 'docker'
image: 'Dockerfile'
env:
GITHUB_TOKEN: ${{ inputs.token }}
GITHUB_TOKEN: ${{ inputs.token }}
Loading
Loading