From d87539421a5d3b3f25c56f407e6fced88befa428 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sun, 12 Apr 2026 13:03:45 -0400 Subject: [PATCH 1/3] #61 - feature: new action to facilitate cascade merging when no conflicts exist. --- README.md | 1 + cascade-merge/Dockerfile | 25 +++ cascade-merge/README.md | 116 ++++++++++++++ cascade-merge/action.yml | 31 ++++ cascade-merge/entrypoint.sh | 108 +++++++++++++ deploy-github-pages/action.yml | 4 +- export-gradle-properties/action.yml | 2 +- post-release/action.yml | 4 +- pre-release/action.yml | 6 +- .../grails/github/CascadeMergeSpec.groovy | 148 ++++++++++++++++++ 10 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 cascade-merge/Dockerfile create mode 100644 cascade-merge/README.md create mode 100644 cascade-merge/action.yml create mode 100755 cascade-merge/entrypoint.sh create mode 100644 tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy 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..00a25f0 --- /dev/null +++ b/cascade-merge/README.md @@ -0,0 +1,116 @@ + + +# `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. + +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 + +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..d278ced --- /dev/null +++ b/cascade-merge/entrypoint.sh @@ -0,0 +1,108 @@ +#!/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}" + +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..54baf66 --- /dev/null +++ b/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy @@ -0,0 +1,148 @@ +/* + * 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.Specification + +class CascadeMergeSpec extends Specification { + + def 'success - merges source branch into next downstream branch'() { + given: + Network net = Network.newNetwork() + + and: + GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') + GitHubDockerAction action = new GitHubDockerAction('cascade-merge', release) + + GitHubRepoMock 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) + gitRepo?.close() + action.close() + } + + def 'failure - errors when the next downstream merge conflicts'() { + given: + Network net = Network.newNetwork() + + and: + GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') + GitHubDockerAction action = new GitHubDockerAction('cascade-merge', release) + + GitHubRepoMock 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) + gitRepo?.close() + action.close() + } + + def 'success - does nothing when source branch is not in branch order'() { + given: + Network net = Network.newNetwork() + + and: + GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: 'feature/test', targetVersion: '7.0.0-SNAPSHOT') + GitHubDockerAction action = new GitHubDockerAction('cascade-merge', release) + + GitHubRepoMock 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) + gitRepo?.close() + action.close() + } +} From 8cc3346cb18d35e0452619f3751768e2219f01e8 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sun, 12 Apr 2026 13:17:36 -0400 Subject: [PATCH 2/3] feature: support '[skip merge]' markers in commit messages to block merges --- cascade-merge/README.md | 11 +++++ cascade-merge/entrypoint.sh | 9 ++++ .../grails/github/CascadeMergeSpec.groovy | 42 +++++++++++++++++++ .../grails/github/mocks/GitHubRepoMock.groovy | 6 ++- 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/cascade-merge/README.md b/cascade-merge/README.md index 00a25f0..1685c0b 100644 --- a/cascade-merge/README.md +++ b/cascade-merge/README.md @@ -42,6 +42,7 @@ It is intended to be generic and reusable across repositories that maintain mult 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`: @@ -96,6 +97,16 @@ How this reads in practice: * 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 diff --git a/cascade-merge/entrypoint.sh b/cascade-merge/entrypoint.sh index d278ced..4353340 100755 --- a/cascade-merge/entrypoint.sh +++ b/cascade-merge/entrypoint.sh @@ -91,6 +91,15 @@ 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}." diff --git a/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy b/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy index 54baf66..2b82d76 100644 --- a/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy +++ b/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy @@ -145,4 +145,46 @@ class CascadeMergeSpec extends Specification { gitRepo?.close() action.close() } + + def 'failure - requires manual merge when source commits include skip merge marker'() { + given: + Network net = Network.newNetwork() + + and: + GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') + GitHubDockerAction action = new GitHubDockerAction('cascade-merge', release) + + GitHubRepoMock 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) + gitRepo?.close() + action.close() + } } 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) { From 01ea835b03d84fdffeb6deb7dd76aaecad9a85fd Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 22 Apr 2026 08:05:12 -0400 Subject: [PATCH 3/3] fix: prevent net connection leaks --- .../grails/github/CascadeMergeSpec.groovy | 48 ++++++++----------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy b/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy index 2b82d76..f46edbe 100644 --- a/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy +++ b/tests/src/test/groovy/org/apache/grails/github/CascadeMergeSpec.groovy @@ -23,19 +23,28 @@ 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: - Network net = Network.newNetwork() - - and: GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') - GitHubDockerAction action = new GitHubDockerAction('cascade-merge', release) + action = new GitHubDockerAction('cascade-merge', release) - GitHubRepoMock gitRepo = new GitHubRepoMock(action.workspacePath, net) + 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') @@ -61,19 +70,14 @@ class CascadeMergeSpec extends Specification { cleanup: System.out.println("Container logs:\n${action.actionLogs}" as String) - gitRepo?.close() - action.close() } def 'failure - errors when the next downstream merge conflicts'() { given: - Network net = Network.newNetwork() - - and: GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') - GitHubDockerAction action = new GitHubDockerAction('cascade-merge', release) + action = new GitHubDockerAction('cascade-merge', release) - GitHubRepoMock gitRepo = new GitHubRepoMock(action.workspacePath, net) + 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') @@ -103,19 +107,14 @@ class CascadeMergeSpec extends Specification { cleanup: System.out.println("Container logs:\n${action.actionLogs}" as String) - gitRepo?.close() - action.close() } def 'success - does nothing when source branch is not in branch order'() { given: - Network net = Network.newNetwork() - - and: GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: 'feature/test', targetVersion: '7.0.0-SNAPSHOT') - GitHubDockerAction action = new GitHubDockerAction('cascade-merge', release) + action = new GitHubDockerAction('cascade-merge', release) - GitHubRepoMock gitRepo = new GitHubRepoMock(action.workspacePath, net) + 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') @@ -142,19 +141,14 @@ class CascadeMergeSpec extends Specification { cleanup: System.out.println("Container logs:\n${action.actionLogs}" as String) - gitRepo?.close() - action.close() } def 'failure - requires manual merge when source commits include skip merge marker'() { given: - Network net = Network.newNetwork() - - and: GitHubVersion release = new GitHubVersion(version: '7.0.0', tagName: null, targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT') - GitHubDockerAction action = new GitHubDockerAction('cascade-merge', release) + action = new GitHubDockerAction('cascade-merge', release) - GitHubRepoMock gitRepo = new GitHubRepoMock(action.workspacePath, net) + 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]') @@ -184,7 +178,5 @@ class CascadeMergeSpec extends Specification { cleanup: System.out.println("Container logs:\n${action.actionLogs}" as String) - gitRepo?.close() - action.close() } }