You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/08/06 18:04:51 UTC
(grails-github-actions) branch fix/parallel-docs-publish-retry created (now dcdd17f)
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a change to branch fix/parallel-docs-publish-retry
in repository https://gitbox.apache.org/repos/asf/grails-github-actions.git
at dcdd17f Document concurrent push retry behavior
This branch includes the following new commits:
new 58f6708 Safely retry concurrent documentation pushes
new dcdd17f Document concurrent push retry behavior
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(grails-github-actions) 02/02: Document concurrent push retry behavior
Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch fix/parallel-docs-publish-retry
in repository https://gitbox.apache.org/repos/asf/grails-github-actions.git
commit dcdd17f4ad3e1d35fa89a3c4a0b1d1bef733bb4d
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Aug 6 13:55:48 2026 -0400
Document concurrent push retry behavior
Clarify the five-attempt limit and the fail-closed conditions for
concurrent documentation publishing.
Assisted-by: opencode:gpt-5.6-sol
---
deploy-github-pages/README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/deploy-github-pages/README.md b/deploy-github-pages/README.md
index 9542da2..459fc17 100644
--- a/deploy-github-pages/README.md
+++ b/deploy-github-pages/README.md
@@ -32,7 +32,7 @@ Note: snapshots ignore the VERSION parameter and always publish to the `snapshot
## Push Retry Logic
-When multiple branches build concurrently, their documentation deployments may race to push to the same documentation branch. To handle this, the action includes automatic retry logic: if a push is rejected (e.g., because another build pushed first), it will pull remote changes with rebase and retry, up to 5 attempts. If all attempts fail, the action exits with an error.
+When publishers race to update an existing documentation branch, the action makes up to five push attempts, including the initial attempt. After a retryable non-fast-forward rejection, it fetches the new remote tip, requires that tip to be a strict descendant of the previously observed remote tip, rebases the unpublished local deployment in the existing checkout, and attempts another normal fast-forward push. The action never force-pushes. Rebase conflicts, fetched retry tips that fail t [...]
## Requirements
If using the default `GITHUB_TOKEN`, this action requires permission `contents: write`. Otherwise, the provided `GH_TOKEN` must be able to commit to the documentation branch.
@@ -71,4 +71,4 @@ Release Usage:
GRADLE_PUBLISH_RELEASE: 'true'
SOURCE_FOLDER: build/docs
VERSION: ${{ needs.publish.outputs.release_version }}
-```
\ No newline at end of file
+```
(grails-github-actions) 01/02: Safely retry concurrent documentation pushes
Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch fix/parallel-docs-publish-retry
in repository https://gitbox.apache.org/repos/asf/grails-github-actions.git
commit 58f6708faba664b563a362ea6e0c13c776ee9385
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Aug 6 13:47:31 2026 -0400
Safely retry concurrent documentation pushes
Retry only genuine non-fast-forward races by fetching and rebasing the
unpublished deployment commit in the existing shallow checkout. Fail safely
on conflicts, rewritten history, and unrelated push errors.
Assisted-by: opencode:gpt-5.6-sol
---
deploy-github-pages/entrypoint.sh | 41 +++-
.../grails/github/DeployGithubPagesSpec.groovy | 220 +++++++++++++++++++--
2 files changed, 235 insertions(+), 26 deletions(-)
diff --git a/deploy-github-pages/entrypoint.sh b/deploy-github-pages/entrypoint.sh
index 01eaa2b..3991c26 100755
--- a/deploy-github-pages/entrypoint.sh
+++ b/deploy-github-pages/entrypoint.sh
@@ -31,7 +31,7 @@ set_value_or_error() {
exit 1
fi
- if [[ -n "$value" ]]; then
+ if [[ -n "$value" ]]; then
decidedValue="$value"
else
echo "${variableName}: Using default value: ${defaultValue}"
@@ -101,7 +101,7 @@ publish_artifacts() {
is_highest_version() {
local new_folder="$1" # e.g. "7.0.x"
-
+
# Strip the trailing ".x" → "7.0", then parse into major/minor
local new_major new_minor
local folder_no_x="${new_folder%.x}" # "7.0"
@@ -207,6 +207,7 @@ if git ls-remote --heads "${GIT_REPO_URL}" "${DOCUMENTATION_BRANCH}" | grep -q "
echo "documentation branch found, cloning"
git clone "${GIT_REPO_URL}" "${DOCUMENTATION_BRANCH}" --branch "${DOCUMENTATION_BRANCH}" --single-branch --depth 1
cd ${DOCUMENTATION_BRANCH}
+ LAST_REMOTE_TIP="$(git rev-parse HEAD)"
echo "::endgroup::"
else
echo "::group::Creating documentation branch"
@@ -216,6 +217,7 @@ else
git init
git checkout -b "${DOCUMENTATION_BRANCH}"
git remote add origin "${GIT_REPO_URL}"
+ LAST_REMOTE_TIP=""
echo "::endgroup::"
fi
@@ -285,7 +287,7 @@ else
echo "Published release documentation to ${genericVersionFolder}"
echo "::endgroup::"
- # Publish to the latest release folder if needed
+ # Publish to the latest release folder if needed
if [[ "$SKIP_RELEASE_FOLDER" == "false" ]]; then
if is_highest_version "${genericVersionFolder}"; then
echo "::group::Overwriting ${LAST_RELEASE_FOLDER} with the latest release documentation"
@@ -321,16 +323,43 @@ MAX_PUSH_ATTEMPTS=5
PUSH_ATTEMPT=1
while [ $PUSH_ATTEMPT -le $MAX_PUSH_ATTEMPTS ]; do
echo "Push attempt ${PUSH_ATTEMPT}/${MAX_PUSH_ATTEMPTS}"
- if git push "${GIT_REPO_URL}" "${DOCUMENTATION_BRANCH}" 2>&1; then
+ PUSH_EXIT=0
+ PUSH_OUTPUT="$(LC_ALL=C git push --porcelain "${GIT_REPO_URL}" "HEAD:refs/heads/${DOCUMENTATION_BRANCH}" 2>&1)" || PUSH_EXIT=$?
+ printf '%s\n' "${PUSH_OUTPUT}"
+ if [ $PUSH_EXIT -eq 0 ]; then
echo "Deployment successful!"
break
fi
+ if ! grep -Eq $'^!\t.*\t\[rejected\] \((non-fast-forward|fetch first)\)$' <<< "${PUSH_OUTPUT}"; then
+ echo "ERROR: Push failed without a retryable non-fast-forward rejection." >&2
+ exit 1
+ fi
+ if [ -z "${LAST_REMOTE_TIP}" ]; then
+ echo "ERROR: Documentation branch was created by another publisher before the first push." >&2
+ exit 1
+ fi
if [ $PUSH_ATTEMPT -eq $MAX_PUSH_ATTEMPTS ]; then
echo "ERROR: Push failed after ${MAX_PUSH_ATTEMPTS} attempts." >&2
exit 1
fi
- echo "Push rejected, pulling remote changes and retrying..."
- git pull --rebase "${GIT_REPO_URL}" "${DOCUMENTATION_BRANCH}"
+ echo "Push rejected by a concurrent publisher, rebasing and retrying..."
+ if ! git fetch --no-tags "${GIT_REPO_URL}" "refs/heads/${DOCUMENTATION_BRANCH}"; then
+ echo "ERROR: Failed to fetch the concurrent documentation branch update." >&2
+ exit 1
+ fi
+ NEW_REMOTE_TIP="$(git rev-parse FETCH_HEAD)"
+ if [[ "${NEW_REMOTE_TIP}" == "${LAST_REMOTE_TIP}" ]] || ! git merge-base --is-ancestor "${LAST_REMOTE_TIP}" "${NEW_REMOTE_TIP}"; then
+ echo "ERROR: Concurrent documentation branch update is not a descendant of the observed remote tip." >&2
+ exit 1
+ fi
+ if ! git rebase --onto "${NEW_REMOTE_TIP}" "${LAST_REMOTE_TIP}"; then
+ echo "ERROR: Rebase failed; aborting without changing the remote branch." >&2
+ git rebase --abort
+ exit 1
+ fi
+ LAST_REMOTE_TIP="${NEW_REMOTE_TIP}"
PUSH_ATTEMPT=$((PUSH_ATTEMPT + 1))
+ BACKOFF_SECONDS=$((1 << (PUSH_ATTEMPT - 2)))
+ sleep $((BACKOFF_SECONDS + RANDOM % (BACKOFF_SECONDS + 1)))
done
echo "::endgroup::"
diff --git a/tests/src/test/groovy/org/apache/grails/github/DeployGithubPagesSpec.groovy b/tests/src/test/groovy/org/apache/grails/github/DeployGithubPagesSpec.groovy
index 1040c5b..48ff75a 100644
--- a/tests/src/test/groovy/org/apache/grails/github/DeployGithubPagesSpec.groovy
+++ b/tests/src/test/groovy/org/apache/grails/github/DeployGithubPagesSpec.groovy
@@ -670,7 +670,7 @@ class DeployGithubPagesSpec extends Specification {
System.out.println("Container logs:\n${action.actionLogs}" as String)
}
- def "push retry - succeeds after initial push rejection"() {
+ def "push retry - rebases and preserves a competing disjoint commit"() {
given:
GitHubVersion release = new GitHubVersion(version: '7.0.0-RC1', tagName: 'rel-7.0.0-RC1', targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT')
action = new GitHubDockerAction('deploy-github-pages', release, new GitHubCliMock())
@@ -684,25 +684,42 @@ class DeployGithubPagesSpec extends Specification {
], 'gh-pages')
gitRepo.stageRepositoryForAction('main', false)
- and: 'install git wrapper that rejects the first push attempt'
+ and: 'install a wrapper that publishes a competing commit immediately before the first push'
def gitWrapper = action.mockPath.resolve('git').toFile()
gitWrapper.text = '''\
#!/bin/sh
REAL_GIT=/usr/bin/git
-MARKER=/tmp/git_push_rejected_once
+MARKER=/tmp/git_push_competing_commit
if [ "$1" = "push" ]; then
if [ ! -f "$MARKER" ]; then
touch "$MARKER"
- echo "error: failed to push some refs" >&2
- echo "hint: Updates were rejected because the remote contains work that you do not" >&2
- echo "hint: have locally. Integrate the remote changes before pushing again." >&2
- exit 1
+ ACTION_CHECKOUT="$(pwd)"
+ rm -rf /tmp/concurrent-publisher
+ for arg in "$@"; do
+ case "$arg" in
+ http://*|https://*) REMOTE="$arg" ;;
+ esac
+ done
+ "$REAL_GIT" clone "$REMOTE" /tmp/concurrent-publisher
+ cd /tmp/concurrent-publisher
+ "$REAL_GIT" checkout gh-pages
+ mkdir -p publisher
+ printf '%s\\n' 'winner' > publisher/winner.html
+ "$REAL_GIT" add publisher/winner.html
+ "$REAL_GIT" -c user.name=winner -c user.email=winner@example.com commit -m winner
+ "$REAL_GIT" push origin HEAD:refs/heads/gh-pages
+ cd "$ACTION_CHECKOUT"
fi
fi
exec "$REAL_GIT" "$@"
'''
gitWrapper.executable = true
+ action.mockPath.resolve('sleep').toFile().with {
+ text = '#!/bin/sh\nexit 0\n'
+ executable = true
+ }
+
and:
def env = getDefaultEnvironment(action, gitRepo)
env['GRADLE_PUBLISH_RELEASE'] = 'false'
@@ -718,14 +735,15 @@ exec "$REAL_GIT" "$@"
then:
action.actionExitCode == 0L
- and: 'first push was rejected and retry occurred'
+ and: 'the actual non-fast-forward rejection was rebased and retried'
action.actionLogs.contains('Push attempt 1/5')
- action.actionLogs.contains('Push rejected, pulling remote changes and retrying...')
+ action.actionLogs.contains('Push rejected by a concurrent publisher, rebasing and retrying...')
action.actionLogs.contains('Push attempt 2/5')
action.actionLogs.contains('Deployment successful!')
- and: 'docs deployed successfully despite initial rejection'
+ and: 'both publishers changes survive'
gitRepo.branchExists('gh-pages')
+ gitRepo.getFileContents('publisher/winner.html', 'gh-pages').trim() == 'winner'
gitRepo.getFileContents('snapshot/index.html', 'gh-pages') == '<html><body>Welcome to the Grails Documentation</body></html>'
gitRepo.getFileContents('index.html', 'gh-pages') == '<html><body>Welcome to the Grails GitHub Pages</body></html>'
@@ -733,7 +751,7 @@ exec "$REAL_GIT" "$@"
System.out.println("Container logs:\n${action.actionLogs}" as String)
}
- def "push retry - fails after maximum push attempts"() {
+ def "push retry - aborts a conflicting rebase and preserves the winner"() {
given:
GitHubVersion release = new GitHubVersion(version: '7.0.0-RC1', tagName: 'rel-7.0.0-RC1', targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT')
action = new GitHubDockerAction('deploy-github-pages', release, new GitHubCliMock())
@@ -747,16 +765,99 @@ exec "$REAL_GIT" "$@"
], 'gh-pages')
gitRepo.stageRepositoryForAction('main', false)
- and: 'install git wrapper that always rejects pushes'
+ and: 'install a wrapper that changes the same destination file immediately before the first push'
def gitWrapper = action.mockPath.resolve('git').toFile()
gitWrapper.text = '''\
#!/bin/sh
REAL_GIT=/usr/bin/git
+MARKER=/tmp/git_push_conflicting_commit
+if [ "$1" = "push" ] && [ ! -f "$MARKER" ]; then
+ touch "$MARKER"
+ ACTION_CHECKOUT="$(pwd)"
+ rm -rf /tmp/concurrent-publisher
+ for arg in "$@"; do
+ case "$arg" in
+ http://*|https://*) REMOTE="$arg" ;;
+ esac
+ done
+ "$REAL_GIT" clone "$REMOTE" /tmp/concurrent-publisher
+ cd /tmp/concurrent-publisher
+ "$REAL_GIT" checkout gh-pages
+ printf '%s\\n' 'winner' > snapshot/index.html
+ "$REAL_GIT" add snapshot/index.html
+ "$REAL_GIT" -c user.name=winner -c user.email=winner@example.com commit -m winner
+ "$REAL_GIT" push origin HEAD:refs/heads/gh-pages
+ cd "$ACTION_CHECKOUT"
+fi
+exec "$REAL_GIT" "$@"
+'''
+ gitWrapper.executable = true
+
+ action.mockPath.resolve('sleep').toFile().with {
+ text = '#!/bin/sh\nexit 0\n'
+ executable = true
+ }
+
+ and:
+ def env = getDefaultEnvironment(action, gitRepo)
+ env['GRADLE_PUBLISH_RELEASE'] = 'false'
+ env['SOURCE_FOLDER'] = 'docs'
+ env['VERSION'] = '7.0.0-SNAPSHOT'
+
+ and:
+ action.createContainer(env, net)
+
+ when:
+ Exception startupException = null
+ try {
+ action.runAction()
+ } catch (Exception e) {
+ startupException = e
+ }
+
+ then: 'action fails without resolving the conflict'
+ startupException != null || action.actionExitCode != 0L
+
+ and: 'the failed rebase was aborted and no second push was attempted'
+ action.actionLogs.contains('Push attempt 1/5')
+ !action.actionLogs.contains('Push attempt 2/5')
+ action.actionLogs.contains('ERROR: Rebase failed; aborting without changing the remote branch.')
+ !action.workspacePath.resolve('gh-pages/.git/rebase-merge').toFile().exists()
+ !action.workspacePath.resolve('gh-pages/.git/rebase-apply').toFile().exists()
+
+ and: 'the winner remains on the remote'
+ gitRepo.getFileContents('snapshot/index.html', 'gh-pages').trim() == 'winner'
+
+ cleanup:
+ System.out.println("Container logs:\n${action.actionLogs}" as String)
+ }
+
+ def "push retry - fails a non-contention push error without fetching or retrying"() {
+ given:
+ GitHubVersion release = new GitHubVersion(version: '7.0.0-RC1', tagName: 'rel-7.0.0-RC1', targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT')
+ action = new GitHubDockerAction('deploy-github-pages', release, new GitHubCliMock())
+
+ gitRepo = new GitHubRepoMock(action.workspacePath, net)
+ gitRepo.init()
+ gitRepo.populateRepository('7.0.0-SNAPSHOT', null, [], getProjectFiles())
+ gitRepo.createDivergedBranch([
+ 'index.html' : '<html><body>Existing root page</body></html>',
+ 'snapshot/index.html': '<html><body>Existing snapshot</body></html>'
+ ], 'gh-pages')
+ gitRepo.stageRepositoryForAction('main', false)
+
+ and: 'install a wrapper that returns a generic push failure and exposes any fetch'
+ def gitWrapper = action.mockPath.resolve('git').toFile()
+ gitWrapper.text = '''\
+#!/bin/sh
if [ "$1" = "push" ]; then
- echo "error: failed to push some refs" >&2
+ echo 'fatal: authentication failed' >&2
exit 1
fi
-exec "$REAL_GIT" "$@"
+if [ "$1" = "fetch" ]; then
+ echo 'WRAPPER_FETCH_CALLED' >&2
+fi
+exec /usr/bin/git "$@"
'''
gitWrapper.executable = true
@@ -777,20 +878,99 @@ exec "$REAL_GIT" "$@"
startupException = e
}
- then: 'action failed'
+ then: 'the generic failure is not treated as a retryable race'
startupException != null || action.actionExitCode != 0L
+ action.actionLogs.contains('Push attempt 1/5')
+ !action.actionLogs.contains('Push attempt 2/5')
+ !action.actionLogs.contains('WRAPPER_FETCH_CALLED')
+ action.actionLogs.contains('ERROR: Push failed without a retryable non-fast-forward rejection.')
- and: 'all 5 push attempts were made'
+ and:
+ !action.actionLogs.contains('Deployment successful!')
+
+ cleanup:
+ System.out.println("Container logs:\n${action.actionLogs}" as String)
+ }
+
+ def "push retry - fails after five competing remote advances"() {
+ given:
+ GitHubVersion release = new GitHubVersion(version: '7.0.0-RC1', tagName: 'rel-7.0.0-RC1', targetBranch: '7.0.x', targetVersion: '7.0.0-SNAPSHOT')
+ action = new GitHubDockerAction('deploy-github-pages', release, new GitHubCliMock())
+
+ gitRepo = new GitHubRepoMock(action.workspacePath, net)
+ gitRepo.init()
+ gitRepo.populateRepository('7.0.0-SNAPSHOT', null, [], getProjectFiles())
+ gitRepo.createDivergedBranch([
+ 'index.html' : '<html><body>Existing root page</body></html>',
+ 'snapshot/index.html': '<html><body>Existing snapshot</body></html>'
+ ], 'gh-pages')
+ gitRepo.stageRepositoryForAction('main', false)
+
+ and: 'install a wrapper that advances the remote before every action push'
+ def gitWrapper = action.mockPath.resolve('git').toFile()
+ gitWrapper.text = '''\
+#!/bin/sh
+REAL_GIT=/usr/bin/git
+COUNT_FILE=/tmp/git_push_advance_count
+if [ "$1" = "push" ]; then
+ ACTION_CHECKOUT="$(pwd)"
+ COUNT=0
+ if [ -f "$COUNT_FILE" ]; then
+ COUNT="$(cat "$COUNT_FILE")"
+ fi
+ COUNT=$((COUNT + 1))
+ printf '%s\\n' "$COUNT" > "$COUNT_FILE"
+ rm -rf /tmp/concurrent-publisher
+ for arg in "$@"; do
+ case "$arg" in
+ http://*|https://*) REMOTE="$arg" ;;
+ esac
+ done
+ "$REAL_GIT" clone "$REMOTE" /tmp/concurrent-publisher
+ cd /tmp/concurrent-publisher
+ "$REAL_GIT" checkout gh-pages
+ mkdir -p publisher
+ printf '%s\\n' "$COUNT" > "publisher/winner-${COUNT}.html"
+ "$REAL_GIT" add "publisher/winner-${COUNT}.html"
+ "$REAL_GIT" -c user.name=winner -c user.email=winner@example.com commit -m "winner ${COUNT}"
+ "$REAL_GIT" push origin HEAD:refs/heads/gh-pages
+ cd "$ACTION_CHECKOUT"
+fi
+exec "$REAL_GIT" "$@"
+'''
+ gitWrapper.executable = true
+
+ action.mockPath.resolve('sleep').toFile().with {
+ text = '#!/bin/sh\nexit 0\n'
+ executable = true
+ }
+
+ and:
+ def env = getDefaultEnvironment(action, gitRepo)
+ env['GRADLE_PUBLISH_RELEASE'] = 'false'
+ env['SOURCE_FOLDER'] = 'docs'
+ env['VERSION'] = '7.0.0-SNAPSHOT'
+
+ and:
+ action.createContainer(env, net)
+
+ when:
+ Exception startupException = null
+ try {
+ action.runAction()
+ } catch (Exception e) {
+ startupException = e
+ }
+
+ then: 'each normal push is rejected by a fresh remote advance'
+ startupException != null || action.actionExitCode != 0L
action.actionLogs.contains('Push attempt 1/5')
action.actionLogs.contains('Push attempt 2/5')
action.actionLogs.contains('Push attempt 3/5')
action.actionLogs.contains('Push attempt 4/5')
action.actionLogs.contains('Push attempt 5/5')
-
- and: 'error message logged after exhausting retries'
+ !action.actionLogs.contains('Push attempt 6/5')
action.actionLogs.contains('ERROR: Push failed after 5 attempts.')
-
- and: 'deployment did not succeed'
!action.actionLogs.contains('Deployment successful!')
cleanup: