Skip to content

fix: support concurrent documentation deploys to GitHub Pages - #98

Closed
jamesfredley wants to merge 2 commits into
asffrom
fix/concurrent-docs-deploy
Closed

fix: support concurrent documentation deploys to GitHub Pages#98
jamesfredley wants to merge 2 commits into
asffrom
fix/concurrent-docs-deploy

Conversation

@jamesfredley

Copy link
Copy Markdown
Contributor

What

Makes the deploy-github-pages action safe when multiple documentation deploys publish concurrently. Today, when several release/snapshot builds push to the documentation branch at the same time, their pushes race: a rejected push runs a plain git pull --rebase, which stops on a merge conflict in the shared folders and - under set -e - kills the whole job. Concurrent releases therefore fail (observed in apache/grails-core doc publishing).

Why it happens

Different releases never collide on their specific X.X.X folders, but they all write the shared paths: latest, the generic X.X.x, the root index.html, and the snapshot folder. A concurrent deploy that lands first advances the branch, our push is rejected, and the naive rebase conflicts on those shared paths and aborts.

The fix

In deploy-github-pages/entrypoint.sh, the push-retry loop is now concurrency-safe:

  • Rebase with -X theirs + backoff. The deploy commit is replayed onto the remote so a concurrent deploy's independent version folders are preserved, and shared-folder file-content conflicts auto-resolve in favour of this deploy. Retries use bounded backoff with jitter.
  • Reconcile owned paths. After the rebase, purge-owned folders (PURGE_EXISTING=true) are restored wholesale from this deploy's commit (clean last-writer-wins), so a shared folder never becomes a union of both deploys' files. Merge-mode folders (PURGE_EXISTING=false) are deliberately left to the -X theirs merge so a concurrent deploy's independent changes survive.
  • latest is highest-version-wins. A lower release replayed after a concurrent higher release yields latest back to the remote instead of overwriting it with older docs.
  • Modify/delete conflicts resolved. -X theirs cannot auto-resolve a modify/delete conflict (a file this deploy purges that a concurrent deploy modified). Instead of aborting and re-hitting the same conflict until attempts are exhausted, the rebase is now driven to completion deterministically.

Tests

Adds a Testcontainers regression test to DeployGithubPagesSpec that lands a concurrent deploy modifying a file this deploy purges (a real modify/delete conflict) and asserts the retry recovers cleanly - the deploy's own content wins and the stale file is removed. All existing deploy tests pass.

Note / follow-up

apache/grails-static-website exhibits the same class of race, but in a separate Gradle publish task (:publishMainSite pushing to apache/grails-website asf-site-production), which lives outside this repository. That will need a separate follow-up fix in that project - it is not addressed here.

When multiple release/snapshot builds publish documentation at the same
time, their pushes to the documentation branch race. Previously a rejected
push triggered a plain `git pull --rebase`, which stops on a merge conflict
in the shared folders (latest, X.X.x, index.html, snapshot) and, under
`set -e`, kills the whole job - so concurrent releases fail.

Make the retry loop concurrency-safe:

- Rebase the deploy commit onto the remote with `-X theirs` so shared-folder
  file conflicts auto-resolve in favour of this deploy, and add bounded
  backoff with jitter between attempts.
- Reconcile owned paths after the rebase: purge-owned folders are restored
  wholesale from the deploy commit (clean last-writer-wins) while merge-mode
  folders and other deploys' independent version folders are preserved.
- Keep `latest` as highest-version-wins: a lower release replayed after a
  concurrent higher release yields `latest` back to the remote instead of
  overwriting it with older docs.
- Resolve modify/delete rebase conflicts that `-X theirs` cannot handle
  (a file this deploy purges but a concurrent deploy modified) by driving
  the rebase to completion deterministically instead of aborting.

Add a Testcontainers regression test that lands a concurrent deploy
modifying a purged file and asserts the retry recovers cleanly.

Assisted-by: claude-code:claude-4.8-opus
Copilot AI review requested due to automatic review settings July 4, 2026 02:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes the deploy-github-pages GitHub Action resilient to concurrent documentation deployments by changing the push-retry strategy to rebase onto the remote with deterministic conflict handling, then reconciling “owned” paths to enforce intended last-writer-wins semantics. It also adds a regression test that simulates a real modify/delete conflict caused by a concurrent deploy.

Changes:

  • Update the deploy action’s push-retry loop to use git pull --rebase -X theirs, add bounded backoff, and deterministically resolve modify/delete conflicts during rebase.
  • Track and reconcile “owned” publish paths after rebase (including special handling so latest remains highest-version-wins).
  • Add a Testcontainers-based regression test covering a concurrent deploy that modifies a file this deploy purges.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
deploy-github-pages/entrypoint.sh Implements concurrency-safe push retries with rebase-based conflict resolution and owned-path reconciliation logic.
tests/src/test/groovy/org/apache/grails/github/DeployGithubPagesSpec.groovy Updates existing retry-log expectation and adds a new regression test for modify/delete conflict recovery.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread deploy-github-pages/entrypoint.sh Outdated
Comment thread deploy-github-pages/entrypoint.sh
Comment thread deploy-github-pages/entrypoint.sh Outdated
Addresses review feedback on the concurrent-deploy reconcile logic. The
authoritative restore commands in reconcile_owned_paths previously used
`|| true`, so if restoring an owned documentation path failed, the action
would silently stage and commit a deletion and publish broken/missing docs.

- Owned purge/keep paths always exist in the deploy commit, so their
  `git checkout`/`git add` restores are now unmasked: a failure fails the
  job under `set -e` instead of committing a silent deletion. Only the
  best-effort pre-clean `git rm --ignore-unmatch` stays lenient.
- The "latest" yield path only restores from the remote when the remote
  actually publishes it (guarded by `git ls-tree`); otherwise the staged
  removal stands, avoiding an abort on a now-absent pathspec. The
  `git ls-tree` check is unmasked so an invalid FETCH_HEAD fails closed.

Assisted-by: claude-code:claude-4.8-opus
@jamesfredley
jamesfredley requested review from jdaugherty and matrei July 4, 2026 04:09
@jdaugherty

Copy link
Copy Markdown
Contributor

Wouldn't it be better to have a concurrency key or some other solution that blocks the run?

@jdaugherty

jdaugherty commented Jul 14, 2026

Copy link
Copy Markdown
Contributor
    concurrency:
       group: shared-exclusive-operation
       cancel-in-progress: false
       queue: max

This can be added per job, so we can have a unique concurrency key per job. I don't believe this change is warranted.

@jdaugherty

Copy link
Copy Markdown
Contributor

apache/grails-core#15988 is an alternative and I think a better solution than complicating the action.

@jdaugherty

Copy link
Copy Markdown
Contributor

I'm closing this for now since I've merged the other change. let's revisit if you disagree

@jdaugherty jdaugherty closed this Jul 18, 2026
@jamesfredley jamesfredley reopened this Jul 30, 2026
@jdaugherty

Copy link
Copy Markdown
Contributor

I'm still against this solution because we're relying on force repushing. We need to solve this outside of the action.

@jdaugherty

Copy link
Copy Markdown
Contributor

apache/grails-core#15988 (comment) shows why the original solution didn't work. The reason I'm so against this change is we're effectively trying to solve a shared resource issue inside of a thread instead of outside of it.

@jdaugherty jdaugherty closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants