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 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 @@ Release Usage: 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 @@ 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') == 'Welcome to the Grails Documentation' gitRepo.getFileContents('index.html', 'gh-pages') == 'Welcome to the Grails GitHub Pages' @@ -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' : 'Existing root page', + 'snapshot/index.html': 'Existing snapshot' + ], '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' : 'Existing root page', + 'snapshot/index.html': 'Existing snapshot' + ], '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: