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: