Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 54 additions & 35 deletions .agents/skills/gradle-best-practices/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: gradle-best-practices
description: Gradle conventions for template-based Grails plugin repositories — convention plugins in build-logic/, lazy configuration APIs, extension configuration, and build structure rules. Use when writing or modifying any build.gradle file or convention plugin.
description: Gradle conventions for template-based Grails plugin repositories — convention plugins in conventions/, lazy configuration APIs, extension configuration, and build structure rules. Use when writing or modifying any build.gradle file or convention plugin.
---

# Gradle Best Practices
Expand All @@ -9,7 +9,7 @@ description: Gradle conventions for template-based Grails plugin repositories

This skill covers Gradle best practices for this project, including convention plugins, extension configuration,
lazy APIs, and build structure. Convention plugins remove duplication across subprojects by centralizing shared
build logic. They live in the `build-logic/` composite build and are applied by ID in each subproject's `build.gradle`.
build logic. They live in the `conventions/` composite build and are applied by ID in each subproject's `build.gradle`.

## Core Rules

Expand All @@ -36,10 +36,10 @@ allprojects {
}
```

Instead, create a convention plugin in `build-logic/` and apply it in each subproject that needs it:
Instead, create a convention plugin in `conventions/` and apply it in each subproject that needs it:

```groovy
// GOOD - build-logic/src/main/groovy/config.compile.gradle
// GOOD - conventions/src/main/groovy/config.compile.gradle
plugins {
id 'groovy'
}
Expand All @@ -59,12 +59,12 @@ root level.

### Use the composite build pattern

Convention plugins reside in `build-logic/`, which is included as a composite build via `settings.gradle`:
Convention plugins reside in `conventions/`, which is included as a composite build via `settings.gradle`:

```groovy
pluginManagement {
includeBuild('./build-logic') {
it.name = 'build-logic'
includeBuild('./conventions') {
it.name = 'conventions'
}
}
```
Expand All @@ -74,55 +74,74 @@ pluginManagement {
Convention plugin files follow the pattern:

```
build-logic/src/main/groovy/config.<purpose>.gradle
conventions/src/main/groovy/config.<purpose>.gradle
```

The plugin ID matches the filename (minus the `.gradle` extension). For example:

- `config.compile.gradle` -> plugin ID `config.compile`

### Declare external plugin dependencies in build-logic/build.gradle
### Declare external plugin dependencies in conventions/build.gradle

When a convention plugin applies a third-party plugin, that plugin must be declared as an `implementation` dependency in
`build-logic/build.gradle`:
When a convention plugin applies a third-party plugin, that plugin must be declared as a dependency in
`conventions/build.gradle`. Plugins whose version does not depend on the Grails line are `implementation`
dependencies. The Grails, asset-pipeline and grails-publish Gradle plugins follow the Grails line (7.x for Grails 7 on
Gradle 8, 8.x for Grails 8 on Gradle 9), so they are only `compileOnly`: a runtime dependency would land in the
published POM and force the conventions' `grailsVersion` on every consumer.

```groovy
// build-logic/build.gradle
// conventions/build.gradle
plugins {
id 'groovy-gradle-plugin'
}
dependencies {
implementation platform("org.apache.grails:grails-bom:${gradleProperties.grailsVersion}")
implementation 'org.apache.grails:grails-gradle-plugins'
implementation "com.adarshr:gradle-test-logger-plugin:${gradleProperties.testLoggerVersion}"
implementation 'cloud.wondrify:asset-pipeline-gradle'
implementation 'org.apache.grails.gradle:grails-publish'
implementation "com.adarshr:gradle-test-logger-plugin:${testLoggerVersion}"
compileOnly platform("org.apache.grails:grails-bom:${grailsVersion}")
compileOnly 'org.apache.grails:grails-gradle-plugins'
compileOnly 'cloud.wondrify:asset-pipeline-gradle'
compileOnly 'org.apache.grails.gradle:grails-publish'
}
```

### Reading root gradle.properties from build-logic

Gradle already exposes every key in the root `gradle.properties` as a project property on each subproject automatically
-- no `allprojects {}` loop or other propagation code is needed for that. That's how `plugin/build.gradle` and
`docs/build.gradle` can reference `projectVersion`, `projectGroup`, and `grailsVersion` directly.

`build-logic/build.gradle` is a separate case: it is its own build (a composite build root), so it does not
automatically see the main build's `gradle.properties`. It reads the file directly into a local `Properties` object
just to resolve the third-party plugin coordinates it declares as dependencies:
The consuming build provides them at its own `grailsVersion`, on the settings classpath that every project inherits.
This block in `settings.gradle` sits between `grails-plugin-conventions` marker comments and is kept up to date by
files-sync:

```groovy
// build-logic/build.gradle
def gradleProperties = new Properties()
file('../gradle.properties').withInputStream { gradleProperties.load(it) }
dependencies {
implementation platform("org.apache.grails:grails-bom:${gradleProperties.grailsVersion}")
implementation "com.adarshr:gradle-test-logger-plugin:${gradleProperties.testLoggerVersion}"
// ...
buildscript {
repositories {
maven { url = 'https://repo.grails.org/grails/restricted' }
}
dependencies {
classpath platform("org.apache.grails:grails-bom:${grailsVersion}")
classpath 'org.apache.grails:grails-gradle-plugins'
classpath 'cloud.wondrify:asset-pipeline-gradle'
classpath 'org.apache.grails.gradle:grails-publish'
}
}
```

`GrailsLineSpec` in `conventions/src/test` builds a consumer on each supported Grails line (the Grails 8 one runs only
on Java 21+).

### Versions inside conventions

`conventions/` is its own build, so it does not see the main build's `gradle.properties`. It has a `gradle.properties`
of its own that pins the versions of the third-party Gradle plugins it builds on (`grailsVersion`,
`asciidoctorVersion`, `testLoggerVersion`) — this is what lets it be published and consumed on its own later.
`grailsVersion` there is only compiled against; at runtime the consumer's `grailsVersion` decides (see above).
Tool versions used by the convention plugins (`checkstyleVersion`, `codenarcVersion`, `jacocoVersion`) have defaults
inside the plugins; a consuming repo overrides them with a property of the same name in its root `gradle.properties`.

The checkstyle/codenarc rule sets are bundled in `conventions/src/main/resources/code-style/` and extracted into each
project's `build/code-style-config/` by `extractCodeStyleConfig`. `config.docs` derives the plugin/docs project paths
from the root project name; override them with `pluginDocs { pluginProject = ':x'; docsProject = ':y' }`.

`conventions/` has TestKit tests (`./gradlew -p conventions test`). Run them with Gradle's embedded Groovy 3, which is
why the build pins the `groovy-3.0` Spock variant.

## Avoid Eager Initialization

Always use lazy/deferred APIs to avoid eagerly resolving tasks or configurations:
Expand Down Expand Up @@ -221,7 +240,7 @@ plugins {
| `app-run.gradle` | Debug flags for `bootRun` |
| `code-coverage.gradle` | JaCoCo coverage for project (XML + HTML reports) |
| `code-coverage-aggregate.gradle` | JaCoCo coverage aggregation across subprojects (XML + HTML reports) |
| `code-style.gradle` | Checkstyle + CodeNarc code style checking (configs in `build-logic/config/`) |
| `code-style.gradle` | Checkstyle + CodeNarc code style checking (configs in `conventions/config/`) |
| `compile.gradle` | Java/Groovy compilation settings (UTF-8, incremental, Java release from `.sdkmanrc`) |
| `docs.gradle` | Documentation aggregation (Groovydoc + Asciidoctor) |
| `example-app.gradle` | Example app config (app-run, compile, grails-assets, testing, grails-web, GSP) |
Expand Down
11 changes: 6 additions & 5 deletions .agents/skills/repository-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ my-plugin/
│ └── dependency-graph/
│ └── external-references.yml # Maven Central package association
│
├── build-logic/ # Gradle convention plugins (composite build)
├── conventions/ # Gradle convention plugins (composite build)
│ ├── build.gradle # Plugin dependencies (groovy-gradle-plugin)
│ ├── settings.gradle # Build-logic project settings
│ ├── config/ # Shared code style config files
Expand Down Expand Up @@ -129,7 +129,7 @@ All tests requiring a running Grails application live in example apps under `exa

### 4. Build logic is centralized

Convention plugins in `build-logic/` eliminate all duplication:
Convention plugins in `conventions/` eliminate all duplication:

- Compilation settings: `config.compile.gradle`
- Test configuration: `config.testing.gradle`
Expand Down Expand Up @@ -172,8 +172,8 @@ These are available in all subprojects as project properties (`projectVersion`,

## Adding a New Convention Plugin

1. Create a new file: `build-logic/src/main/groovy/config.<name>.gradle`
2. If the plugin applies third-party plugins, add their dependencies to `build-logic/build.gradle`
1. Create a new file: `conventions/src/main/groovy/config.<name>.gradle`
2. If the plugin applies third-party plugins, add their dependencies to `conventions/build.gradle`
3. Apply the new plugin ID in the relevant subproject(s)
4. Keep the plugin focused on a single concern

Expand Down Expand Up @@ -208,7 +208,8 @@ These are available in all subprojects as project properties (`projectVersion`,
## SDK Management

The `.sdkmanrc` file pins exact SDK versions. Run `sdk env install` to install them. CI reads `.sdkmanrc` to determine
the Java version dynamically.
the Java version dynamically. `.sdkmanrc` and the Gradle wrapper are not overwritten by the template sync — each repo
owns them, and CI fails if `.sdkmanrc`'s `gradle` version differs from the wrapper's `distributionUrl`.

## CI/CD Pipeline

Expand Down
6 changes: 4 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ updates:
- "*"
exclude-patterns:
- "gradle"
# The wrapper follows the Grails line a repository is on (Gradle 8 for Grails 7, Gradle 9 for Grails 8), so it
# only gets minor and patch updates. Dependabot names the wrapper `gradle-wrapper`, not `gradle`.
ignore:
- dependency-name: "gradle"
versions: [ ">= 9" ]
- dependency-name: "gradle-wrapper"
update-types: [ "version-update:semver-major" ]

# GitHub Actions
- package-ecosystem: "github-actions"
Expand Down
3 changes: 1 addition & 2 deletions .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@
"fileMatch": [
"(^|/)build\\.gradle$",
"(^|/)settings\\.gradle$",
"(^|/)gradle\\.properties$",
"build-logic/src/main/groovy/.*\\.gradle$"
"(^|/)gradle\\.properties$"
]
},
"gradle-wrapper": {
Expand Down
44 changes: 24 additions & 20 deletions .github/scripts/commit-or-pr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
# default branch: repos with branch protection reject the push and the whole job
# fails. This helper tries the direct push first — so unprotected repos keep the
# zero-friction behaviour and produce no PR noise — and only if that is rejected
# does it park the commit on a chore branch and open a pull request.
# does it park the commit on a chore branch and open a pull request. That pull
# request is merged only once its checks pass; otherwise it is left open.
#
# Environment:
# COMMIT_MESSAGE required commit subject (include [skip ci] if wanted)
Expand All @@ -20,8 +21,8 @@
# PR_BODY optional pull request body
# BASE_BRANCH optional PR base; defaults to the currently checked-out branch
# GH_TOKEN required for the fallback path only (gh authentication)
# MERGE_WAIT_SECONDS optional how long to wait for checks when the repo has
# auto-merge disabled (default 900)
# MERGE_WAIT_SECONDS optional how long to wait for the pull request's checks
# before giving up on merging it (default 900)
#
# Arguments: the paths to stage, e.g. `project.yml`.

Expand Down Expand Up @@ -77,29 +78,32 @@ else
fi

# --- land the pull request ---------------------------------------------------
# Preferred path: auto-merge, which lands the PR as soon as any required checks
# pass without holding this runner open.
if gh pr merge "${pr_url}" --squash --auto --delete-branch; then
echo "Auto-merge enabled on ${pr_url}"
exit 0
fi
# Merge only when the checks pass. `gh pr merge --auto` is deliberately not used: on a repository with no required
# checks it merges at once, before CI has run, so a failing change would land anyway. Instead wait here for the checks
# to appear and finish; if they fail, time out, or there are none, leave the pull request open for a human.

# Auto-merge is not available — most often because the repository has "Allow
# auto-merge" switched off. Do the waiting here instead of leaving the PR for a
# human: watch the checks, then merge directly.
echo "Auto-merge unavailable — waiting for checks on ${pr_url}, then merging directly"
# Checks attach to the PR shortly after it is opened; give them up to two minutes to register.
check_count=0
for _ in $(seq 1 12); do
check_count=$(gh pr checks "${pr_url}" --json name --jq 'length' 2>/dev/null || echo 0)
[ "${check_count}" -gt 0 ] && break
sleep 10
done

# Checks attach to the PR shortly after it is opened; watching too early makes
# gh report "no checks reported" and return before any have registered.
sleep 20
if [ "${check_count}" -eq 0 ]; then
echo "::warning::No checks reported on ${pr_url} — not merging it automatically; review and merge it manually"
exit 0
fi

wait_seconds="${MERGE_WAIT_SECONDS:-900}"
timeout "${wait_seconds}" gh pr checks "${pr_url}" --watch --fail-fast \
|| echo "Checks did not all pass, timed out after ${wait_seconds}s, or none are configured" \
"— attempting the merge anyway; branch protection remains the authority"
if ! timeout "${wait_seconds}" gh pr checks "${pr_url}" --watch --fail-fast; then
echo "::warning::Checks on ${pr_url} did not all pass (or took longer than ${wait_seconds}s) — not merging it" \
"automatically; fix or merge it manually"
exit 0
fi

if gh pr merge "${pr_url}" --squash --delete-branch; then
echo "Merged ${pr_url}"
else
echo "::warning::Could not merge ${pr_url} automatically — merge it manually"
echo "::warning::Could not merge ${pr_url} automatically (branch protection?) — merge it manually"
fi
37 changes: 37 additions & 0 deletions .github/scripts/groovy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# WARNING: Do not edit this file directly in your own repository.
# It is maintained in the grails-plugin-template repository.
# To make changes, edit it there and they will be synced automatically:
# https://github.com/grails-plugins/grails-plugin-template/tree/main/.github/scripts
#
# Runs a Groovy script in the official Groovy Docker image, so workflows need
# neither a JDK nor sdkman to run the scripts in .github/scripts. The Groovy
# version is independent of .sdkmanrc: the scripts only need Groovy and its
# bundled YAML module.
#
# Usage: .github/scripts/groovy.sh <script.groovy> [args...]
#
# The current directory is mounted at the same path, so relative and absolute
# paths into it keep working, and the script runs as the calling user, so files
# it writes stay owned by that user (and committable by the workflow).
#
# Environment:
# GROOVY_IMAGE overrides the image (default below)
# GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_OUTPUT, FILTER_PROJECT, VERIFY_STRICT
# passed through to the script when set
set -euo pipefail

image="${GROOVY_IMAGE:-groovy:5.0-jdk21}"

docker_args=(--rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "${PWD}:${PWD}" -w "${PWD}")
for var in GITHUB_TOKEN GITHUB_REPOSITORY GITHUB_OUTPUT FILTER_PROJECT VERIFY_STRICT; do
if [ -n "${!var:-}" ]; then
docker_args+=(-e "${var}")
fi
done
# GITHUB_OUTPUT is a runner file outside the workspace
if [ -n "${GITHUB_OUTPUT:-}" ]; then
docker_args+=(-v "${GITHUB_OUTPUT}:${GITHUB_OUTPUT}")
fi

exec docker run "${docker_args[@]}" "${image}" groovy "$@"
1 change: 0 additions & 1 deletion .github/scripts/update-contributors.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// WARNING: Do not edit this file directly in your own repository.
// It is maintained in the grails-plugin-template repository.
// https://github.com/grails-plugins/grails-plugin-template/tree/main/.github/scripts
@Grab('org.apache.groovy:groovy-yaml')
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.yaml.YamlBuilder
Expand Down
1 change: 0 additions & 1 deletion .github/scripts/update-versions.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// WARNING: Do not edit this file directly in your own repository.
// It is maintained in the grails-plugin-template repository.
// https://github.com/grails-plugins/grails-plugin-template/tree/main/.github/scripts
@Grab('org.apache.groovy:groovy-yaml')
import groovy.json.JsonSlurper
import groovy.yaml.YamlBuilder
import groovy.yaml.YamlSlurper
Expand Down
Loading
Loading