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