Technical Architecture: How Content Publishes to grails.apache.org

Core Model
----------

apache/grails-website (specifically the asf-site-production branch) is the single aggregation and publication point for the live site.

Apache Infrastructure watches this branch via the standard ASF site publishing mechanism controlled by .asf.yaml:

publish:
  whoami: asf-site-production

Any fast-forward commit that lands on asf-site-production is mirrored by Apache Infra to https://grails.apache.org/ (with the usual CDN soft-purge behaviour). The repository itself contains no build logic for the site; it is purely the destination that multiple independent producers push into.

Content Producers
-----------------

There are currently two primary independent publishers (with more expected):

1. Main website + guides aggregate – apache/grails-static-website

- Gradle-based static site generator (pages, blog, assets, templates, guides registry, etc.).
   - Workflow: .github/workflows/publish.yml
- Triggers: push to master, every 2 hours (cron: '0 */2 * * *'), or workflow_dispatch. - Has its own repository-scoped concurrency group (publish-${{ github.ref }}, cancel-in-progress: false) so only one of its own publishes runs at a time.
     - Runs ./gradlew clean publishMainSite.
- The Gradle task uses environment variables (GITHUB_SLUG=apache/grails-website, GH_BRANCH=asf-site-production, GH_TOKEN) to push the generated content (root of the site) into the shared branch.
   - This is responsible for the majority of the top-level site content.

2. Versioned documentation – apache/grails-core (via the grails-doc build)

- Snapshot and release documentation jobs in grails-core (and related workflows) build the docs, then invoke the shared action:
     uses: apache/grails-github-actions/deploy-github-pages@asf
   - Key environment variables:
     - TARGET_REPOSITORY: apache/grails-website
     - DOCUMENTATION_BRANCH: asf-site-production
     - TARGET_FOLDER: docs
     - SOURCE_FOLDER: grails-doc/build/docs
     - GRADLE_PUBLISH_RELEASE: true|false
- The action checks out the target branch, overlays the documentation under /docs/ (creating versioned folders such as latest, specific release versions, and snapshot), commits a single deployment commit, and pushes.

Legacy documentation already lives under docs-legacy-* directories in the same branch. Guides are in the process of being fully consolidated into the static-website publisher (previously split across several locations). Grails Forge is expected to become another publisher targeting the same destination.

Shared Publishing Action
------------------------

apache/grails-github-actions (branch asf) provides the reusable deploy-github-pages action used by the documentation publishers. It is deliberately a thin Git-based overlay:

- Checkout of the target branch into a working tree.
- Copy of the generated content into the configured folder.
- Single commit + push.

Because multiple independent workflows (from different repositories) can target the same branch tip concurrently, non-fast-forward rejections are a normal occurrence. The current work (PR #110) makes the action robust to this by:

- Detecting a genuine non-fast-forward rejection.
- Fetching the new tip into the existing shallow checkout.
- Confirming the fetched tip is a descendant of the originally observed tip.
- Rebasing the unpublished local deployment commit.
- Retrying a normal (non-force) push (maximum five attempts).
- Failing closed on true content conflicts or non-descendant history.

No force-push is ever performed.

Why Cross-Repository Coordination Is Required
---------------------------------------------

GitHub Actions concurrency groups are scoped to a single repository. A group defined in grails-core (grails-docs-publish) cannot coordinate with jobs running in grails-static-website (or future publishers such as Forge). Serialising everything inside one repository simply creates artificial queues and measurable release delays (observed 11–18 minute waits, plus manual re-runs and runner contention).

The fundamental problem is concurrent independent commits to a shared branch. The correct coordination mechanism is ordinary Git fast-forward semantics (fetch + rebase + push), which is exactly what the improved action implements.

Resulting Architecture
----------------------

grails-static-website  ──publishMainSite──┐
                                           │
grails-core (docs jobs) ──deploy-github-pages──┼──► asf-site-production ──ASF Infra──► grails.apache.org
                                           │
(future: Grails Forge, etc.) ──────────────┘

The live site is the pure aggregation of whatever the various generators last successfully pushed. There is no central “build the whole site” job; each producer owns its own content generation and is responsible for landing a clean, rebaseable commit on the shared branch.

This is the architecture that the current pair of PRs (grails-github-actions#110 + grails-core#16110) is designed to make reliable under concurrent load without introducing additional artifact storage, coordinating workflows, or force-pushes.

On 8/6/2026 4:12 PM, James Fredley wrote:
We have an ongoing issue with concurrent publishing to the grails- website repository. This has affected multiple releases over the past several months in addition to grails-website publishing.  Any time two publishing actions are running at the same time.

I previously opened https://github.com/apache/grails-github-actions/ pull/98 to make concurrent documentation pushes safe. It was closed in favor of a job-level concurrency group (`grails-docs-publish`) added in grails-core#15988 and then closed again, after re-opening, when it became clear that did not work. That approach serializes independent documentation jobs within Grails Core. Because GitHub Actions concurrency groups are scoped to a single repository, it cannot coordinate publishers from other repositories (grails-static-website, and eventually Grails Forge) that also target the same destination branch.

In practice making this serial has introduced measurable delays during release sequences (for example, an 18-minute wait observed on the each release documentation job, plus manual synchronization, plus manual re- run on grail-website action which failed, plus waiting on github runner availability).  This burned 3 hours this morning.

The current pair of pull requests restores the ability to publish in parallel while remaining conservative:


- https://github.com/apache/grails-github-actions/pull/110 – Safely retry concurrent documentation pushes   When another publisher advances the destination branch first, the action recognizes a genuine non-fast-forward rejection, fetches the new tip into the existing shallow checkout, confirms the fetched tip descends from the originally observed tip, rebases the unpublished local deployment commit, and retries a normal push (maximum five attempts). There is no force-push. Conflicts, non-descendant history, and unrelated failures fail closed without modifying the remote.

- https://github.com/apache/grails-core/pull/16110 – Allow concurrent documentation publishing   Removes the repository-local `grails-docs-publish` queue so independent documentation jobs can proceed once the action-level retry is available. Git’s normal fast-forward rules then coordinate updates to the shared branch.

This is a deliberately narrow solution. It covers the cross-repository case, avoids serializing unrelated release work, and fails safely. I am open to any alternative that fully addresses the same constraints (multiple independent publishers, no force-push, safe handling of true content conflicts, and no artificial serialization of independent jobs). Until a better complete solution is available and verified, these changes allow the project to move forward without further release delays.

An alternate solution has also been proposed of uploading artifacts and then having a separate workflow process them: That approach would introduce additional moving parts (artifact storage, a new coordinating workflow, hand-off between producers and a consumer, extra latency, and new failure modes) for what is fundamentally a concurrent commit problem on a shared branch. Multiple independent publishers are simply generating documentation and pushing it to the same destination. When another push lands first, the required response is the standard Git sequence: fetch the new tip, rebase the unpublished local commit, and push again. That is exactly what the retry logic in apache/grails-github-actions#110 does, using ordinary fast-forward rules and failing closed on true conflicts.

Let's debate this and get it fixed before the next release cycle, so we can get back to faster releases.

James

Reply via email to