You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by "jamesfredley (via GitHub)" <gi...@apache.org> on 2026/02/15 17:54:25 UTC

Re: [I] Move Guides Publishing to grails.apache.org/guides [grails-static-website]

jamesfredley commented on issue #354:
URL: https://github.com/apache/grails-static-website/issues/354#issuecomment-3904912096

   # Grails Guides: Publishing Architecture and Migration Plan
   
   This document describes how Grails guide publishing works today, what's wrong with
   it, and the plan to consolidate everything into `apache/grails-static-website`.
   
   ---
   
   ## Table of Contents
   
   ### Part 1 -- Current System
   
   - [Numbers at a Glance](#numbers-at-a-glance)
   - [Current Architecture Overview](#current-architecture-overview)
   - [Complete Repo Inventory](#complete-repo-inventory)
     - [Repos with both initial/ and complete/ (79)](#repos-with-both-initial-and-complete-directories-79)
     - [Repos with complete/ only (14)](#repos-with-complete-only----no-initial-14)
     - [Repos with neither (1)](#repos-with-neither-initial-nor-complete-1)
     - [Repos not published in guides.json (10)](#repos-in-org-but-not-published-in-guidesjson-10)
     - [Known data issues](#known-data-issues)
   - [Grails Version Analysis](#grails-version-analysis)
     - [Branch distribution across repos](#branch-distribution-across-repos)
     - [Published versions in guides.json](#published-versions-in-guidesjson)
     - [What "multi-version" really means today](#what-multi-version-really-means-today)
     - [Unpublished branch analysis](#unpublished-branch-analysis)
   - [How Publishing Works Today](#how-publishing-works-today)
     - [Step 1: Scaffold the guide](#step-1-scaffold-the-guide)
     - [Step 2: Create the GitHub repo](#step-2-create-the-github-repo)
     - [Step 3: Set up CI manually](#step-3-set-up-ci-manually)
     - [Step 4: CI runs and the guide gets published](#step-4-ci-runs-and-the-guide-gets-published)
     - [Step 5: The guide appears on guides.grails.org](#step-5-the-guide-appears-on-guidesgrailsorg)
   - [Current CI/CD Flow Diagram](#current-cicd-flow-diagram)
   - [Current Component Reference](#current-component-reference)
   - [Current Pain Points](#current-pain-points)
   
   ### Part 2 -- Migration Plan
   
   - [Consolidation Plan](#consolidation-plan)
     - [Phase 1: Move guide metadata here](#phase-1-move-guide-metadata-here)
     - [Phase 2: Move common doc snippets and theme resources here](#phase-2-move-common-doc-snippets-and-theme-resources-here)
     - [Phase 3: Move guide-build.gradle here](#phase-3-move-guide-buildgradle-here)
     - [Phase 4: Move ALL guide AsciiDoc source here](#phase-4-move-all-guide-asciidoc-source-here)
     - [Phase 5: Simplify deployment](#phase-5-simplify-deployment)
     - [Phase 6: Strip doc-publishing from guide repos](#phase-6-strip-doc-publishing-from-guide-repos)
     - [Phase 7: Wind down grails-guides-template](#phase-7-wind-down-grails-guides-template)
     - [URL Redirect Plan](#url-redirect-plan)
   - [Migration Checklist](#migration-checklist)
   
   ### Part 3 -- New System (After Migration)
   
   - [How Publishing Will Work After Migration](#how-publishing-will-work-after-migration)
     - [Adding a new guide (doc-only)](#adding-a-new-guide-doc-only-no-sample-project)
     - [Adding a new guide with sample projects](#adding-a-new-guide-with-sample-projects)
     - [Updating an existing guide](#updating-an-existing-guide)
     - [Adding a new Grails version to an existing guide](#adding-a-new-grails-version-to-an-existing-guide)
     - [How versioning simplifies with migration](#how-versioning-simplifies-with-migration)
   - [Comparison: Before vs After](#comparison-before-vs-after)
   - [Key Files Reference](#key-files-reference)
   
   ---
   
   # Part 1 -- Current System
   
   ## Numbers at a Glance
   
   | Metric | Count |
   |--------|-------|
   | Repos in `grails-guides` org (excluding template) | 94 |
   | Repos published in `guides.json` | 84 |
   | Total `guides.json` entries (including multi-version) | 125 |
   | Single-version guides in `guides.json` | 44 |
   | Multi-version guides in `guides.json` | 40 |
   | Repos in org but NOT in `guides.json` (unpublished) | 10 |
   | Repos with both `initial/` and `complete/` dirs | 79 |
   | Repos with `complete/` only (no `initial/`) | 14 |
   | Repos with neither `initial/` nor `complete/` | 1 |
   | Repos with a `grails6` branch | 3 |
   | Repos with a `grails5` branch | 37 |
   | Repos with a `grails-4` or `grails4` branch | 27 |
   | Repos with a `grails3` branch | 40 |
   
   ---
   
   ## Current Architecture Overview
   
   Guide publishing spans **three layers**:
   
   1. **94 individual guide repos** in the `grails-guides` GitHub org (e.g.
      `grails-guides/creating-your-first-grails-app`) -- each is a standalone Grails
      project containing AsciiDoc source, an `initial/` starter project, and a
      `complete/` solution.
   
   2. **`grails-guides/grails-guides-template`** (infrastructure + deployment repo) --
      provides the shared build plugin (`gradle/guide-build.gradle`), guide scaffolding
      script (`create-guide.sh`), CI/CD scripts (`githubactions/`), common AsciiDoc
      snippets (`src/main/docs/`), and theme resources (`src/main/resources/`).
      Its **`gh-pages` branch** stores the built HTML for every guide, the central
      `guides.json` registry, and the guides index site served at
      `https://guides.grails.org`.
   
   3. **`apache/grails-static-website`** (this repo) -- generates the guides *index site*
      (listing, search, tag/category pages) by fetching `guides.json` at build time.
      The output is pushed back to `grails-guides/grails-guides-template` `gh-pages`.
   
   > **Note**: Many CI scripts and individual guide `build.gradle` files still reference
   > the old URL `grails/grails-guides`. This was the former location of
   > `grails-guides/grails-guides-template` before it was moved to the `grails-guides`
   > GitHub org. GitHub redirects mask this, but the references are stale.
   
   ### The `grails-guides` GitHub Org
   
   The `grails-guides` GitHub org contains **95 repositories**:
   
   - **`grails-guides-template`** -- the infrastructure repo described above.
   - **94 individual guide repositories** -- each named after the guide topic.
   
   Every individual guide repo in the org:
   - Has its own GitHub Actions CI workflow (manually set up -- not generated by the
     scaffolding script)
   - May maintain version branches (`master`, `grails3`, `grails4`, `grails5`, `grails6`)
   - Applies `guide-build.gradle` remotely from `grails-guides-template` at Gradle build time
   - Downloads and executes CI scripts from `grails-guides-template` at CI runtime via `curl`
   - Pushes built HTML and metadata to `grails-guides-template` `gh-pages`
   
   ---
   
   ## Complete Repo Inventory
   
   ### Repos with both `initial/` and `complete/` directories (79)
   
   These repos contain runnable sample Grails applications. After migration, these
   repos will **remain in the `grails-guides` org** as sample project repos (with
   doc-publishing stripped out). AsciiDoc source moves to `grails-static-website`.
   
   | Repo | Published | Repo branches |
   |------|-----------|---------------|
   | `adding-commit-info` | Yes (master, grails3) | fix-commits-upgrade, gradle4, grails3, grails-3.2.8, grails-4, master |
   | `angular2-combined` | Yes | grail-4, master |
   | `building-a-react-app` | Yes (master) | master |
   | `building-a-vue-app` | Yes (master) | master, rewrite |
   | `building-an-android-client-powered-by-a-grails-backend` | Yes (master) | master |
   | `building-an-ios-objectc-client-powered-by-a-grails-backend` | Yes (master) | grails5, grails-4, master |
   | `building-an-ios-swift-client-powered-by-a-grails-backend` | Yes (master) | grails5, grails-4, master, upgrade |
   | `command-objects-and-forms` | Yes (master, grails3) | grails3, grails-4, master, tests, upgrade |
   | `creating-your-first-grails-app` | Yes (master, grails3) | grails3, grails4, master, upgrade |
   | `database-per-tenant` | Yes (master) | grails5, ilopmar-patch-1, master |
   | `discriminator-per-tenant` | Yes (master) | grails5, master |
   | `gorm-event-listeners` | Yes (master, grails3) | fix_typos, grails3, grails-4, many-to-many, master, without-tags |
   | `gorm-graphql-with-react-and-apollo` | Yes (master) | master, + many dependabot branches |
   | `gorm-ratpack` | Yes (master, grails3) | grails3, master |
   | `grails_api_ai` | **No** | master |
   | `grails_i18n` | Yes (master, grails3) | grails3, master |
   | `grails_url_mappings` | Yes (master) | grails5, master |
   | `grails-android-security` | **No** | master, upgrade |
   | `grails-as-docker-container` | Yes (master, grails3) | grails3, grails5, grails-4, guide, master |
   | `grails-async-promises` | Yes (master, grails3) | grails3, master |
   | `grails-basicauth` | Yes (master, grails3) | grails3, master |
   | `grails-code-coverage` | Yes (master, grails3) | grails3, master |
   | `grails-codenarc` | Yes (master) | master |
   | `grails-configuration-properties-micronaut` | Yes (grails4, grails6) | basics-g5-changes, grails4, grails5, grails6, master |
   | `grails-controller-testing` | Yes (master, grails3) | grails_three_three, grails3, master |
   | `grails-custom-security-tenant-resolver` | Yes (master, grails3) | current-user-with-spring-security-service, grails3, master |
   | `grails-database-migration` | Yes (grails4, grails3, grails6) | grails3, grails5, grails6, master |
   | `grails-deploy-aws-elastic-beanstalk` | **No** | master |
   | `grails-deploy-heroku` | **No** | master |
   | `grails-deploy-pws` | **No** | grails5, grails-4, master |
   | `grails-docker-external-services` | Yes (master) | grails5, master |
   | `grails-dynamic-multiple-datasources` | Yes (master, grails3) | grails3, grails5, master |
   | `grails-elasticsearch` | Yes | master |
   | `grails-email` | Yes (master, grails3) | grails3, grails5, master |
   | `grails-events` | Yes (master, grails3) | grails3, master |
   | `grails-geb-multiple-browsers` | Yes (master, grails3) | grails3, master |
   | `grails-google-cloud` | Yes (master) | grails5, grails-4, grails-gr8-demos, master |
   | `grails-google-home` | Yes (master) | alvarosanchez-patch-1, grails5, grails-4, master |
   | `grails-gorm-data-services` | Yes (master, grails3) | grails3, grails-4, master |
   | `grails-ios-objectivec-security` | **No** | master, upgrade |
   | `grails-javamelody` | Yes | grails5, master |
   | `grails-logicaldelete` | Yes (master, grails3) | grails3, grails5, master |
   | `grails-micronaut-http` | Yes (grails5) | bean-inject-error, grails5, grails6, master |
   | `grails-mock-basics` | Yes (master, grails3) | grails3, master |
   | `grails-mock-http-server` | Yes (master, grails3) | grails3, grails5, master |
   | `grails-mock-logging-slf4j-test` | Yes (master, grails3) | grails3, grails5, grails-4-datastore-rest-client, grails-4, master |
   | `grails-multi-datasource` | Yes (master, grails3) | error, grails3, grails5, master |
   | `grails-multi-project-build` | Yes (master) | grails5, grails-4, master, upgrade_guide |
   | `grails-oauth-facebook` | **No** | master |
   | `grails-oauth-google` | Yes (master) | grails5, master, suggestions |
   | `grails-oauth-twitter` | Yes (master) | grails5, master |
   | `grails-on-circleci-basics` | Yes (master) | grails5, grails-4, master |
   | `grails-on-github-actions` | Yes (master) | grails5, master |
   | `grails-on-travis-basics` | Yes (master, grails3) | grails3, grails5, master |
   | `grails-rabbitmq` | Yes | grails5, grails-4, master |
   | `grails-restapi-angularjs` | Yes (master) | grails5, grails-4, master |
   | `grails-scheduled` | Yes (grails3, master) | grails3, grails5, grails-4, master, suggestions |
   | `grails-schwartz` | Yes | grails5, master |
   | `grails-soap` | Yes (master, grails3) | grails3, master |
   | `grails-spring-security-core-plugin-custom-authentication` | Yes (master, grails3) | grails3, grails5, master |
   | `grails-taglib-wyswyg-trix` | Yes (master, grails3) | grails3, grails-4, master |
   | `grails-test-domain-class-constraints` | Yes (master, grails3) | errorCodes, grails_three_three, grails3, grails-4, master, unique-constraint |
   | `grails-test-security` | Yes (master, grails3) | grails3, master |
   | `grails-tvmlapp` | Yes (master, grails3) | grails3, grails5, master |
   | `grails-upload-file` | Yes (master, grails3) | grails3, grails5, grails-4, master |
   | `grails-vs-nodejs` | Yes (master) | master |
   | `grails-vue-combined` | Yes (master) | grails-4, master |
   | `grails-yourkit-profiling` | Yes (master) | grails5, master |
   | `neo4j-movies` | Yes | master, upgrade |
   | `querying-gorm-dynamic-finders` | Yes (master, grails3) | grails3, master |
   | `react-combined` | Yes | grails-4, master, review |
   | `react-router-spring-security` | **No** | master |
   | `react-spring-security` | Yes (master) | master, upgrade |
   | `rest-hibernate` | Yes (master, grails3) | grails3, grails-4, master |
   | `rest-mongodb` | Yes (master) | master, upgrade |
   | `server-sent-events` | Yes (master, grails3) | grails3, grails5, grails-4, master |
   | `using-hal-with-json-views` | Yes (master, grails3) | grails3, master, upgrade |
   | `vaadin-grails` | Yes | 3.3.0.RC1, grails-4, master, upgrade |
   | `web-app-step-by-step` | **No** | compilestatic, link-to-grails-download, master, sdkman-link, sett, sett-rev |
   
   ### Repos with `complete/` only -- no `initial/` (14)
   
   These repos have a completed sample project but no starter project. The 7
   quickcast repos are video-companion code (no interactive tutorial flow). After
   migration, repos with meaningful sample code remain in the org; doc-only repos
   (quickcasts) can be archived entirely.
   
   | Repo | Published | Repo branches | Notes |
   |------|-----------|---------------|-------|
   | `gorm-without-grails` | Yes (master) | grails3, master | Standalone GORM demo |
   | `grails-configuration-properties` | Yes (master, grails3) | grails3, master | |
   | `grails-elasticbeanstalk` | Yes (master) | grails5, grails-4, master | |
   | `grails-file-download-excel` | Yes (master, grails3) | grails3, grails5, master | |
   | `grails-micronaut-kafka` | Yes (master)* | master | *Typo in `guides.json`: listed as `grails-micronaut-kakfa` |
   | `grails-quickcast-logging` | Yes | master | Quickcast (video companion) |
   | `grails-quickcasts-angularjs-scaffolding-with-grails-3` | Yes | master | Quickcast |
   | `grails-quickcasts-developing-grails-3-applications-with-intellij-idea` | Yes | master | Quickcast |
   | `grails-quickcasts-interceptors` | Yes | master | Quickcast |
   | `grails-quickcasts-json-views` | Yes | master | Quickcast |
   | `grails-quickcasts-multi-project-builds` | Yes | master | Quickcast |
   | `grails-quickcasts-retrieving-config-values` | Yes | master | Quickcast |
   | `using-the-react-profile` | Yes (master) | master | |
   | `using-the-vue-profile` | Yes (master, grails3) | dependabot branch, grails3, master | |
   
   ### Repos with neither `initial/` nor `complete/` (1)
   
   | Repo | Published | Repo branches | Notes |
   |------|-----------|---------------|-------|
   | `grails-acl` | **No** | master | Doc-only, never published |
   
   ### Repos in org but NOT published in `guides.json` (10)
   
   These repos exist in the `grails-guides` org but have no corresponding entry in
   `guides.json`, meaning they do not appear on `https://guides.grails.org`. Their
   CI may never have successfully run `updateGuidesJson`, or they may be incomplete.
   
   | Repo | `initial/` | `complete/` | Branches |
   |------|------------|-------------|----------|
   | `grails_api_ai` | yes | yes | master |
   | `grails-acl` | no | no | master |
   | `grails-android-security` | yes | yes | master, upgrade |
   | `grails-deploy-aws-elastic-beanstalk` | yes | yes | master |
   | `grails-deploy-heroku` | yes | yes | master |
   | `grails-deploy-pws` | yes | yes | grails5, grails-4, master |
   | `grails-ios-objectivec-security` | yes | yes | master, upgrade |
   | `grails-oauth-facebook` | yes | yes | master |
   | `react-router-spring-security` | yes | yes | master |
   | `web-app-step-by-step` | yes | yes | compilestatic, link-to-grails-download, master, sdkman-link, sett, sett-rev |
   
   ### Known data issues
   
   - **Typo in `guides.json`**: `grails-micronaut-kakfa` should be `grails-micronaut-kafka`
   - **Category case inconsistency**: `guides.json` has both `"Grails + DevOps"` (6 guides)
     and `"Grails + Devops"` (1 guide) -- should be unified
   - **Branch value issue**: `grails-on-travis-basics` has a `guides.json` entry with
     `githubBranch` set to `"master,grails3"` (comma inside the value) instead of being
     two separate entries
   - **Underscored repo names**: `grails_api_ai`, `grails_i18n`, `grails_url_mappings`
     use underscores instead of hyphens (inconsistent with all other repo names)
   
   ---
   
   ## Grails Version Analysis
   
   ### Branch distribution across repos
   
   Repos use version branches to maintain guides for different Grails versions. The
   branch naming is inconsistent -- some use `grails4`, others use `grails-4`:
   
   | Branch | Repos | Notes |
   |--------|-------|-------|
   | `master` | 93 | Present in almost every repo; maps to Grails 4 in `guides.json` |
   | `grails3` | 40 | Grails 3.x version |
   | `grails-4` | 25 | Grails 4.x version (hyphenated variant) |
   | `grails4` | 2 | Grails 4.x version (non-hyphenated variant) |
   | `grails5` | 37 | Grails 5.x version |
   | `grails6` | 3 | Grails 6.x version |
   
   **Only 3 repos have been updated to Grails 6**: `grails-configuration-properties-micronaut`,
   `grails-database-migration`, and `grails-micronaut-http`.
   
   ### Published versions in `guides.json`
   
   The `guides.json` branch-to-major-version mapping (in `GuidesFetcher.groovy`):
   
   ```groovy
   BRANCH_TO_MAJOR = [grails3: 3, grails4: 4, master: 4, grails5: 5, grails6: 6]
   ```
   
   This means `master` = Grails 4 in the published guides. The mapping does NOT
   include `grails-4` (hyphenated), so repos with a `grails-4` branch but no
   `grails4` branch are effectively not publishing a separate Grails 4 version --
   their `master` branch serves as the Grails 4 version.
   
   Of the 40 multi-version guides in `guides.json`:
   - **37** have exactly `master` + `grails3` (Grails 4 + Grails 3)
   - **1** has `grails4` + `grails6` (`grails-configuration-properties-micronaut`)
   - **1** has `grails4` + `grails3` + `grails6` (`grails-database-migration`)
   - **1** has a data issue (`grails-on-travis-basics` -- comma-separated in one entry)
   
   ### What "multi-version" really means today
   
   In practice, almost all multi-version guides are just Grails 3 vs Grails 4 (`master`).
   Despite 37 repos having a `grails5` branch and 25 having a `grails-4` branch, these
   branches have NOT been published to `guides.json` because:
   
   1. The `githubactions-build.sh` template curls `build-guide` from
      `grails-guides-template` **`master`** branch
   2. The `master` branch version of `build-guide` only handles `master` and `grails3`
      branches for publishing
   3. The `6.0.x` branch version handles `grails3`-`grails6` but is not used by any
      guide repo's CI
   
   This means the `grails5` branches in 37 repos and `grails-4` branches in 25 repos
   exist in Git but their built docs were **never published**.
   
   ### Unpublished branch analysis
   
   The `grails5` and `grails-4` branches were never published to `guides.json` (see
   above), but many contain distinct content that should be considered for publishing
   during migration. The table below shows each branch's relationship to `master`.
   
   **`grails5` branches (37 repos)**:
   
   | Status | Count | Meaning |
   |--------|-------|---------|
   | Ahead of master | 35 | Branch has unique commits not in master -- **distinct content** |
   | Behind master | 2 | Branch is a subset of master -- no unique content |
   
   | Repo | Ahead | Behind | Status | Commit summary | File-level changes |
   |------|-------|--------|--------|----------------|--------------------|
   | `building-an-ios-objectc-client-powered-by-a-grails-backend` | 1 | 0 | ahead | grails5 upgrade | 19 files. Notable: `AnnouncementService.groovy` modified (5 lines), `AnnouncementControllerSpec.groovy` modified (85 lines) |
   | `building-an-ios-swift-client-powered-by-a-grails-backend` | 1 | 0 | ahead | grails5 upgrade | 20 files. Notable: `AnnouncementService.groovy` modified (4 lines), `AnnouncementControllerSpec.groovy` modified (85 lines) |
   | `database-per-tenant` | 1 | 0 | ahead | grails5 upgrade | 20 files. Notable: codenarc `rules.groovy`, `testrules.groovy`, `codenarc.gradle` removed |
   | `discriminator-per-tenant` | 1 | 0 | ahead | grails5 upgrade | 19 files. Notable: `tenantSelection.adoc` modified (2 lines) |
   | `grails_url_mappings` | 0 | 1 | behind | *(no unique content)* | -- |
   | `grails-as-docker-container` | 1 | 0 | ahead | "grails4 upgrade" *(may be mislabeled)* | 18 files: build infra only |
   | `grails-configuration-properties-micronaut` | 4 | 0 | ahead | grails 5 upgrade + CI updates | 14 files. Notable: `gradle.yml` workflow modified, `githubactions-build.sh` modified |
   | `grails-database-migration` | 1 | 0 | ahead | grails5 upgrade | 18 files: build infra only |
   | `grails-deploy-pws` | 1 | 0 | ahead | grails5 upgrade | 20 files. Notable: `deployWithGradle.adoc` modified (2 lines) |
   | `grails-docker-external-services` | 1 | 0 | ahead | grails 5 upgrade | 18 files: build infra only |
   | `grails-dynamic-multiple-datasources` | 1 | 0 | ahead | grails5 upgrade | 2 files: 2 `build.gradle` only |
   | `grails-elasticbeanstalk` | 1 | 0 | ahead | "grails5 - problem with management endpoint" *(may be incomplete)* | 11 files: build infra only |
   | `grails-email` | 1 | 0 | ahead | grails 5 upgrade | 18 files: build infra only |
   | `grails-file-download-excel` | 1 | 0 | ahead | "grails5 update - geb issue" *(Geb problem noted)* | 11 files: build infra only |
   | `grails-google-cloud` | 1 | 0 | ahead | grails5 upgrade | 19 files. Notable: `UploadBookFeaturedImageService.groovy` modified (5 lines) |
   | `grails-google-home` | 1 | 0 | ahead | grails 5 upgrade | 16 files: build infra only |
   | `grails-javamelody` | 1 | 0 | ahead | grails5 upgrade | 22 files. Notable: 2 `GebConfig.groovy` removed, `gettingStarted.adoc` modified (2 lines) |
   | `grails-logicaldelete` | 1 | 0 | ahead | "grails5 upgrade - geb issue" *(Geb problem noted)* | 18 files: build infra only |
   | `grails-micronaut-http` | 4 | 0 | ahead | CI/config updates | 3 files: `gradle.yml`, `githubactions-build.sh`, `gradle.properties` only |
   | `grails-mock-http-server` | 1 | 0 | ahead | grails5 | 19 files. Notable: `OpenweathermapServiceSpec.groovy` modified (2 lines) |
   | `grails-mock-logging-slf4j-test` | 1 | 0 | ahead | grails5 changes | 18 files: build infra only |
   | `grails-multi-datasource` | 1 | 0 | ahead | grails 5 upgrade | 19 files. Notable: `MultipleDataSourceSpec.groovy` modified (16 lines) |
   | `grails-multi-project-build` | 1 | 0 | ahead | grails5 upgrade | 11 files: build infra only |
   | `grails-oauth-google` | 1 | 0 | ahead | grails5 upgrade | 20 files. Notable: `howto.adoc` modified (4 lines) |
   | `grails-oauth-twitter` | 2 | 0 | ahead | grails 5 upgrade (2 commits) | 22 files. Notable: `GebConfig.groovy` removed, `howto.adoc` modified (4 lines) |
   | `grails-on-circleci-basics` | 1 | 0 | ahead | grails5 upgrade | 18 files: build infra only |
   | `grails-on-github-actions` | 1 | 0 | ahead | grails5 upgrade | 17 files: build infra only |
   | `grails-on-travis-basics` | 1 | 0 | ahead | grails5 upgrade | 20 files. Notable: `DefaultHomePageSpec.groovy` modified (3 lines), `integrationTest.adoc` modified (2 lines) |
   | `grails-rabbitmq` | 2 | 0 | ahead | grails5 upgrade (2 commits) | 35 files. Notable: `BookPageViewDataServiceSpec.groovy` modified (2 lines), `buildingAnalytics.adoc` modified (4 lines) |
   | `grails-restapi-angularjs` | 1 | 0 | ahead | grails5 upgrade | 19 files: build infra only |
   | `grails-scheduled` | 1 | 0 | ahead | grails 5 upgrade | 18 files: build infra only |
   | `grails-schwartz` | 1 | 0 | ahead | grails5 upgrade | 21 files. Notable: 2 `GebConfig.groovy` removed |
   | `grails-spring-security-core-plugin-custom-authentication` | 1 | 0 | ahead | grails5 upgrade | 18 files: build infra only |
   | `grails-tvmlapp` | 1 | 0 | ahead | grails5 upgrade | 18 files. Notable: `QuickcastSpec.groovy` modified (3 lines) |
   | `grails-upload-file` | 1 | 0 | ahead | grails5 upgrade | 19 files. Notable: codenarc `rules.groovy` removed |
   | `grails-yourkit-profiling` | 2 | 0 | ahead | grails5 upgrade (2 commits) | 18 files: build infra only |
   | `server-sent-events` | 0 | 1 | behind | *(no unique content)* | -- |
   
   > **Summary**: 616 files across 35 repos. "Build infra" = gradle wrapper, build.gradle, gradle.properties, application.yml, settings.gradle. Only 15 of 35 repos have any changes beyond build infra. The Grails 5 migration is almost entirely a build/infrastructure upgrade -- only 10 source/test files were touched across all repos.
   
   **`grails-4` branches (25 repos)**:
   
   | Status | Count | Meaning |
   |--------|-------|---------|
   | Diverged from master | 15 | Both branch and master have unique commits -- **distinct content** |
   | Behind master | 10 | Branch is a subset of master -- no unique content |
   
   | Repo | Ahead | Behind | Status | Commit summary | File-level changes |
   |------|-------|--------|--------|----------------|--------------------|
   | `adding-commit-info` | 0 | 16 | behind | *(no unique content)* | -- |
   | `building-an-ios-objectc-client-powered-by-a-grails-backend` | 2 | 1 | diverged | Gradle 5 upgrade, Grails 4 upgrade with client HTTP change | 12 files. Notable: `AnnouncementService.groovy` modified (2 lines), `AnnouncementControllerSpec.groovy` modified (82 lines) |
   | `building-an-ios-swift-client-powered-by-a-grails-backend` | 3 | 1 | diverged | Gradle 5 upgrade, Grails 4 upgrade with HTTP client change, add view plugin | 10 files. Notable: `AnnouncementService.groovy` modified (2 lines), `AnnouncementControllerSpec.groovy` modified (82 lines) |
   | `command-objects-and-forms` | 0 | 32 | behind | *(no unique content)* | -- |
   | `gorm-event-listeners` | 0 | 23 | behind | *(no unique content)* | -- |
   | `grails-as-docker-container` | 0 | 15 | behind | *(no unique content)* | -- |
   | `grails-deploy-pws` | 2 | 1 | diverged | Gradle wrapper upgrade (2 commits) | 9 files: build infra only |
   | `grails-elasticbeanstalk` | 3 | 1 | diverged | Gradle 5, Grails 4 (Spring Boot 2 health actuator change), actuator path fix | 10 files. Notable: `HealthSpec.groovy` modified (2 lines), `deploy.adoc` modified (2 lines), `writingTheApp.adoc` modified (88 lines) |
   | `grails-google-cloud` | 3 | 1 | diverged | Gradle upgrade, Grails 4 (pathingJar needed), test correction | 11 files. Notable: `UploadBookFeaturedImageService.groovy` modified (5 lines), `BookSpec.groovy` modified (8 lines) |
   | `grails-google-home` | 3 | 2 | diverged | Gradle 4 upgrade, Grails 4 upgrade, remove codenarc | 10 files. Notable: codenarc `rules.groovy` removed, `codenarc.gradle` removed |
   | `grails-gorm-data-services` | 2 | 22 | diverged | Upgrade to Grails 4 (2 commits) | 11 files. Notable: `BootStrap.groovy` modified (1 line), 2 `.gradle` cache files removed |
   | `grails-mock-logging-slf4j-test` | 0 | 14 | behind | *(no unique content)* | -- |
   | `grails-multi-project-build` | 0 | 28 | behind | *(no unique content)* | -- |
   | `grails-on-circleci-basics` | 0 | 23 | behind | *(no unique content)* | -- |
   | `grails-rabbitmq` | 2 | 1 | diverged | Gradle 5 upgrade, Grails 4 upgrade | 16 files: build infra only (4 subprojects) |
   | `grails-restapi-angularjs` | 2 | 1 | diverged | Gradle 5 upgrade, Grails 4 upgrade | 9 files: build infra only |
   | `grails-scheduled` | 0 | 4 | behind | *(no unique content)* | -- |
   | `grails-taglib-wyswyg-trix` | 0 | 27 | behind | *(no unique content)* | -- |
   | `grails-test-domain-class-constraints` | 26 | 42 | diverged | Grails 3.3.8 -> 4.0 SNAPSHOT: Gradle 5, GORM 7, Hibernate 5.3.7, asset-pipeline, codenarc, remove Geb/mavenLocal, add openjdk11, update views | 83 files. Notable: `.travis.yml` modified, codenarc `rules.groovy` modified, `HotelSpec.groovy` modified, `GebConfig.groovy` added, 44 scaffold assets refreshed (images, JS, CSS, GSP views for Grails 4 templates) |
   | `grails-upload-file` | 9 | 24 | diverged | Gradle wrapper, asset-pipeline, Hibernate core, Grails/GORM versions, replace addResources, codenarc | 20 files. Notable: `HotelController.groovy`, `PointOfInterestController.groovy`, `RestaurantController.groovy` modified (2 lines each) |
   | `grails-vue-combined` | 9 | 1 | diverged | Grails 4 server upgrade, views compile fix (#208), Grails 4 RC1 + Vue profile 2 | 121 files. Full webpack to Vue CLI migration: ~25 webpack/eslint/nightwatch/jest configs removed, Vue CLI configs added, `yarn.lock` added, client assets updated (bootstrap.css 13K lines, grails.css, Welcome.vue), server `Application.groovy` and `logback.groovy` modified, `config.adoc` modified |
   | `react-combined` | 7 | 3 | diverged | Gradle 5, Grails 4 upgrade, views compile fix, fix issue #3 | 13 files. Notable: project restructured (`gradle.properties` moved to `server/`), `writingTheApp.adoc` modified (3 lines) |
   | `rest-hibernate` | 20 | 37 | diverged | Gradle 5, Grails 4.0 SNAPSHOT, GORM 7, remove Geb/mavenLocal, GORM Data Service, @CompileStatic, codenarc, add func tests | 27 files. Notable: `ProductController.groovy` modified (8 lines), `BootStrap.groovy` modified (3 lines), `ProductService.groovy` added (9 lines), `HomeSpec.groovy` added (37 lines), `ProductFunctionalSpec.groovy` added (157 lines), `ProductControllerSpec.groovy` modified (15 lines), codenarc configs modified |
   | `server-sent-events` | 0 | 18 | behind | *(no unique content)* | -- |
   | `vaadin-grails` | 2 | 1 | diverged | Gradle 5 + Grails upgrade, guide text changes | 16 files. Notable: `UrlMappings.groovy` modified, `DemoGrailsUI.groovy` modified, `README.md` modified, `gettingStarted.adoc`, `profile.adoc`, `toc.yml` modified |
   
   > **Summary**: 377 files across 15 diverged repos. More diverse than Grails 5 due to the Grails 3-to-4 scope. Outliers: `grails-vue-combined` (121 files, webpack to Vue CLI migration), `grails-test-domain-class-constraints` (83 files, scaffold assets), `rest-hibernate` (27 files, new service + functional tests). The 10 "behind" branches are subsets of master with no unique work.
   
   **`grails4` branches (2 repos)**:
   
   | Repo | Ahead | Behind | Status |
   |------|-------|--------|--------|
   | `creating-your-first-grails-app` | 0 | 30 | behind (no unique content) |
   | `grails-configuration-properties-micronaut` | 0 | 0 | identical to master |
   
   Neither `grails4` branch has unique content.
   
   ---
   
   ## How Publishing Works Today
   
   This is the end-to-end lifecycle of a guide in the current system, from creation
   to appearing on `https://guides.grails.org`:
   
   ### Step 1: Scaffold the guide
   
   An author clones `grails-guides-template` and runs `create-guide.sh`:
   
   ```bash
   ./create-guide.sh my-new-guide
   ```
   
   This script:
   1. Runs `grails create-app my-new-guide` to generate a Grails app
   2. Renames the app directory to `initial/`
   3. Copies `initial/` to `complete/`
   4. Writes `settings.gradle` with `include 'complete', 'initial'`
   5. Copies template files from `src/main/project/` into the guide root
   
   The template files copied are:
   - `build.gradle` -- applies `guide-build.gradle` remotely
   - `gradle.properties` -- metadata stub with `TODO` placeholders
   - `githubactions-build.sh` -- CI entrypoint (curls scripts at runtime)
   - `src/main/docs/guide/` -- AsciiDoc skeleton (`toc.yml`, `gettingStarted.adoc`, etc.)
   
   **What is NOT created by the script:**
   - `.github/workflows/` -- the GitHub Actions workflow YAML must be created manually
   - No CI configuration is auto-generated
   
   ### Step 2: Create the GitHub repo
   
   The author manually:
   1. Creates a new repository in the `grails-guides` org on GitHub
   2. The repo name must match the guide directory name
   3. Pushes the scaffolded project
   
   ### Step 3: Set up CI manually
   
   The author must manually:
   1. Create `.github/workflows/gradle.yml` (or similar) -- there is no template for this
   2. Configure repository secrets: `GH_TOKEN`, `GIT_NAME`, `GIT_EMAIL`
   
   A typical workflow looks like this (from `creating-your-first-grails-app`):
   
   ```yaml
   name: Java CI with Gradle
   on:
     push:
       branches: [ master ]
     pull_request:
       branches: [ master ]
   jobs:
     build:
       runs-on: ubuntu-latest
       steps:
       - uses: actions/checkout@v2
       - name: Set up JDK 11
         uses: actions/setup-java@v2
         with:
           java-version: '11'
           distribution: 'adopt'
           cache: gradle
       - name: Run script
         run: ./githubactions-build.sh
         env:
           GH_TOKEN: ${{ secrets.GH_TOKEN }}
           GIT_NAME: ${{ secrets.GIT_NAME }}
           GIT_EMAIL: ${{ secrets.GIT_EMAIL }}
   ```
   
   Note: many of these workflows are outdated (actions/checkout@v2, JDK 11, adopt
   distribution). The `grails-guides-template` README still references Travis CI as
   the CI system, with instructions for `travis encrypt` -- GitHub Actions was
   retrofitted without updating the docs.
   
   ### Step 4: CI runs and the guide gets published
   
   On push, the workflow runs `./githubactions-build.sh`. This is where it gets wild:
   
   #### 4a. `githubactions-build.sh` (in the guide repo)
   
   This script does **not** contain any build logic. It downloads and executes scripts
   from `grails-guides-template` at runtime via `curl`:
   
   ```bash
   #!/bin/bash
   set -e
   export EXIT_STATUS=0
   
   # Download the build script from grails-guides-template master branch at runtime
   curl -O https://raw.githubusercontent.com/grails/grails-guides/master/githubactions/build-guide
   chmod 777 build-guide
   ./build-guide || EXIT_STATUS=$?
   
   if [[ $EXIT_STATUS -ne 0 ]]; then
       echo "build-guide failed"
       exit $EXIT_STATUS
   fi
   
   # Download the republish script from grails-guides-template master branch at runtime
   curl -O https://raw.githubusercontent.com/grails/grails-guides/master/githubactions/republish-guides-website.sh
   chmod 777 republish-guides-website.sh
   ./republish-guides-website.sh || EXIT_STATUS=$?
   
   exit $EXIT_STATUS
   ```
   
   Every single guide repo has an identical copy of this file. All 94 of them `curl`
   the same two scripts from `grails-guides-template` `master` branch (using the old
   `grails/grails-guides` URL).
   
   #### 4b. `build-guide` (curled from `grails-guides-template`)
   
   This is the actual build logic. It:
   
   1. Runs `./gradlew -Dgeb.env=chromeHeadless check` (tests)
   2. Runs `./gradlew publishGuide` (AsciiDoc -> HTML)
   3. **Only for specific branches** (`master`, `grails3`, `grails4`, `grails5`, `grails6`):
      - Clones `grails-guides-template` `gh-pages` branch
      - Runs `./gradlew updateGuidesJson` -- **this is how a guide gets added to
        `guides.json`** (the central registry). The task reads `gradle.properties` and
        upserts the guide's metadata into the JSON file.
      - Copies built HTML to the appropriate directory on `gh-pages`:
        - `master` branch -> `<guide-name>/` (root, no version prefix)
        - `grails3` branch -> `grails3/<guide-name>/`
        - `grails4` branch -> `grails4/<guide-name>/`
        - etc.
      - Commits and pushes to `gh-pages`
   
   **Branch discrepancy**: The `master` branch of `grails-guides-template` has a
   `build-guide` that only handles `master` and `grails3`. The `6.0.x` branch version
   handles `grails3`, `grails4`, `grails5`, `grails6` (not `master`). Since the
   `githubactions-build.sh` template curls from `master`, guides using that template
   only get the older behavior.
   
   #### 4c. `republish-guides-website.sh` (curled from `grails-guides-template`)
   
   After the guide is built, this script **regenerates the entire guides index site**:
   
   1. Clones `grails/grails-static-website` (this repo, using old URL) `master` branch
   2. Runs `./gradlew buildGuide` -- which fetches `guides.json` from `gh-pages` and
      generates index/tag/category HTML pages
   3. Clones `grails-guides-template` `gh-pages` again
   4. Copies `build/dist/*` (the generated index pages) into `gh-pages`
   5. Commits and pushes
   
   This means **every single guide CI build triggers a full rebuild of the entire guides
   index site**. And the same rebuild also runs every 2 hours via the cron job in
   `grails-static-website`'s `publish.yml`.
   
   ### Step 5: The guide appears on `guides.grails.org`
   
   Once the `gh-pages` push completes, the guide is live:
   - Guide HTML: `https://guides.grails.org/<guide-name>/guide/index.html`
   - Versioned: `https://guides.grails.org/grails{N}/<guide-name>/guide/index.html`
   - Listed on the index: `https://guides.grails.org/index.html`
   - Searchable by tags and categories
   
   The guide was added to `guides.json` by `updateGuidesJson` (step 4b) and the index
   was regenerated by `republish-guides-website.sh` (step 4c), so it shows up in the
   listing immediately.
   
   ---
   
   ## Current CI/CD Flow Diagram
   
   ```
   Guide Author pushes to grails-guides/<guide-name>
           |
           v
   .github/workflows/gradle.yml  (manually created, per-repo)
           |
           v
   githubactions-build.sh  (identical in every repo)
           |
           |-- curl build-guide from grails-guides-template master (old URL)
           |-- curl republish-guides-website.sh from grails-guides-template master (old URL)
           |
           v
   build-guide  (downloaded at runtime)
           |
           |-- ./gradlew check                    (run tests)
           |-- ./gradlew publishGuide             (AsciiDoc -> HTML via grails-docs)
           |       |
           |       |-- build.gradle: apply from   (curls guide-build.gradle from
           |       |   grails-guides-template master or 6.0.x, old URL)
           |       |
           |       |-- guide-build.gradle:
           |       |     prepareResources          (downloads grails-guides-template
           |       |                                as ZIP to get theme resources)
           |       |     publishGuide              (uses org.grails:grails-docs:6.0.0-RC1)
           |       |
           |       v
           |   build/docs/guide/index.html
           |
           |-- git clone grails-guides-template gh-pages (old URL)
           |-- ./gradlew updateGuidesJson          (upserts into guides.json)
           |-- cp build/docs -> gh-pages/grails{N}/<guide-name>/
           |-- git push gh-pages
           |
           v
   republish-guides-website.sh  (downloaded at runtime)
           |
           |-- git clone grails-static-website (this repo, old URL)
           |-- ./gradlew buildGuide
           |       |
           |       |-- GuidesFetcher: HTTP GET guides.json from gh-pages
           |       |-- GuidesTask: generates index.html, categories/*.html, tags/*.html
           |       v
           |   build/dist/
           |
           |-- git clone grails-guides-template gh-pages (old URL)
           |-- cp build/dist/* -> gh-pages/
           |-- git push gh-pages
           |
           v
   https://guides.grails.org  (GitHub Pages from grails-guides-template gh-pages)
   ```
   
   Additionally, `grails-static-website`'s own `publish.yml` runs the same
   `./gradlew buildGuide` + push to `grails-guides-template gh-pages` independently:
   - On every push to `master`
   - On a cron schedule every 2 hours
   - On manual `workflow_dispatch`
   
   ---
   
   ## Current Component Reference
   
   ### `grails-guides-template` structure (source branches: `master`, `6.0.x`)
   
   ```
   grails-guides-template/
   ├── create-guide.sh                  # Scaffolds new guide projects
   ├── build.gradle                     # Downloads navbar assets from grails-navbar repo
   ├── gradle/
   │   └── guide-build.gradle           # Shared build plugin applied by all guides
   ├── githubactions/
   │   ├── build-guide                  # CI: test + build + publish guide HTML
   │   └── republish-guides-website.sh  # CI: regenerate guides index site
   ├── src/main/
   │   ├── docs/
   │   │   ├── common-completesolution.adoc
   │   │   ├── common-gradle.adoc
   │   │   ├── common-howto.adoc
   │   │   ├── common-installingGrails.adoc
   │   │   ├── common-requirements.adoc
   │   │   └── ... (~45 common-*.adoc snippets)
   │   ├── project/                     # Template copied into new guides
   │   │   ├── build.gradle
   │   │   ├── gradle.properties
   │   │   ├── githubactions-build.sh
   │   │   └── src/main/docs/guide/
   │   │       ├── toc.yml
   │   │       ├── gettingStarted.adoc
   │   │       ├── howto.adoc
   │   │       ├── requirements.adoc
   │   │       ├── runningTheApp.adoc
   │   │       └── writingTheApp.adoc
   │   └── resources/                   # Theme assets
   │       ├── css/                     # guide.css, screen.css, navbar.css, etc.
   │       ├── fonts/                   # Archia family + FontAwesome
   │       └── img/                     # Grails logo, category icons
   └── README.md                        # Still references Travis CI
   ```
   
   ### `grails-guides-template` `gh-pages` branch (deployment)
   
   ```
   gh-pages/
   ├── guides.json                      # Central metadata registry (125 entries, 84 unique guides)
   ├── index.html                       # Guides listing (generated by static-website)
   ├── categories/*.html                # Per-category pages
   ├── tags/*.html                      # Per-tag pages
   ├── sitemap.xml
   ├── stylesheets/                     # Site CSS
   ├── javascripts/                     # Site JS
   ├── images/                          # Site images
   ├── creating-your-first-grails-app/  # Guide HTML (master branch builds)
   │   └── guide/index.html
   ├── grails3/                         # Grails 3 versioned guides
   │   └── <guide-name>/guide/index.html
   ├── grails4/                         # Grails 4 versioned guides
   ├── grails5/                         # Grails 5 versioned guides
   └── grails6/                         # Grails 6 versioned guides
   ```
   
   ### Individual guide repo structure (e.g. `creating-your-first-grails-app`)
   
   ```
   creating-your-first-grails-app/
   ├── .github/workflows/
   │   └── gradle.yml                   # Manually created, NOT from template
   ├── githubactions-build.sh           # Curls build-guide + republish at runtime
   ├── build.gradle                     # apply from: guide-build.gradle (remote, old URL)
   ├── gradle.properties                # Guide metadata
   ├── settings.gradle                  # include 'complete', 'initial'
   ├── initial/                         # Starter Grails app
   ├── complete/                        # Completed solution
   ├── src/main/
   │   ├── docs/guide/
   │   │   ├── toc.yml
   │   │   └── *.adoc                   # Guide content
   │   └── resources/img/               # Guide-specific images
   ├── gradle/wrapper/                  # Gradle wrapper
   ├── gradlew / gradlew.bat
   └── README.md
   ```
   
   ### Guide `build.gradle`
   
   Every guide's `build.gradle` remotely applies the shared build script. Some reference
   `master`, some `6.0.x`:
   
   ```groovy
   plugins {
       id 'org.asciidoctor.convert' version '1.5.3'
   }
   asciidoctorj {
       version = '1.5.4'
   }
   // NOTE: old URL; actual repo is grails-guides/grails-guides-template
   apply from:"https://raw.githubusercontent.com/grails/grails-guides/master/gradle/guide-build.gradle"
   ```
   
   ### Guide `gradle.properties`
   
   ```properties
   title=Creating your first Grails Application
   subtitle=Learn how to create your first Grails app
   authors=Zachary Klein
   copyright=Copies of this document may be made for your own use and for...
   githubSlug=grails-guides/creating-your-first-grails-app
   githubBranch=master
   category=Grails Apprentice
   tags=mysql,gsp,grails4
   publicationDate=23 Jan 2017
   ```
   
   | Property          | Description                          | Required |
   |-------------------|--------------------------------------|----------|
   | `title`           | Display title                        | Yes      |
   | `subtitle`        | Short description                    | Yes      |
   | `authors`         | Comma-separated authors              | Yes      |
   | `githubSlug`      | Repo identifier (`grails-guides/...`)| Yes      |
   | `githubBranch`    | Git branch                           | Yes      |
   | `tags`            | Comma-separated tags                 | Yes      |
   | `category`        | Must match a predefined category     | Yes      |
   | `publicationDate` | Format: `dd MMM yyyy`                | Yes      |
   
   ### Shared build plugin: `guide-build.gradle`
   
   Applied remotely by every guide. Key tasks:
   
   - **`prepareResources`** -- downloads `grails-guides-template` (branch `6.0.x`) as
     a ZIP from GitHub and extracts theme resources (CSS, fonts, images) and common
     AsciiDoc snippets. This is a network fetch on every build.
   - **`copyLocalGuideImgResources`** -- copies guide-specific images from
     `src/main/resources/img/` into the extracted build tree.
   - **`publishGuide`** -- runs `grails.doc.gradle.PublishGuide` (from
     `org.grails:grails-docs:6.0.0-RC1`) to convert AsciiDoc to HTML.
     Output: `build/docs/guide/single.html` renamed to `build/docs/guide/index.html`.
     Also creates a redirect `build/docs/index.html`.
   - **`updateGuidesJson`** -- reads `gh-pages/guides.json`, upserts the current guide's
     metadata from `gradle.properties`, writes the updated JSON back. This is how a
     guide registers itself in the central metadata file.
   
   Validation at task graph resolution time enforces that all required properties are
   set and not `TODO`.
   
   ### Guides index generation (this repo: `grails-static-website`)
   
   The `buildGuides` Gradle task (registered in `GrailsWebsitePlugin`) orchestrates:
   
   ```
   buildGuides
   ├── genGuides  (GuidesTask)    -- fetches guides.json, generates HTML pages
   ├── copyAssets (AssetsTask)     -- copies CSS/JS/images
   └── genSitemap (SitemapTask)   -- generates sitemap.xml
   ```
   
   **`GuidesFetcher`** (`buildSrc/.../model/guides/GuidesFetcher.groovy`):
   - Fetches `guides.json` from
     `https://raw.githubusercontent.com/grails-guides/grails-guides-template/gh-pages/guides.json`
   - Parses each entry into a `GuideDto`
   - Groups by `githubSlug`; creates `SingleGuide` (one branch) or
     `GrailsVersionedGuide` (multiple branches like grails3/4/5/6)
   - Branch-to-version mapping: `grails3`->3, `grails4`->4, `master`->4, `grails5`->5, `grails6`->6
   - Filters out future-dated guides, sorts newest-first
   
   **`GuidesTask`** (`buildSrc/.../tasks/GuidesTask.groovy`):
   - Calls `GuidesFetcher.fetchGuides()` and `TagUtils.populateTags(guides)`
   - Generates `build/temp/guides.html` (main listing)
   - Generates `build/temp/tags/<tag>.html` for each tag
   - Generates `build/temp/categories/<category>.html` for each category
   - Renders all through the site template into `build/dist/`
   
   **`GuidesPage`** (`buildSrc/.../model/guides/GuidesPage.groovy`):
   - Renders HTML using Groovy `MarkupBuilder`
   - 14 predefined categories with SVG icons
   - Links to `https://guides.grails.org/<name>/guide/index.html` (single)
   - Links to `https://guides.grails.org/grails{N}/<slug>/guide/index.html` (versioned)
   - Features: latest guides list (8 most recent), tag cloud, search box, category grouping
   
   ### Main site publishing (`publish.yml` workflow)
   
   The GitHub Actions workflow runs on push to master, every 2 hours via cron, and on
   manual dispatch:
   
   ```yaml
   # Step 1: Build and publish the main grails.apache.org site
   GRADLE_TASK: build
   GITHUB_SLUG: apache/grails-website
   GH_BRANCH: asf-site-production
   
   # Step 2: Build and publish the guides.grails.org index site
   GRADLE_TASK: buildGuide
   GITHUB_SLUG: grails-guides/grails-guides-template
   GH_BRANCH: gh-pages
   ```
   
   Both steps use `publish.sh`, which runs the Gradle task, clones the target repo's
   deploy branch, copies `build/dist/` into it, and pushes.
   
   ---
   
   ## Current Pain Points
   
   1. **Runtime `curl` of CI scripts** -- every guide CI run downloads `build-guide`
      and `republish-guides-website.sh` from GitHub at runtime. The CI logic is not
      committed to the guide repos; it's fetched live. This is fragile (GitHub outage
      = broken CI) and opaque (the actual CI behavior is invisible in the guide repo).
   
   2. **Remote `apply from` for Gradle builds** -- every guide's `build.gradle` fetches
      `guide-build.gradle` from GitHub at Gradle configuration time. Combined with
      `prepareResources` downloading a ZIP of `grails-guides-template`, a single guide
      build makes 3+ HTTP requests to GitHub before any real work begins.
   
   3. **Cascading index rebuilds** -- every single guide CI build runs
      `republish-guides-website.sh`, which clones this entire repo, runs
      `./gradlew buildGuide`, and pushes the result. If 5 guides push in the same hour,
      the index gets rebuilt 5 times -- plus once more from the cron job. These can also
      race and cause push conflicts on `gh-pages`.
   
   4. **Stale old-URL references everywhere** -- the `githubactions-build.sh` in all 94
      guide repos curls from `grails/grails-guides` (old URL). The `build-guide` and
      `republish-guides-website.sh` scripts clone `grails/grails-guides` (old URL) and
      `grails/grails-static-website` (old URL). Guide `build.gradle` files apply from
      `grails/grails-guides` (old URL). GitHub redirects mask this but add fragility.
   
   5. **`master` vs `6.0.x` branch discrepancy in `build-guide`** -- the `master`
      branch version of `build-guide` only handles `master` and `grails3` branches.
      The `6.0.x` branch version handles `grails3`-`grails6` (not `master`). Since
      the curled template references `master`, guides using the default template only
      publish for `master` and `grails3`. This is why only 3 guides have published
      Grails 6 content despite 3 repos having a `grails6` branch, and why 37 repos
      with `grails5` branches have never published Grails 5 content.
   
   6. **No workflow template in scaffolding** -- `create-guide.sh` copies
      `build.gradle`, `gradle.properties`, `githubactions-build.sh`, and docs, but does
      NOT create `.github/workflows/`. The GitHub Actions workflow must be created and
      configured manually for each guide, leading to inconsistency across 94 repos.
   
   7. **Outdated README** -- the `grails-guides-template` README documents Travis CI
      as the CI system (with `travis encrypt` instructions). GitHub Actions was
      retrofitted without updating the documentation.
   
   8. **Outdated guide workflows** -- many guide repos use `actions/checkout@v2`,
      `actions/setup-java@v2`, JDK 11, and `adopt` distribution. These are years out
      of date.
   
   9. **Overloaded `gh-pages` branch** -- `grails-guides-template` `gh-pages` stores
      built guide HTML (pushed by 94 individual guide CIs), `guides.json` (mutated by
      `updateGuidesJson` from each guide CI), AND the index site (pushed by both
      `republish-guides-website.sh` and `grails-static-website`'s cron). Multiple
      independent CI pipelines write to the same branch concurrently.
   
   10. **`guides.json` as the integration point** -- metadata is serialized to JSON in
       one repo, fetched via HTTP in another. Changes to the schema require coordinating
       across repos.
   
   11. **Stale `grails-docs` dependency** -- the `PublishGuide` task comes from
       `org.grails:grails-docs:6.0.0-RC1`, a Grails-specific doc toolchain. Modern
       AsciiDoctor Gradle plugins are more capable and actively maintained.
   
   12. **94 repos to maintain** -- the `grails-guides` org has 94 individual guide repos
       plus the template. Most target older Grails versions (only 3 have Grails 6
       branches). Maintaining permissions, branch policies, and secrets across all of
       them is significant overhead.
   
   13. **10 unpublished repos** -- 10 repos exist in the org but never made it into
       `guides.json`, so they are invisible on the guides site. Their status is unclear.
   
   14. **Inconsistent branch naming** -- 25 repos use `grails-4` (hyphenated) while 2
       use `grails4` (unhyphenated). The `GuidesFetcher` branch mapping only recognizes
       `grails4`, meaning the 25 repos with `grails-4` branches are not properly mapped.
   
   ---
   
   # Part 2 -- Migration Plan
   
   ## Consolidation Plan
   
   ### Goal
   
   Move all guide AsciiDoc source and publishing infrastructure into
   `grails-static-website` so that:
   - All guide AsciiDoc source lives in this repo
   - Guide metadata is defined in a single YAML file (`conf/guides.yml`)
   - Built guide HTML and the index site are deployed together
   - One CI pipeline handles everything
   - No runtime `curl` of scripts, no remote `apply from`, no cascading rebuilds
   - The `grails-guides` GitHub org retains only repos with `initial/`+`complete/`
     sample applications (79 repos), serving purely as runnable code companions
   
   ### Phase 1: Move guide metadata here
   
   **What**: Create `conf/guides.yml` in this repo containing all guide metadata
   currently in `guides.json`. Stop fetching from GitHub at build time.
   
   **Changes**:
   - Add `conf/guides.yml` with all 84 published guide entries (title, authors, tags,
     category, publication date, Grails versions)
   - Fix the `grails-micronaut-kakfa` typo to `grails-micronaut-kafka`
   - Unify `"Grails + DevOps"` / `"Grails + Devops"` category case
   - Add entries for the 10 currently unpublished repos (or explicitly exclude them)
   - Add a `guides` property to `GrailsWebsiteExtension` pointing to it
   - Modify `GuidesFetcher` to read from a local YAML file instead of a remote JSON URL
   - Update `GuidesTask` to accept the local file as an `@InputFile`
   
   **Benefits**: No more network dependency during builds. Metadata changes are
   version-controlled with the site. Adding/updating a guide is a simple YAML edit + PR.
   
   **Impact on `grails-guides` org**: None yet. Individual guide repos continue to
   function as before; their CI just becomes the non-authoritative source for metadata.
   
   ### Phase 2: Move common doc snippets and theme resources here
   
   **What**: Bring `src/main/docs/common-*.adoc` (~45 files) and `src/main/resources/`
   (CSS, fonts, images) from `grails-guides-template` into this repo.
   
   **Changes**:
   - Copy `grails-guides-template/src/main/docs/common-*.adoc` to `guides/common/`
   - Copy `grails-guides-template/src/main/resources/` to `guides/resources/`
   - Update `guide-build.gradle` (or its replacement) to reference local paths
   - Individual guide repos can still exist but would reference resources from this repo
   
   **Benefits**: Theme and common content are versioned with the site. No ZIP download
   at build time (`prepareResources` task eliminated).
   
   **Impact on `grails-guides-template`**: Its source branches (`master`, `6.0.x`) become
   redundant for `src/main/docs/` and `src/main/resources/`. Only `gh-pages` is still
   needed until Phase 5.
   
   ### Phase 3: Move `guide-build.gradle` here
   
   **What**: Bring the shared Gradle build plugin into `buildSrc/` alongside the existing
   site build logic.
   
   **Changes**:
   - Port `guide-build.gradle` tasks (`publishGuide`, `updateGuidesJson`) into
     `buildSrc/` as proper Gradle task classes (like `GuidesTask` already is)
   - Replace the `org.grails:grails-docs` `PublishGuide` dependency with the standard
     `org.asciidoctor.jvm.convert` Gradle plugin
   - Individual guide repos would `apply from` this repo (or use a published plugin)
   
   **Benefits**: Build logic is testable, type-safe Groovy/Java classes. Modern
   AsciiDoctor tooling. Single place to update build conventions.
   
   **Impact on `grails-guides-template`**: `gradle/guide-build.gradle`,
   `create-guide.sh`, and `githubactions/` become dead code. The repo's source branches
   are now fully superseded.
   
   **Impact on individual guide repos**: Their `build.gradle` must be updated to point
   the `apply from` URL at this repo (or use a published Gradle plugin). This can be
   done incrementally per guide.
   
   ### Phase 4: Move ALL guide AsciiDoc source here
   
   **What**: Move the AsciiDoc source from every guide repo into this repo. This is
   **mandatory** for all 84 published guides and optionally for the 10 unpublished ones.
   
   **Changes**:
   - Create `guides/<guide-name>/` directories containing `guide/toc.yml` and `.adoc` files
   - Add a Gradle task to build all local guides in one pass
   - The `buildGuides` task builds all guide HTML from local AsciiDoc source
   - `conf/guides.yml` references the local path for each guide
   
   **Per-guide migration procedure**:
   
   1. **Copy AsciiDoc**: Copy `src/main/docs/guide/` from the guide repo into
      `guides/<guide-name>/` in this repo
   2. **Copy guide-specific images**: Copy `src/main/resources/img/` into
      `guides/<guide-name>/images/`
   3. **Update `conf/guides.yml`**: Ensure the guide entry has `source: local`
   4. **Remove doc-publishing from the guide repo**: Delete `src/main/docs/`,
      `githubactions-build.sh`, the doc-related parts of `build.gradle`, and
      `gradle.properties` metadata fields used only for doc publishing
   5. **Update the guide repo CI**: If the repo has `initial/`+`complete/` sample
      projects, keep a simplified workflow that only builds/tests the sample apps
      (no more `publishGuide`, `updateGuidesJson`, or `republish-guides-website.sh`).
      If the repo has no sample projects, archive it entirely.
   
   **What happens to each repo category**:
   
   | Category | Count | Action |
   |----------|-------|--------|
   | Has both `initial/` and `complete/` | 79 | AsciiDoc moves here. Repo keeps sample projects with simplified CI. |
   | Has `complete/` only (non-quickcast) | 7 | AsciiDoc moves here. Strip doc-publishing, archive the repo. |
   | Has `complete/` only (quickcast) | 7 | AsciiDoc moves here. Strip doc-publishing, archive the repo. |
   | Has neither dir | 1 | `grails-acl` -- doc-only, never published. Archive the repo. |
   | Unpublished (in org, not in `guides.json`) | 10 | Evaluate each: migrate AsciiDoc if the guide has value; archive if abandoned. |
   
   **Proposed structure**:
   ```
   grails-static-website/
   ├── conf/
   │   └── guides.yml                 <-- all guide metadata (84+ entries)
   ├── guides/
   │   ├── common/                    <-- shared AsciiDoc snippets (from grails-guides-template)
   │   ├── resources/                 <-- theme CSS, fonts, images (from grails-guides-template)
   │   ├── creating-your-first-grails-app/
   │   │   ├── toc.yml
   │   │   ├── images/                <-- guide-specific images
   │   │   └── guide/*.adoc
   │   ├── grails-mock-basics/
   │   │   ├── toc.yml
   │   │   └── guide/*.adoc
   │   └── ... (84+ guide directories)
   ├── buildSrc/
   │   └── .../guides/
   │       ├── GuidesFetcher.groovy      <-- reads conf/guides.yml
   │       ├── GuidesTask.groovy         <-- generates index site
   │       ├── GuideBuildTask.groovy     <-- NEW: builds individual guide HTML
   │       └── ...
   └── .github/workflows/
       └── publish.yml                <-- single workflow for site + guides
   ```
   
   ### Phase 5: Simplify deployment
   
   **What**: Deploy everything (main site + guide HTML + guide index) as part of
   `grails.apache.org`. All guides will be served at `https://grails.apache.org/guides/`.
   
   **Changes**:
   - Build output produces one unified `build/dist/` tree:
     ```
     build/dist/
     ├── index.html                  <-- grails.apache.org homepage
     ├── guides/
     │   ├── index.html              <-- grails.apache.org/guides/ listing
     │   ├── categories/*.html
     │   ├── tags/*.html
     │   └── <guide-name>/
     │       └── guide/index.html    <-- individual guide HTML
     └── ...
     ```
   - A single `publish.yml` step pushes to `apache/grails-website` `asf-site-production`
   - Remove the second `publish.sh` step that pushes to `grails-guides-template` `gh-pages`
   - Eliminate `republish-guides-website.sh` -- no more cascading rebuilds
   - Update `GUIDES_URL` in `GuidesPage.groovy` from `https://guides.grails.org` to
     `https://grails.apache.org/guides`
   
   **New URL structure**:
   - `https://grails.apache.org/guides/` -- guides listing (index)
   - `https://grails.apache.org/guides/<guide-name>/guide/index.html` -- individual guide
   - `https://grails.apache.org/guides/tags/<tag>.html` -- guides by tag
   - `https://grails.apache.org/guides/categories/<category>.html` -- guides by category
   
   **Benefits**: One deploy, one repo, one domain. No cross-repo push
   choreography. No concurrent writes to `gh-pages`. Drastically simpler CI/CD.
   
   ### Phase 6: Strip doc-publishing from guide repos
   
   **What**: After AsciiDoc source has been migrated (Phase 4), strip the doc-publishing
   machinery from every guide repo in the `grails-guides` org.
   
   **Per-repo procedure for repos keeping `initial/`+`complete/` (79 repos)**:
   
   1. Delete `src/main/docs/` (AsciiDoc source -- now in `grails-static-website`)
   2. Delete `githubactions-build.sh` (no longer needed)
   3. Simplify `build.gradle`:
      - Remove the `apply from:` that fetches `guide-build.gradle`
      - Remove the `asciidoctor` plugin
      - Keep only what's needed to build/test `initial/` and `complete/` subprojects
   4. Remove doc-publishing metadata from `gradle.properties` (title, subtitle, authors,
      githubSlug, githubBranch, category, tags, publicationDate)
   5. Update `.github/workflows/gradle.yml`:
      - Remove the `./githubactions-build.sh` step
      - Replace with `./gradlew check` to just run tests on the sample apps
      - Update to modern actions versions (checkout@v4, setup-java@v4, JDK 17+)
      - Remove `GH_TOKEN` / `GIT_NAME` / `GIT_EMAIL` secrets (no longer pushing to gh-pages)
   6. Add a `README.md` note: "Guide content is published from
      [apache/grails-static-website](https://github.com/apache/grails-static-website).
      This repo contains only the sample applications."
   
   **For repos with `complete/` only (14 repos)**:
   
   Same stripping procedure as above, then archive the repo. These repos have no
   `initial/` project, so they do not serve as interactive tutorial companions.
   The `complete/` code is preserved in the archived repo for reference.
   
   **For repos with neither dir (1 repo: `grails-acl`)**:
   
   Archive the repo. It was never published and has no sample code.
   
   ### Phase 7: Wind down `grails-guides-template`
   
   **What**: After consolidation is complete, clean up `grails-guides-template`.
   
   **Impact on `grails-guides/grails-guides-template`**:
   - Source branches (`master`, `6.0.x`) are fully superseded after Phases 2-3 --
     all build logic, common docs, and theme resources now live in `grails-static-website`
   - `gh-pages` branch continues to serve `guides.grails.org` via GitHub Pages, but
     all HTML files are replaced with meta-refresh redirects to `grails.apache.org/guides/`
     (see URL Redirect Plan below)
   - **Action**: Replace all HTML on `gh-pages` with redirect stubs. Archive the repo's
     source branches. The `gh-pages` branch stays live to serve redirects indefinitely.
   
   **Impact on the `grails-guides` org after all phases**:
   
   | What remains | Count | Purpose |
   |-------------|-------|---------|
   | Repos with `initial/`+`complete/` sample apps | 79 | Runnable code companions for guides (no doc-publishing) |
   | Repos archived (`complete/`-only, doc-only, obsolete) | 15 | Read-only reference (14 complete-only + 1 no-code) |
   | `grails-guides-template` | 1 | Source branches archived; `gh-pages` stays live for redirects |
   
   ### URL Redirect Plan
   
   Current guide URLs follow the pattern:
   ```
   https://guides.grails.org/<guide-name>/guide/index.html          (single version)
   https://guides.grails.org/grails{N}/<guide-name>/guide/index.html (versioned)
   ```
   
   After consolidation, guides will be served from `grails.apache.org/guides/`. To
   preserve existing links, the `grails-guides-template` `gh-pages` branch (which
   serves `guides.grails.org` via GitHub Pages) will be updated with meta-refresh
   redirects pointing to the new `grails.apache.org` URLs.
   
   **Redirect implementation**: Replace all HTML files on `gh-pages` with redirect
   stubs using `<meta http-equiv="refresh">`:
   
   ```html
   <!DOCTYPE html>
   <html>
   <head>
     <meta http-equiv="refresh" content="0; url=https://grails.apache.org/guides/<guide-name>/guide/index.html">
     <link rel="canonical" href="https://grails.apache.org/guides/<guide-name>/guide/index.html">
   </head>
   <body>
     <p>This page has moved to
       <a href="https://grails.apache.org/guides/<guide-name>/guide/index.html">
         grails.apache.org/guides/<guide-name>/guide/index.html
       </a>.
     </p>
   </body>
   </html>
   ```
   
   This covers:
   - `https://guides.grails.org/<guide-name>/guide/index.html` ->
     `https://grails.apache.org/guides/<guide-name>/guide/index.html`
   - `https://guides.grails.org/grails{N}/<guide-name>/guide/index.html` ->
     `https://grails.apache.org/guides/grails{N}/<guide-name>/guide/index.html`
   - `https://guides.grails.org/index.html` ->
     `https://grails.apache.org/guides/index.html`
   - `https://guides.grails.org/tags/*.html` ->
     `https://grails.apache.org/guides/tags/*.html`
   - `https://guides.grails.org/categories/*.html` ->
     `https://grails.apache.org/guides/categories/*.html`
   
   The `gh-pages` branch stays live (not archived) to serve redirects indefinitely.
   The `grails-guides-template` repo itself can still be archived since the `gh-pages`
   branch is independent of the source branches.
   
   ---
   
   ## Migration Checklist
   
   **Phase 1 -- Metadata**
   - [ ] Create `conf/guides.yml` from current `guides.json` (84 published + 10 unpublished to evaluate)
   - [ ] Fix typo: `grails-micronaut-kakfa` -> `grails-micronaut-kafka`
   - [ ] Unify category: `"Grails + DevOps"` / `"Grails + Devops"`
   - [ ] Fix data issue: `grails-on-travis-basics` branch value `"master,grails3"`
   - [ ] Update `GuidesFetcher` to read local YAML
   - [ ] Verify index site renders identically
   
   **Phase 2 -- Common docs and resources**
   - [ ] Copy ~45 `common-*.adoc` snippets to `guides/common/`
   - [ ] Copy theme resources (CSS, fonts, images) to `guides/resources/`
   
   **Phase 3 -- Build plugin**
   - [ ] Port `guide-build.gradle` tasks into `buildSrc/`
   - [ ] Replace `grails-docs` with standard `asciidoctor-gradle-plugin`
   - [ ] Add `GuideBuildTask` for building individual guide HTML
   
   **Phase 4 -- Guide AsciiDoc source (all 84 published guides)**
   - [ ] Migrate AsciiDoc from all 79 repos with `initial/`+`complete/`
   - [ ] Migrate AsciiDoc from all 14 repos with `complete/`-only
   - [ ] Migrate AsciiDoc from `grails-acl` (if worth preserving)
   - [ ] Evaluate and migrate the 10 unpublished repos
   - [ ] Verify all guides build and render correctly from local source
   
   **Phase 5 -- Deployment**
   - [ ] Update `publish.yml` to produce unified output under `build/dist/guides/`
   - [ ] Remove second `publish.sh` step for `grails-guides-template`
   - [ ] Update `GUIDES_URL` in `GuidesPage.groovy` to `https://grails.apache.org/guides`
   - [ ] Verify all guide URLs work at `grails.apache.org/guides/`
   
   **Phase 6 -- Strip doc-publishing from guide repos**
   - [ ] Strip doc-publishing from 79 repos with `initial/`+`complete/`
   - [ ] Simplify CI workflows in all retained repos
   - [ ] Archive repos with no meaningful sample code
   - [ ] Remove `GH_TOKEN` / `GIT_NAME` / `GIT_EMAIL` secrets from all repos
   
   **Phase 7 -- Wind down template**
   - [ ] Replace all HTML files on `grails-guides-template` `gh-pages` with meta-refresh redirects to `grails.apache.org/guides/`
   - [ ] Verify all `guides.grails.org` URLs redirect correctly
   - [ ] Archive `grails-guides-template` source branches (`master`, `6.0.x`)
   - [ ] Keep `gh-pages` branch live for redirects
   - [ ] Add deprecation notice to `grails-guides` org description
   
   ---
   
   # Part 3 -- New System (After Migration)
   
   ## How Publishing Will Work After Migration
   
   After consolidation, adding or updating a guide is straightforward. There are no
   repos to create, no CI to configure, no secrets to set up, no scripts to curl.
   
   ### Adding a new guide (doc-only, no sample project)
   
   This is the simplest case -- a guide that is pure documentation with no runnable
   `initial/`+`complete/` sample apps.
   
   1. **Create the guide directory**:
      ```
      guides/my-new-grails8-feature/
      ├── toc.yml
      └── guide/
          ├── intro.adoc
          ├── writingTheApp.adoc
          └── summary.adoc
      ```
   
   2. **Write `toc.yml`** (table of contents):
      ```yaml
      title: My New Grails 8 Feature
      authors:
        - Your Name
      ---
      introduction: intro.adoc
      writingTheApp:
        title: Writing the Application
        guide: writingTheApp.adoc
      summary: summary.adoc
      ```
   
   3. **Write `.adoc` files** using AsciiDoc syntax. Use `include::` to pull in
      shared snippets from `guides/common/` (e.g. `common-requirements.adoc`).
   
   4. **Add an entry in `conf/guides.yml`**:
      ```yaml
      - name: my-new-grails8-feature
        title: "My New Grails 8 Feature"
        subtitle: "Learn how to use the new feature in Grails 8"
        authors: "Your Name"
        category: "Grails Apprentice"
        tags: [grails8, new-feature]
        publicationDate: "15 Feb 2026"
        grailsVersions: [8]
      ```
   
   5. **Build and preview locally**:
      ```bash
      ./gradlew buildGuides
      # Open build/dist/guides/my-new-grails8-feature/guide/index.html
      ```
   
   6. **Submit a PR** to `apache/grails-static-website`. Once merged, the guide is
      automatically built and deployed by `publish.yml`.
   
   That's it. One PR, one repo, automatic deployment.
   
   ### Adding a new guide with sample projects
   
   For guides that include runnable `initial/`+`complete/` Grails applications:
   
   1. **Create the guide content in this repo** (same as above -- steps 1-4).
   
   2. **Add `sampleProjectRepo`** to the guide's `conf/guides.yml` entry:
      ```yaml
      - name: my-new-grails8-guide
        title: "My New Grails 8 Guide"
        subtitle: "A hands-on guide to building with Grails 8"
        authors: "Your Name"
        category: "Grails Apprentice"
        tags: [grails8]
        publicationDate: "15 Feb 2026"
        grailsVersions: [8]
        sampleProjectRepo: "grails-guides/my-new-grails8-guide"
      ```
   
   3. **Create the sample project repo** in the `grails-guides` org:
      ```
      grails-guides/my-new-grails8-guide/
      ├── initial/          <-- starter Grails app
      ├── complete/         <-- finished solution
      ├── settings.gradle   <-- include 'complete', 'initial'
      ├── build.gradle      <-- just builds/tests the sample apps (no doc publishing)
      └── .github/workflows/
          └── build.yml     <-- runs ./gradlew check only
      ```
   
      The sample project repo is simple -- no `githubactions-build.sh`, no
      `apply from:` remote scripts, no doc-publishing metadata, no secrets needed
      for pushing to `gh-pages`. It just builds and tests the sample apps.
   
   4. **Submit two PRs**:
      - PR to `apache/grails-static-website` with the guide content + YAML entry
      - Create the sample project repo with `initial/`+`complete/`
   
   ### Updating an existing guide
   
   1. **Edit the AsciiDoc** in `guides/<guide-name>/guide/*.adoc`.
   2. **Update `conf/guides.yml`** if metadata changed (e.g. adding a new Grails version).
   3. **Submit a PR**. That's it.
   
   If the guide has a sample project repo in `grails-guides`, update the sample code
   there in a separate PR.
   
   ### Adding a new Grails version to an existing guide
   
   1. **Update `conf/guides.yml`** to add the version:
      ```yaml
      grailsVersions: [6, 7, 8]  # was [6, 7]
      ```
   
   2. **Update AsciiDoc** if there are version-specific instructions (use AsciiDoc
      conditionals or separate sections as needed).
   
   3. **Update the sample project repo** (if one exists) -- either update the existing
      `initial/`+`complete/` to the new Grails version, or add versioned directories
      (e.g. `complete-grails8/`) if multiple versions need distinct sample code.
   
   4. **Submit a PR**.
   
   ### How versioning simplifies with migration
   
   In the current system, multi-version guides require maintaining separate Git branches
   per Grails version, each with its own CI pipeline writing to `gh-pages`. After
   consolidation:
   
   1. **No more multi-branch complexity**: Guide version metadata lives in
      `conf/guides.yml` with a simple `grailsVersions: [4, 5, 6]` list. The AsciiDoc
      source is a single copy with version-conditional content if needed.
   
   2. **Publish previously unpublished versions**: The 35 `grails5` branches with
      distinct content and the 15 `grails-4` branches with distinct content can now
      be published by adding their AsciiDoc and sample code during migration. These
      were never published only because the CI scripts didn't handle them.
   
   3. **Explicit version support**: Instead of inferring version from branch names
      with inconsistent patterns (`grails4` vs `grails-4`), each guide declares
      supported versions explicitly in `conf/guides.yml`.
   
   4. **Single-pass build**: One Gradle build generates all guide HTML for all versions,
      instead of each version branch triggering separate CI pipelines that race to
      write to `gh-pages`.
   
   5. **Easy to add Grails 7/8 guides**: Adding a new guide version is a YAML edit,
      not a branch + CI + secrets setup.
   
   ---
   
   ## Comparison: Before vs After
   
   | | Current system | After migration |
   |---|---|---|
   | **Create a new guide** | Run `create-guide.sh`, create GitHub repo, manually create workflow YAML, configure 3 secrets, push | Create `guides/<name>/` dir + `conf/guides.yml` entry, submit PR |
   | **Where guide content lives** | Scattered across 94 repos | Single `guides/` directory in this repo |
   | **How it gets published** | Push to guide repo -> CI curls scripts -> builds AsciiDoc -> pushes to gh-pages -> curls another script -> rebuilds index -> pushes again | Push to this repo -> CI builds everything -> deploys |
   | **CI configuration** | Per-repo workflow YAML + 3 secrets + runtime curl of scripts | Single `publish.yml` in this repo |
   | **Preview changes** | Must push to trigger CI; no local preview | `./gradlew buildGuides` locally |
   | **Add a Grails version** | Create a branch, hope the CI handles it (it probably won't due to `build-guide` branch discrepancy) | Edit one line in `conf/guides.yml` |
   | **Time to publish** | Minutes (CI cascade across repos) | Seconds (single build + deploy) |
   | **Guide URL** | `guides.grails.org/<name>/guide/index.html` | `grails.apache.org/guides/<name>/guide/index.html` |
   | **Repos to maintain** | 94 guide repos + 1 template repo | 1 repo (this one) + sample project repos |
   
   ---
   
   ## Key Files Reference
   
   ### grails-static-website (this repo)
   
   | File | Purpose |
   |------|---------|
   | `buildSrc/.../GrailsWebsitePlugin.groovy` | Registers all tasks including `genGuides` and `buildGuides` |
   | `buildSrc/.../tasks/GuidesTask.groovy` | Generates guide index, tag, and category HTML pages |
   | `buildSrc/.../model/guides/GuidesFetcher.groovy` | Fetches and parses `guides.json` from GitHub |
   | `buildSrc/.../model/guides/GuidesPage.groovy` | Renders guide listing HTML (categories, tags, search) |
   | `buildSrc/.../model/guides/Guide.groovy` | Guide interface (category, name, title, tags, etc.) |
   | `buildSrc/.../model/guides/SingleGuide.groovy` | Guide with one Grails version |
   | `buildSrc/.../model/guides/GrailsVersionedGuide.groovy` | Guide spanning multiple Grails versions |
   | `.github/workflows/publish.yml` | CI: builds main site + guides index, pushes to deploy branches |
   | `publish.sh` | Runs Gradle build, clones deploy repo, copies output, pushes |
   
   ### grails-guides/grails-guides-template (infrastructure + deployment repo)
   
   | File | Branch | Purpose |
   |------|--------|---------|
   | `gradle/guide-build.gradle` | `master`, `6.0.x` | Shared build script applied by all guide repos |
   | `create-guide.sh` | `master`, `6.0.x` | Scaffolds a new guide project |
   | `src/main/docs/common-*.adoc` | `master`, `6.0.x` | ~45 reusable AsciiDoc snippets included by guides |
   | `src/main/resources/` | `master`, `6.0.x` | CSS, fonts, images for guide HTML theme |
   | `src/main/project/` | `master`, `6.0.x` | Template files copied into new guides (build.gradle, gradle.properties, githubactions-build.sh, docs skeleton) |
   | `githubactions/build-guide` | `master` | CI script: test + build + publish a single guide + update guides.json. **`master` version handles `master`+`grails3` only** |
   | `githubactions/build-guide` | `6.0.x` | CI script: same but handles `grails3`-`grails6` (not `master`) |
   | `githubactions/republish-guides-website.sh` | `master`, `6.0.x` | CI script: clone static-website, rebuild index, push to gh-pages |
   | `gh-pages` branch | -- | Deployment: built guide HTML, `guides.json` (125 entries), and index site |
   | `README.md` | `master`, `6.0.x` | Still references Travis CI as the CI system |
   
   ### Individual guide repos (grails-guides org, 94 repos)
   
   | File | Purpose |
   |------|---------|
   | `.github/workflows/gradle.yml` | GitHub Actions workflow -- manually created, NOT from template. Often outdated (actions@v2, JDK 11). |
   | `githubactions-build.sh` | CI entrypoint. Identical in every repo. Curls `build-guide` and `republish-guides-website.sh` from `grails-guides-template` `master` at runtime. |
   | `build.gradle` | Applies `guide-build.gradle` remotely via `apply from:` URL (old `grails/grails-guides` URL). Some point to `master`, some to `6.0.x`. |
   | `gradle.properties` | Guide metadata (title, authors, tags, category, publicationDate, githubSlug, githubBranch) |
   | `settings.gradle` | Includes `complete` and `initial` subprojects |
   | `src/main/docs/guide/toc.yml` | Table of contents for the guide |
   | `src/main/docs/guide/*.adoc` | AsciiDoc source files |
   | `src/main/resources/img/` | Guide-specific images |
   | `initial/` | Starter Grails app for the reader to begin with (79 repos) |
   | `complete/` | Finished Grails app -- the solution (93 repos) |
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@grails.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org