diff --git a/README.md b/README.md index d166c0c..3a9cbdc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cascade-merge/Dockerfile b/cascade-merge/Dockerfile new file mode 100644 index 0000000..a8c05e7 --- /dev/null +++ b/cascade-merge/Dockerfile @@ -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"] diff --git a/cascade-merge/README.md b/cascade-merge/README.md new file mode 100644 index 0000000..1685c0b --- /dev/null +++ b/cascade-merge/README.md @@ -0,0 +1,127 @@ + + +# `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 +``` diff --git a/cascade-merge/action.yml b/cascade-merge/action.yml new file mode 100644 index 0000000..d06108d --- /dev/null +++ b/cascade-merge/action.yml @@ -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 }} diff --git a/cascade-merge/entrypoint.sh b/cascade-merge/entrypoint.sh new file mode 100755 index 0000000..4353340 --- /dev/null +++ b/cascade-merge/entrypoint.sh @@ -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::" diff --git a/deploy-github-pages/action.yml b/deploy-github-pages/action.yml index ee7a0aa..0a01b70 100644 --- a/deploy-github-pages/action.yml +++ b/deploy-github-pages/action.yml @@ -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' diff --git a/export-gradle-properties/action.yml b/export-gradle-properties/action.yml index 5e856fc..7dc73d0 100644 --- a/export-gradle-properties/action.yml +++ b/export-gradle-properties/action.yml @@ -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: diff --git a/post-release/action.yml b/post-release/action.yml index c238765..2f4fa0e 100644 --- a/post-release/action.yml +++ b/post-release/action.yml @@ -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' diff --git a/pre-release/action.yml b/pre-release/action.yml index 9eb2a55..958b5f0 100644 --- a/pre-release/action.yml +++ b/pre-release/action.yml @@ -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' @@ -24,4 +24,4 @@ runs: using: 'docker' image: 'Dockerfile' env: - GITHUB_TOKEN: ${{ inputs.token }} \ No newline at end of file + GITHUB_TOKEN: ${{ inputs.token }} diff --git a/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy b/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy new file mode 100644 index 0000000..f46edbe --- /dev/null +++ b/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy @@ -0,0 +1,182 @@ +/* + * 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. + */ +package org.apache.grails.github + +import org.apache.grails.github.mocks.GitHubDockerAction +import org.apache.grails.github.mocks.GitHubRepoMock +import org.apache.grails.github.mocks.GitHubVersion +import org.testcontainers.containers.Network +import org.testcontainers.containers.ContainerLaunchException +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +class CascadeMergeSpec extends Specification { + + @Shared + @AutoCleanup + Network net = Network.newNetwork() + + @AutoCleanup + GitHubDockerAction action + + @AutoCleanup + GitHubRepoMock gitRepo + + def 'success - merges source branch into next downstream branch'() { + given: + GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') + action = new GitHubDockerAction('cascade-merge', release) + + gitRepo = new GitHubRepoMock(action.workspacePath, net) + gitRepo.init() + gitRepo.populateRepository('7.0.0-SNAPSHOT', null, ['7.0.x', '7.1.x', '8.0.x']) + gitRepo.storeFiles(['README.md': '# merged from 7.0.x\n'], '7.0.x') + gitRepo.stageRepositoryForAction('7.0.x', false) + + and: + def env = action.getDefaultEnvironment() + env['BRANCH_ORDER'] = '7.0.x, 7.1.x, 8.0.x' + + and: + action.createContainer(env, net) + + when: + action.runAction() + + then: + action.actionExitCode == 0L + action.getActionGroupLogs('Merge 7.0.x into 7.1.x').contains('Merged 7.0.x into 7.1.x.') + + and: + gitRepo.getFileContents('README.md', '7.1.x') == '# merged from 7.0.x\n' + gitRepo.getFileContents('README.md', '8.0.x') == '# demo\n' + + cleanup: + System.out.println("Container logs:\n${action.actionLogs}" as String) + } + + def 'failure - errors when the next downstream merge conflicts'() { + given: + GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') + action = new GitHubDockerAction('cascade-merge', release) + + gitRepo = new GitHubRepoMock(action.workspacePath, net) + gitRepo.init() + gitRepo.populateRepository('7.0.0-SNAPSHOT', null, ['7.0.x', '7.1.x', '8.0.x']) + gitRepo.storeFiles(['README.md': '# source branch change\n'], '7.0.x') + gitRepo.storeFiles(['README.md': '# conflicting target change\n'], '7.1.x') + gitRepo.stageRepositoryForAction('7.0.x', false) + + and: + def env = action.getDefaultEnvironment() + env['BRANCH_ORDER'] = '7.0.x,7.1.x,8.0.x' + + and: + action.createContainer(env, net) + + when: + action.runAction() + + then: + def e = thrown(ContainerLaunchException) + e.message.contains('Container startup failed') + + and: + action.actionLogs.contains('ERROR: Merge conflict while merging 7.0.x into 7.1.x. Resolve manually.') + + and: + gitRepo.getFileContents('README.md', '7.1.x') == '# conflicting target change\n' + gitRepo.getFileContents('README.md', '8.0.x') == '# demo\n' + + cleanup: + System.out.println("Container logs:\n${action.actionLogs}" as String) + } + + def 'success - does nothing when source branch is not in branch order'() { + given: + GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: 'feature/test', targetVersion: '7.0.0-SNAPSHOT') + action = new GitHubDockerAction('cascade-merge', release) + + gitRepo = new GitHubRepoMock(action.workspacePath, net) + gitRepo.init() + gitRepo.populateRepository('7.0.0-SNAPSHOT', null, ['7.0.x', '7.1.x', '8.0.x', 'feature/test']) + gitRepo.storeFiles(['README.md': '# feature branch\n'], 'feature/test') + gitRepo.stageRepositoryForAction('feature/test', false) + + and: + def env = action.getDefaultEnvironment() + env['BRANCH_ORDER'] = '7.0.x,7.1.x,8.0.x' + + and: + action.createContainer(env, net) + + when: + action.runAction() + + then: + action.actionExitCode == 0L + action.actionLogs.contains("SOURCE_BRANCH 'feature/test' was not found in BRANCH_ORDER. Nothing to merge.") + + and: + gitRepo.getFileContents('README.md', '7.0.x') == '# demo\n' + gitRepo.getFileContents('README.md', '7.1.x') == '# demo\n' + gitRepo.getFileContents('README.md', '8.0.x') == '# demo\n' + + cleanup: + System.out.println("Container logs:\n${action.actionLogs}" as String) + } + + def 'failure - requires manual merge when source commits include skip merge marker'() { + given: + GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') + action = new GitHubDockerAction('cascade-merge', release) + + gitRepo = new GitHubRepoMock(action.workspacePath, net) + gitRepo.init() + gitRepo.populateRepository('7.0.0-SNAPSHOT', null, ['7.0.x', '7.1.x', '8.0.x']) + gitRepo.storeFiles(['README.md': '# skip merge change\n'], '7.0.x', 'docs: hold forward merge [skip merge]') + gitRepo.stageRepositoryForAction('7.0.x', false) + + and: + def env = action.getDefaultEnvironment() + env['BRANCH_ORDER'] = '7.0.x,7.1.x,8.0.x' + + and: + action.createContainer(env, net) + + when: + action.runAction() + + then: + def e = thrown(ContainerLaunchException) + e.message.contains('Container startup failed') + + and: + action.actionLogs.contains('Commits marked with [skip merge] were found between 7.0.x and 7.1.x:') + action.actionLogs.contains('docs: hold forward merge [skip merge]') + action.actionLogs.contains('ERROR: Manual merge required for 7.0.x -> 7.1.x.') + + and: + gitRepo.getFileContents('README.md', '7.1.x') == '# demo\n' + + cleanup: + System.out.println("Container logs:\n${action.actionLogs}" as String) + } +} diff --git a/tests/src/test/groovy/org/apache/grails/github/mocks/GitHubRepoMock.groovy b/tests/src/test/groovy/org/apache/grails/github/mocks/GitHubRepoMock.groovy index be7ca8d..9799922 100644 --- a/tests/src/test/groovy/org/apache/grails/github/mocks/GitHubRepoMock.groovy +++ b/tests/src/test/groovy/org/apache/grails/github/mocks/GitHubRepoMock.groovy @@ -287,6 +287,10 @@ class GitHubRepoMock implements Closeable { } void storeFiles(Map files, String refName) { + storeFiles(files, refName, "store files :${files.keySet().join(',')}") + } + + void storeFiles(Map files, String refName, String commitMessage) { Path temp = Files.createTempDirectory('store-files') try { cloneRepo(temp, refName) { Git git -> @@ -301,7 +305,7 @@ class GitHubRepoMock implements Closeable { .setAuthor('CI', 'ci@example.com') .setCommitter('CI', 'ci@example.com') .setSign(false) - .setMessage("store files :${files.keySet().join(',')}").call() + .setMessage(commitMessage).call() def tagRef = git.repository.findRef("${Constants.R_TAGS + refName}") if (tagRef != null) {