Merge pull request #110 from apache/fix/parallel-docs-publish-retry

Safely retry concurrent documentation pushes
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 @@
 
 ## 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 the descendant check, concurrent creation of a previously missing documentation branch, and non-contention push failures stop the deployment without changing the remote branch.
 
 ## 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 @@
           GRADLE_PUBLISH_RELEASE: 'true'
           SOURCE_FOLDER: build/docs
           VERSION: ${{ needs.publish.outputs.release_version }}
-```
\ No newline at end of file
+```
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 @@
     exit 1
   fi
 
-  if [[ -n "$value" ]]; then    
+  if [[ -n "$value" ]]; then
     decidedValue="$value"
   else
     echo "${variableName}: Using default value: ${defaultValue}"
@@ -101,7 +101,7 @@
 
 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 @@
   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 @@
   git init
   git checkout -b "${DOCUMENTATION_BRANCH}"
   git remote add origin "${GIT_REPO_URL}"
+  LAST_REMOTE_TIP=""
   echo "::endgroup::"
 fi
 
@@ -285,7 +287,7 @@
   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 @@
 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 @@
         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 @@
         ], '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 @@
         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 @@
         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,19 +765,102 @@
         ], '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
-if [ "$1" = "push" ]; then
-  echo "error: failed to push some refs" >&2
-  exit 1
+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 'fatal: authentication failed' >&2
+  exit 1
+fi
+if [ "$1" = "fetch" ]; then
+  echo 'WRAPPER_FETCH_CALLED' >&2
+fi
+exec /usr/bin/git "$@"
+'''
+        gitWrapper.executable = true
+
         and:
         def env = getDefaultEnvironment(action, gitRepo)
         env['GRADLE_PUBLISH_RELEASE'] = 'false'
@@ -777,20 +878,99 @@
             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: