You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@grails.apache.org by "jamesfredley (via GitHub)" <gi...@apache.org> on 2026/07/20 12:39:00 UTC

[PR] Automate repository conventions and stage static analysis enforcement [grails-core]

jamesfredley opened a new pull request, #16025:
URL: https://github.com/apache/grails-core/pull/16025

   ## Description
   
   This PR turns recurring, objective review feedback into deterministic repository checks while deliberately leaving subjective engineering judgment in review.
   
   The starting point was an audit of recent merged and proposed changes in the `#16012` through `#15688` PR-number window. The recurring mechanical concerns were action immutability, static-analysis drift, duplicate message keys, agent-skill metadata drift, report trustworthiness, and license/provenance checks. Repeating those comments by hand does not scale. This change creates a ratchet: enforce the clean baseline now, preserve compatibility for broader opt-in analysis, and expand enforcement only as the recorded backlog is resolved.
   
   No Grails runtime API or application behavior changes in this PR.
   
   ### What this moves forward
   
   #### 1. PMD becomes blocking where it is already clean
   
   PMD is now enabled by full Gradle project path for the four modules that produce clean, non-empty PMD XML today:
   
   - `:grails-data-graphql-core`
   - `:grails-data-mongodb-spring-data`
   - `:grails-datasource`
   - `:grails-testing-support-core`
   
   The implementation supports both staged project allowlists and the existing global opt-in flags. It also preserves `grails.code-analysis.ignoreFailures=true` for report-only baseline runs.
   
   Analysis and style reports are now tied to exact task-owned outputs instead of scanning a broad directory. Before analyzers run, active outputs are cleaned. Each analyzer that actually executes writes a relocatable marker containing the configured XML output's root-relative path, so custom directories and filenames remain associated without serializing an absolute machine path. A genuinely missing report always fails while an unscheduled or legitimate `NO-SOURCE` task is omitted. One writer task owns each pair of Markdown summaries, runs after scheduled analyzers, and is reached through a no-output finalizer even when an analyzer fails. Direct single-module analyzer tasks therefore remain usable, consumers cannot race a second report writer, and build-cache relocation remains portable. Marker filenames encode the full project path, so nested projects with the same leaf name cannot collide.
   
   Generated files below each project's configured build directory are excluded from PMD inputs without excluding normal sources when a checkout ancestor happens to be named `build`.
   
   #### 2. Repository conventions have one public Gradle surface
   
   `./gradlew validateRepositoryConventions` now validates the canonical repository root and writes `build/reports/violations/REPOSITORY_CONVENTIONS.md`.
   
   It checks these scenarios:
   
   - Canonical `.agents/skills/*/SKILL.md` files start with valid top-level YAML front matter containing string `name`, `description`, and `license` values.
   - Skill names match their directory and are unique.
   - Every canonical skill path is listed in `AGENTS.md`, and every listed path exists.
   - Workflow and local action-manifest YAML is parsed with SnakeYAML's data-only `SafeConstructor`.
   - Every external `uses` reference is pinned to a lowercase 40-character commit SHA.
   - A given external action uses one consistent SHA across workflows and composite actions.
   - Docker `uses`, Docker action `runs.image`, and workflow job/service container images use immutable lowercase `sha256` digests rather than mutable tags.
   - Duplicate YAML keys and malformed YAML fail closed.
   - Logical keys in `messages*.properties` are unique, including escaped separators and continued lines.
   - Generated/build trees do not create false duplicate-key findings.
   - RAT runs before repository conventions so license provenance remains part of the same developer-facing gate.
   
   The task is wired into `aggregateStyleViolations` and `aggregateViolations` only for the canonical root. Independent builds such as `grails-gradle` retain their existing aggregate style behavior and do not require a local `AGENTS.md`.
   
   #### 3. GitHub Actions are immutable by construction
   
   Remaining symbolic external action references were replaced with verified commit SHAs, including Checkout, Cache, Setup Java, and the shared Grails action repository. The Mongo service image is pinned to its Docker Hub manifest digest rather than the mutable `mongo:8` tag.
   
   The new validator covers ordinary steps, reusable workflow jobs, quoted keys, inline YAML maps, YAML aliases, and repository-local composite actions in any `action.yml` or `action.yaml` location, including a path segment named `build`. Local `./...` references remain valid, but any external actions used inside their manifests are still validated.
   
   #### 4. Release provenance matches RAT provenance
   
   Agent-local state, configuration symlinks, and guidance files excluded from RAT are now excluded from the source ZIP with matching patterns. The source checkout no longer persists GitHub credentials, and Info-ZIP stores any remaining repository symlinks as links rather than dereferencing their targets. This prevents an excluded file or linked external path from being shipped unaudited.
   
   #### 5. Existing deterministic drift is repaired
   
   - Removed the duplicate `spring.security.ui.forgotPassword.email.line4` message key.
   - Corrected stale agent-skill guidance and added the canonical mono-repository integration skill to `AGENTS.md`.
   - Updated the violation-fixer skill with the staged-analysis properties, truthful disabled-tool report semantics, and the new repository convention workflow.
   
   ### Why the rollout is staged
   
   Enabling every analyzer everywhere in this PR would require thousands of unrelated source edits and would make the convention work impossible to review safely. This PR goes as far as the repository can go without mixing a broad cleanup campaign into the build-policy change:
   
   - It makes currently clean, report-producing PMD modules blocking.
   - It keeps global PMD and SpotBugs baseline modes available.
   - It preserves repository-wide SpotBugs execution in separate non-blocking CI advisory passes with distinct artifacts while #16014 tracks task-wiring and detector failures.
   - It reports disabled tools explicitly instead of calling them clean.
   - It files quantified follow-up issues for every known area that cannot be enabled safely yet.
   - It adds the infrastructure needed to extend the allowlists one module at a time.
   
   Subjective concerns such as architecture, compatibility judgment, semantic documentation quality, test sufficiency, and quote/type preferences remain review-only. They are documented for agents but are not encoded as brittle mechanical rules.
   
   ### Remaining work and follow-up issues
   
   | Issue | Remaining work | Why it is not in this PR |
   | :--- | :--- | :--- |
   | #16014 | Stabilize SpotBugs task wiring and detector execution | Broad runs currently expose undeclared generated-output dependencies and detector `IndexOutOfBoundsException` failures. Tool execution must be reliable before enforcement. |
   | #16018 | Triage the provisional 287-finding SpotBugs baseline | The baseline must be regenerated after #16014, then fixed or narrowly documented before modules can become blocking. |
   | #16017 | Resolve 5,551 PMD findings across the remaining root modules | Fixing these findings would create a large, unrelated source diff. The issue records maintained-source counts and the incremental allowlist process. |
   | #16016 | Resolve 113 PMD findings in `grails-gradle-model` | The independent Gradle-plugin build remains report-only until its existing model findings are fixed. |
   | #16015 | Resolve 224 maintained-source PMD findings in Grails Forge and add supported staged CI enforcement | Forge intentionally has no aggregate analysis task, so its explicit source-bearing PMD task set has a separate cleanup and enforcement path. |
   
   The clean PMD baseline was corrected during verification: `grails-scaffolding`, `grails-spring-security`, and `grails-spring-security-acl` initially appeared as zero-finding modules, but their PMD tasks produce no XML because they have no PMD-analyzable Java source. They are not presented as clean and are not allowlisted.
   
   ### Suggested next steps
   
   1. Resolve #16014 so SpotBugs completes without task-wiring or detector failures.
   2. Regenerate and triage the SpotBugs baseline in #16018, then introduce a staged SpotBugs project allowlist.
   3. Start PMD cleanup with the smallest root modules in #16017 and add each report-producing clean module to the blocking allowlist in the same PR.
   4. Complete the focused `grails-gradle-model` cleanup in #16016.
   5. Clean Forge's 224 maintained-source findings from the smallest modules upward, then make its explicit source-bearing PMD task set blocking in Forge CI under #16015.
   
   ### Verification
   
   - `build-logic`: `..\gradlew.bat build --no-watch-fs --no-daemon`
   - Independent `grails-gradle`: `.\gradlew.bat aggregateStyleViolations --no-watch-fs --no-daemon --max-workers=2`
   - Independent `grails-gradle` PMD baseline: `.\gradlew.bat aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd=true" "-Pgrails.code-analysis.ignoreFailures=true" --rerun-tasks --no-watch-fs --no-daemon --max-workers=2`; it reports only the 113 maintained-source findings in `:grails-gradle-model` and omits three legitimate `NO-SOURCE` tasks.
   - Direct analyzer surface: `.\gradlew.bat :grails-datasource:pmdMain --no-watch-fs --no-daemon --max-workers=2`; the single requested analyzer and its finalizer succeed without false missing reports for other allowlisted modules.
   - Root selective analysis: `.\gradlew.bat aggregateAnalysisViolations --continue --no-daemon --max-workers=2 --no-watch-fs`
   - Code-analysis CI preserves repository-wide SpotBugs execution as separate `continue-on-error` advisory passes for the root and `grails-gradle`, each uploaded under a distinct artifact name after the enforced PMD reports.
   - Root report-only baseline: `.\gradlew.bat aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd=true" "-Pgrails.code-analysis.ignoreFailures=true" --no-watch-fs --no-daemon --max-workers=2`; 5,551 maintained-source findings across 53 source-bearing modules.
   - Mandatory pre-commit gate: `.\gradlew.bat clean aggregateViolations :grails-test-report:check --continue --no-daemon --no-parallel --max-workers=2 --no-watch-fs`
   - The mandatory run completed 1,984 tasks and generated the full test-report matrix. All convention, style, selective-analysis, and report aggregation tasks passed. The aggregate integration report recorded 3,617 tests, 2 failures, 0 errors, and 98 skipped. One unchanged Windows-only mail example failed because its GSP renders CRLF while the test hard-codes LF; a serial targeted run reproduced the same `Hello\r\nWorld!` versus `Hello\nWorld!` mismatch in `MailServiceSpec`. The unchanged SiteMesh async multiple-layout scenario received a transient HTTP 500 during the full run; an isolated serial rerun of `EndToEndSpec` then passed all 14 tests. Neither example implementation nor test is changed by this PR.
   - Final root `aggregateViolations` after all review fixes and rebasing onto `origin/8.0.x`: 751 tasks completed successfully.
   - Final reports: Checkstyle, CodeNarc, PMD, and repository conventions are clean; SpotBugs explicitly reports `SpotBugs is disabled.`
   - Manual failure path: a quoted mutable action, inline Docker tag, mutable Docker action image, mutable service container, build-path local action, and repository path escape were rejected with structural YAML locations, then the fixtures were removed and the clean task rerun.
   - Invalid PMD root/unknown project paths, configured build-directory exclusion without checkout-path false positives, unsuppressible missing reports, failed-analyzer report writing, stale-output removal after a later writer failure, direct single-project style/analysis, legitimate `NO-SOURCE`, custom report directories and filenames, portable root-relative markers, single-writer ordering, duplicate nested project names, top-level skill metadata typing, duplicate YAML keys, malformed YAML, semantic versus ordinary `uses` fields, recursive build-path actions, repository path escapes, case-insensitive Docker metadata, immutable container images, and configuration-cache reuse are covered by TestKit.
   
   The Windows validation run disabled Gradle file-system watching because Gradle's native `gradle-fileevents.dll` crashed a long-lived daemon. The same checks passed with `--no-watch-fs`; no product or test failure was hidden.
   
   Open PR #15977 also changes the `AGENTS.md` skill inventory. It is not a semantic duplicate of this work, but the documentation overlap should be resolved when either branch is rebased.
   
   ## Contributor Checklist
   
   ### Issue and Scope
   
   - [x] No pre-existing implementation issue covered this cross-cutting convention work. The audit background and rationale are documented above; all discovered legacy cleanup was separated into #16014 through #16018.
   - [x] This PR completes its stated scope: deterministic enforcement infrastructure and the currently clean baseline. The linked follow-up issues are explicitly separate cleanup projects, not unfinished code in this PR.
   - [x] This PR contains one focused change: turn recurring objective repository review conventions into automated checks and a staged analysis ratchet.
   - [x] This PR targets `8.0.x`, the current major-development branch.
   
   ### Code Quality
   
   - [x] Tests were added or updated for every changed build-logic behavior.
   - [ ] The separate `./gradlew build --rerun-tasks` command was not run because no runtime module behavior changed. The mandatory `:grails-test-report:check` matrix did run: 3,617 integration tests were reported before the single unchanged Windows CRLF mismatch described above, and a serial targeted rerun confirmed it. The affected build-logic suite, independent `grails-gradle` surfaces, and 750-task root aggregate gate pass.
   - [x] The project code-style and aggregate violation workflows pass.
   - [x] This PR does not contain mass reformatting or broad legacy-analysis cleanup.
   - [x] Generative tooling used a quality model and the final diff was independently reviewed for goal fit, code quality, security, hands-on QA, and repository context.
   
   ### Licensing and Attribution
   
   - [x] New source files contain Apache License 2.0 headers, and RAT passes.
   - [x] The contribution is original work prepared for this repository.
   - [x] Generative tooling use follows ASF policy and is attributed in the commit metadata.
   
   ### Documentation
   
   - [x] Developer-facing guidance in `AGENTS.md` and the violation-fixer skill documents the new commands, properties, reports, and remediation paths.
   - [x] This build-policy change does not add a Grails user-facing feature, so no What's New entry is required.
   - [x] This change does not alter runtime APIs or require application upgrade steps, so no Upgrade Notes entry is required.
   - [x] The description explains the problem, each enforced scenario, the staged boundary, verification, remaining issues, and next steps.
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3614347515


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,473 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add(sanitizeViolation("${path}: malformed skill front matter: ${exception.message}".toString()))
+            return [:]
+        }

Review Comment:
   `frontMatter` adds already-sanitized strings to `violations`, but `writeReport()` and the exception path sanitize again. This can double-escape `|` (Markdown table) and makes violation formatting inconsistent depending on which path produced the message. Prefer storing raw violation text here and sanitizing only at the output boundaries.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,473 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add(sanitizeViolation("${path}: malformed skill front matter: ${exception.message}".toString()))
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add(sanitizeViolation("${path}: skill front matter must be a YAML mapping".toString()))
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add(sanitizeViolation("${path}: skill front matter field '${key}' must be a string".toString()))
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile
+        if (!target.toPath().startsWith(canonicalRoot.toPath())) {
+            violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString())
+            return
+        }
+        if (target.isFile() && target.name ==~ /.*\.ya?ml/) {
+            validateActionManifest(root, target, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        ['action.yml', 'action.yaml'].each { String manifestName ->
+            File manifest = new File(target, manifestName)
+            if (manifest.isFile()) {
+                validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static void validateProperties(File root, List<File> files, List<String> violations) {
+        files.findAll { File file -> file.name.startsWith('messages') && file.name.endsWith('.properties') }.sort().each { File file ->
+            Map<String, Integer> keys = [:]
+            logicalPropertiesLines(file).each { PropertiesLine line ->
+                String key = propertyKey(line.content)
+                if (!key) {
+                    return
+                }
+                if (keys.containsKey(key)) {
+                    violations.add("${relativePath(root, file)}:${line.number}: duplicate message key '${key}' (first declared at line ${keys[key]})".toString())
+                } else {
+                    keys[key] = line.number
+                }
+            }
+        }
+    }
+
+    private static List<PropertiesLine> logicalPropertiesLines(File file) {
+        List<PropertiesLine> result = []
+        String content = null
+        int start = 0
+        file.readLines().eachWithIndex { String line, int index ->
+            if (content == null) {

Review Comment:
   `logicalPropertiesLines` reads `.properties` content with `file.readLines()` (platform default charset). This makes duplicate-key detection non-deterministic across OSes/locales (notably Windows) and can mis-parse non-ASCII keys. Since the build targets Java 21 (UTF-8 `.properties` semantics), read the file as UTF-8 explicitly.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,473 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add(sanitizeViolation("${path}: malformed skill front matter: ${exception.message}".toString()))
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add(sanitizeViolation("${path}: skill front matter must be a YAML mapping".toString()))
+            return [:]
+        }

Review Comment:
   Same double-sanitization issue as above: these violations are sanitized at creation time but also sanitized when writing the report / throwing the aggregated exception. Keep `violations` raw and sanitize only when rendering.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -128,33 +131,38 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
 
         project.tasks.withType(Pmd).configureEach {
             it.group = 'verification'
-            it.onlyIf { !project.hasProperty('skipCodeStyle') }
+            it.onlyIf { !skipCodeStyle.present }
             it.ignoreFailures = ignoreFailures.get()
 
             if (it.name.contains('Test') || it.name.contains('test')) {
                 it.enabled = testStylingEnabled.get()
             }
 
+            it.exclude { org.gradle.api.file.FileTreeElement element ->
+                element.file.toPath().toAbsolutePath().normalize().startsWith(projectBuildDirectory.get())
+            }

Review Comment:
   The PMD `exclude` predicate calls `projectBuildDirectory.get()` for every source file visited. That provider resolution and path normalization is avoidable overhead on large source sets. You can preserve the current lazy semantics (so late `layout.buildDirectory.set(...)` still works) while memoizing the resolved `Path` once per task execution.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,473 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add(sanitizeViolation("${path}: malformed skill front matter: ${exception.message}".toString()))
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add(sanitizeViolation("${path}: skill front matter must be a YAML mapping".toString()))
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add(sanitizeViolation("${path}: skill front matter field '${key}' must be a string".toString()))
+            }

Review Comment:
   Same double-sanitization issue as above: this message is sanitized when added and then sanitized again when rendering/throwing. Add the raw message here and leave escaping to `sanitizeViolation(...)` at output time.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677414855


##########
build-logic/plugins/build.gradle:
##########
@@ -40,6 +40,7 @@ dependencies {
     implementation "org.cyclonedx.bom:org.cyclonedx.bom.gradle.plugin:${gradleProperties.gradleCycloneDxPluginVersion}"
     implementation "com.github.spotbugs.snom:spotbugs-gradle-plugin:${gradleProperties.spotbugsPluginVersion}"
     implementation "org.sonatype.gradle.plugins:scan-gradle-plugin:${gradleProperties.sonatypeScanPluginVersion}"
+    implementation 'org.yaml:snakeyaml:2.4'

Review Comment:
   Fixed. `snakeyamlVersion=2.6` now lives in the build-tooling block of the root `gradle.properties` alongside `pmdVersion`, `checkstyleVersion`, and friends, and `build-logic/plugins/build.gradle` consumes it as `"org.yaml:snakeyaml:${gradleProperties.snakeyamlVersion}"`, matching how every other build-tooling dependency in that file is declared. That also brings it up to the 2.6 you identified instead of 2.4.



##########
gradle.properties:
##########
@@ -72,6 +72,9 @@ pmdVersion=7.25.0
 spotbugsPluginVersion=6.4.8
 sonatypeScanPluginVersion=3.1.6
 
+# PMD is enforced only for projects with a clean baseline. Add project paths after clearing debt.
+grails.code-analysis.enabled.pmd.projects=:grails-data-graphql-core,:grails-data-mongodb-spring-data,:grails-datasource,:grails-testing-support-core

Review Comment:
   Agreed, and removed. The hardcoded list is gone from `gradle.properties` entirely.
   
   PMD and SpotBugs are now opted into per project, in the module that owns the decision:
   
   ```groovy
   grailsCodeAnalysis {
       // PMD baseline is clean for this module; keep it blocking.
       pmdEnabled = true
   }
   ```
   
   `GrailsCodeAnalysisExtension` gained lazy `pmdEnabled` / `spotbugsEnabled` properties, and `GrailsCodeAnalysisPlugin` defers `configurePmd` / `configureSpotbugs` to `afterEvaluate` so a module's own `build.gradle` can set them. The four clean modules carry the flag themselves.
   
   The root aggregator no longer needs a list either: it derives the enabled set from `pluginManager.withPlugin('pmd')` / `withPlugin('com.github.spotbugs')` across `allprojects`, so adding a module is a one-line change in that module and nothing central has to be edited. The `-Pgrails.code-analysis.enabled.pmd` and `-P...pmd.projects=:a,:b` properties are kept purely as overrides for baseline and CI runs, and the unknown-path validation now applies only to paths passed through that override.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {
+            it.doLast {
+                deleteReports(codenarcMarkers.files, codenarcReports.files)
+                deleteReports(checkstyleMarkers.files, checkstyleReports.files)
+            }
+        }
 
-        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+        def writerTask = root.tasks.register('writeStyleViolations') {
             it.group = 'verification'
-            it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/'
-            it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md'))
-            it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md'))
+            it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/'
+            it.inputs.files(codenarcMarkers).optional()
+            it.inputs.files(checkstyleMarkers).optional()
+            it.inputs.property('ignoreFailures', ignoreFailures)
+            it.outputs.file(codenarcMarkdown)
+            it.outputs.file(checkstyleMarkdown)
+            it.outputs.upToDateWhen { false }
+            it.doFirst {
+                codenarcMarkdown.get().asFile.delete()
+                checkstyleMarkdown.get().asFile.delete()
+            }
             it.doLast {
-                parseStyleViolations(styleXmlDir.get(), violationsDir.get(),
-                    checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get())
+                parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(),
+                    checkStyleTests.get(), codenarcEnabled.get(),
+                    checkstyleEnabled.get(), ignoreFailures.get())
             }
         }
-        root.subprojects { Project sub ->
-            sub.pluginManager.withPlugin('codenarc') {
-                aggregateTask.configure {
-                    it.dependsOn(sub.tasks.withType(CodeNarc))
-                }
+        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+            it.group = 'verification'
+            it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/'
+            it.dependsOn(writerTask)
+        }
+        if (root.tasks.names.contains('validateRepositoryConventions')) {
+            aggregateTask.configure { it.dependsOn(root.tasks.named('validateRepositoryConventions')) }
+        }
+        def finalizeTask = root.tasks.register('finalizeStyleViolations') {
+            it.group = 'verification'
+            it.dependsOn(writerTask)
+        }
+        root.allprojects { Project sub ->
+            sub.tasks.withType(CodeNarc).all { CodeNarc codeNarcTask ->

Review Comment:
   Fixed. All four blocks use `configureEach`, and the aggregate, writer, and cleanup relationships are now collection-based:
   
   ```groovy
   def pmdTasks = sub.tasks.withType(Pmd)
   aggregateTask.configure { it.dependsOn(pmdTasks); it.dependsOn(cleanupTask) }
   writerTask.configure { it.mustRunAfter(pmdTasks) }
   pmdTasks.configureEach { Pmd pmdTask -> ... }
   ```
   
   Nothing is realized at configuration time any more. Thanks for the note about never-realized markers, that is what made the collection form safe to adopt without extra bookkeeping.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {

Review Comment:
   Fixed. Quality tasks no longer `dependsOn` the cleanup task, so a single-module `build` no longer wipes the other 60+ modules and no longer defeats each task's own up-to-date check. The cleanup task is now attached to the aggregate lane only: the aggregate task depends on it and quality tasks merely `mustRunAfter` it, which keeps stale markers from renamed or removed tasks purged when the aggregate lane actually runs.
   
   You were also right that the report deletion was redundant. `GradleUtils.configureReportMarker` already deletes each task's own report in `doFirst`, so the cleanup task now only removes markers, and the `codenarcReports` / `checkstyleReports` / `pmdReports` / `spotbugsReports` collections that existed solely to feed that deletion are gone.
   
   One follow-on worth flagging, since it is the other half of this thread: with cleanup no longer a dependency of every analyzer task, a direct single-analyzer run would have left earlier runs' markers in place and folded them into the regenerated report. That is fixed in the thread on the finalizer above, by removing the analyzer finalizers entirely so only the aggregate lane writes the Markdown. Cleanup stays scoped to that lane, which is where it belongs.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {
+            it.doLast {
+                deleteReports(codenarcMarkers.files, codenarcReports.files)
+                deleteReports(checkstyleMarkers.files, checkstyleReports.files)
+            }
+        }
 
-        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+        def writerTask = root.tasks.register('writeStyleViolations') {
             it.group = 'verification'
-            it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/'
-            it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md'))
-            it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md'))
+            it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/'
+            it.inputs.files(codenarcMarkers).optional()
+            it.inputs.files(checkstyleMarkers).optional()
+            it.inputs.property('ignoreFailures', ignoreFailures)
+            it.outputs.file(codenarcMarkdown)
+            it.outputs.file(checkstyleMarkdown)
+            it.outputs.upToDateWhen { false }
+            it.doFirst {
+                codenarcMarkdown.get().asFile.delete()
+                checkstyleMarkdown.get().asFile.delete()
+            }
             it.doLast {
-                parseStyleViolations(styleXmlDir.get(), violationsDir.get(),
-                    checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get())
+                parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(),
+                    checkStyleTests.get(), codenarcEnabled.get(),
+                    checkstyleEnabled.get(), ignoreFailures.get())
             }
         }
-        root.subprojects { Project sub ->
-            sub.pluginManager.withPlugin('codenarc') {
-                aggregateTask.configure {
-                    it.dependsOn(sub.tasks.withType(CodeNarc))
-                }
+        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+            it.group = 'verification'
+            it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/'
+            it.dependsOn(writerTask)
+        }
+        if (root.tasks.names.contains('validateRepositoryConventions')) {
+            aggregateTask.configure { it.dependsOn(root.tasks.named('validateRepositoryConventions')) }
+        }
+        def finalizeTask = root.tasks.register('finalizeStyleViolations') {
+            it.group = 'verification'
+            it.dependsOn(writerTask)
+        }
+        root.allprojects { Project sub ->
+            sub.tasks.withType(CodeNarc).all { CodeNarc codeNarcTask ->
+                codenarcMarkers.from(GradleUtils.reportMarker(sub, 'codenarc', codeNarcTask.name))
+                def reportLocation = codeNarcTask.reports.xml.outputLocation
+                codenarcReports.from { reportLocation.get().asFile }
+                codeNarcTask.dependsOn(cleanupTask)
+                codeNarcTask.finalizedBy(finalizeTask)

Review Comment:
   Fixed, and I ended up taking both of the options you offered, because a review pass over the final diff showed the first one alone was not enough.
   
   First, the reports now record their scope. Every aggregate report carries a header line naming the modules that actually contributed data:
   
   ```
   # CodeNarc Violations Summary
   Generated on: ...
   
   Modules analyzed: :grails-core
   ```
   
   `none` is emitted when nothing contributed.
   
   Second, and this is the part I had missed: the header alone did not fix it. Because the reports are assembled from marker files left on disk, a module analyzed in an EARLIER run still contributed its stale markers to the regenerated report, so `./gradlew :grails-core:check` could produce a report listing modules it never ran, built from previous-run data. So I also took your "only rewrite in the aggregate lane" option: `finalizeStyleViolations` and `finalizeAnalysisViolations` are gone along with all four analyzer `finalizedBy` registrations. A direct analyzer task now produces only its own XML report and marker, and the aggregate Markdown is written solely by `aggregateStyleViolations`, `aggregateAnalysisViolations`, and `aggregateViolations`, which purge markers first.
   
   There is a regression test that builds an authoritative aggregate report, runs a single direct analyzer, and asserts the aggregate Markdown is byte-for-byte unchanged.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })

Review Comment:
   Both fixed. The lookup is now `root.tasks.named { String name -> name == 'rat' }`, which filters by name without realizing anything, and the relationship is `mustRunAfter` rather than `dependsOn` since the task reads nothing from rat's output.
   
   You are right that the hard dependency was silently no-oping when rat is not registered, which is exactly the independent-build case. RAT is still part of the developer-facing gate: `aggregateViolations` pulls in both the conventions task and `rat` directly, so the ordering holds where it matters and standalone runs are no longer taxed.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {

Review Comment:
   Fixed. Both trees now carry the same exclusion set, including `**/.gradle/**`, so daemon-mutated files can no longer perturb the input fingerprint of an `@InputFiles` property.
   
   While in there I also took your other suggestion and widened the include from `**/messages*.properties` to `**/grails-app/i18n/**/*.properties`, so the `spring-security-core.properties` family is covered too.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -243,9 +382,11 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
     }
 
     @CompileDynamic
-    private static void parseStyleViolations(Directory styleXmlDir, Directory violationsDir,
-            boolean checkStyleTests, boolean codenarcEnabled, boolean checkstyleEnabled) {
+    private static void parseStyleViolations(Set<File> codenarcMarkers, Set<File> checkstyleMarkers, Directory rootDirectory,
+            Directory violationsDir, boolean checkStyleTests,
+            boolean codenarcEnabled, boolean checkstyleEnabled, boolean ignoreFailures) {

Review Comment:
   Removed, option (b). CodeNarc and Checkstyle tasks already honor their own `ignoreFailures` task property and fail on their own findings, so mirroring the gate in the aggregator would have duplicated it rather than added anything. The parameter, the `inputs.property('ignoreFailures', ...)` declaration on `writeStyleViolations`, and the unused argument are all gone, so there is no longer an input that cannot influence the output. The unconditional missing-report failure is unchanged, and `parseAnalysisViolations` keeps its own gate.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5819410468

   ## 🔎 No tests executed 🔎
   
   🏷️ Commit: 13b8c54c3d3708095f1c410a659b3eb7837a55ba
   ▶️ Tests:  0 executed
   ⚪️ Checks: 2/2 completed
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5200482769

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 7b2b62dd7f62472e1036ff79984df8e6671c76bb
   ▶️ Tests:  66579 executed
   ⚪️ Checks: 62/62 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677417923


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile

Review Comment:
   Fixed. The repository root is canonicalized once in `validateRepositoryConventions` and the single canonical `File` is threaded through `relativePath`, `isWorkflowManifest`, `isActionManifest`, and `validateLocalAction`. `relativePath` also canonicalizes the file before relativizing, so a symlink component in the checkout path can no longer produce a `../..` result, misclassify a locally referenced workflow as a composite action, and then have `validatedManifests` dedup-skip the file when the outer loop reaches it.
   
   Latent as you say, but it is the designated local-action path, so it is worth being correct. The direct-file branch now has spec coverage too, in the thread below.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {

Review Comment:
   The strictness is intended, and you are right that it needed explaining rather than just rejecting. An expression value cannot be checked for immutability at validation time, so accepting it would mean the rule silently stops enforcing anything for matrix jobs.
   
   Rather than relax it, the rejection now explains itself with its own message: container images must be literal `name@sha256:<digest>` values because expression values cannot be verified as immutable. That is a distinct violation from the ordinary mutable-tag one, so the first author of a matrix-image job gets told why rather than being left guessing. Documented in AGENTS.md as well, and covered by a spec.
   
   If a matrix-image job actually becomes necessary, I would rather revisit it then with a concrete case in hand.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)

Review Comment:
   Documented rather than re-pinned. There is now a comment on the `COMMIT_SHA` constant recording that a 40-hex ref may be either a commit or an annotated-tag object, that both are immutable, and that the validator cannot distinguish them. AGENTS.md says the same in the contributor-facing policy.
   
   I left `release-drafter@e1247478...` alone deliberately: it is correct and immutable today, that file's own bump checklist already documents the peeling step, and re-pinning it here would put an unrelated third-party action bump into a build-policy PR. Happy to normalize it in a follow-up if you would rather have every pin be a peeled commit.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set

Review Comment:
   Fixed. `import java.util.Set` is gone and the `java.*` block now sits above the third-party imports, matching `GrailsCodeAnalysisPlugin` and the rest of build-logic. Also took the related note in the other thread and replaced the inline fully-qualified `org.gradle.api.file.FileTreeElement` with a proper import.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()

Review Comment:
   Fixed in both jobs. The advisory run, upload, and summary steps now use `if: success() || failure()`, so a superseded PR run actually frees its runner instead of finishing a full advisory SpotBugs pass on a doomed job. The pre-existing `if: always()` on the enforced-report upload and summary steps is left as it was, since those predate this PR.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd.projects=" -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: core-spotbugs-advisory-reports
+          path: build/reports/violations/

Review Comment:
   Fixed. Both advisory uploads are scoped to the single file the pass legitimately produces - `build/reports/violations/SPOTBUGS_VIOLATIONS.md` and `grails-gradle/build/reports/violations/SPOTBUGS_VIOLATIONS.md` - so the artifact can no longer contradict the enforced PMD step that ran in the same job.
   
   The related cause is gone too: the advisory pass no longer passes `-Pgrails.code-analysis.enabled.pmd.projects=` to switch PMD off, because with the per-project opt-in there is no central list to blank out. It simply runs with `ignoreFailures`, and the scoped upload keeps the artifact honest.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd.projects=" -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: core-spotbugs-advisory-reports
+          path: build/reports/violations/
+      - name: "📋 Publish SpotBugs Advisory Report in Job Summary"
+        if: always()

Review Comment:
   Fixed, following the `vulnerability-scan.yml` pattern you pointed at. Each advisory run step now has an `id` and the summary echoes its outcome:
   
   ```yaml
   echo "Advisory run outcome: ${{ steps.core_spotbugs_advisory.outcome }}" >> $GITHUB_STEP_SUMMARY
   ```
   
   Same for the gradle-plugin variant. A crashed advisory run now shows as `failure` in the summary instead of leaving a bare heading on a green job.



##########
.github/workflows/release.yml:
##########
@@ -224,7 +224,7 @@ jobs:
         # staged from a JDK 25 runner. This is a NEW reproducibility pin -
         # keep $JAVA_VERSION_MICRONAUT synced with the secondary JDK in
         # etc/bin/Dockerfile so verifiers can reproduce the resulting JARs.
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   You are right, and this is resolved by the revert. Both steps are back to `actions/setup-java@v4`, so there is no major-version crossing in the reproducibility pins at all and no need to re-verify Liberica archive equivalence for either `JAVA_VERSION` value. The v4 to v5 jump was an unintended side effect of mechanically replacing tags with the SHAs their tags currently resolve to, which is exactly the maintenance burden you describe.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5022749922

   ## [Codecov](https://app.codecov.io/gh/apache/grails-core/pull/16025?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
   :white_check_mark: All modified and coverable lines are covered by tests.
   :white_check_mark: Project coverage is 51.4561%. Comparing base ([`dfebb9a`](https://app.codecov.io/gh/apache/grails-core/commit/dfebb9a2616f726a6e571c84941c25ea4cae03e3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`adbe27c`](https://app.codecov.io/gh/apache/grails-core/commit/adbe27cde5d6196d89eeb2ddc9ec0255b059df02?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
   :warning: Report is 3 commits behind head on 8.0.x.
   
   <details><summary>Additional details and impacted files</summary>
   
   
   
   [![Impacted file tree graph](https://app.codecov.io/gh/apache/grails-core/pull/16025/graphs/tree.svg?width=650&height=150&src=pr&token=qJrryhpk7u&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)](https://app.codecov.io/gh/apache/grails-core/pull/16025?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)
   
   ```diff
   @@                Coverage Diff                 @@
   ##                8.0.x     #16025        +/-   ##
   ==================================================
   + Coverage     51.4404%   51.4561%   +0.0157%     
   - Complexity      17728      17731         +3     
   ==================================================
     Files            2039       2039                
     Lines           95497      95497                
     Branches        16564      16564                
   ==================================================
   + Hits            49124      49139        +15     
   + Misses          39074      39057        -17     
   - Partials         7299       7301         +2     
   ```
   [see 3 files with indirect coverage changes](https://app.codecov.io/gh/apache/grails-core/pull/16025/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)
   </details>
   <details><summary> :rocket: New features to boost your workflow: </summary>
   
   - :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
   - :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
   </details>


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5122645028

   @jdaugherty @matrei we should pin all or nearly all of the actions.  It is consistent and the most secure, if GitHub or and apache repo get hacked.  


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5459427526

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Functional Tests \(Java 25, indy\=false\)](https://github.com/apache/grails-core/actions/runs/33223409571/job/99022095553?pr=16025) > :grails-test-examples-scaffolding:integrationTest
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | UserControllerSpec > User list | ❌ | [2%&nbsp;🟡](https://github.com/apache/grails-core/issues/16030) |
   
   🏷️ Commit: 6abaa60fe59ffd89dc166acef381bfd77136137d
   ▶️ Tests:  66137 executed
   🟡 Checks: 63/87 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>UserControllerSpec > User list</strong> (:grails-test-examples-scaffolding:integrationTest in <a href="https://github.com/apache/grails-core/actions/runs/33223409571/job/99022095553?pr=16025">CI / Functional Tests (Java 25, indy=false)</a>)</summary>
   
   ```
   geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
   	at geb.waiting.Wait.waitFor(Wait.groovy:128)
   	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
   	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
   	at geb.Page.waitFor(Page.groovy:120)
   	at com.example.pages.LoginPage.login(LoginPage.groovy:39)
   	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)
   Caused by: Assertion failed: 
   
   title != pageTitle && $('input', name: 'username').empty
   |     |  |         |
   |     |  |         false
   |     |  'Please sign in'
   |     false
   'Please sign in'
   
   	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:39)
   	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
   	at geb.waiting.Wait.waitFor(Wait.groovy:117)
   	... 5 more
   ```
   
   </details>
   
   ### Rerun Controls
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 UserControllerSpec > User list <!-- uniqueId=[engine:spock]/[spec:com.example.UserControllerSpec]/[feature:$spock_feature_1_0] -->
   
   Reuse successful test results:
   
   🔲 ♻️ Only rerun the tests that failed or were muted before
   
   Click the checkbox to trigger a rerun:
   
   🔲 **Rerun jobs**
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5833378146

   @matrei we had agreed to not pin actions by sha for the github ones and somehow that got merged when the build was broken.  Are you reversing that decision or only worried about the merge conflict?  I can fix 7.x separately.


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5834505184

   @matrei https://github.com/apache/grails-core/pull/16404 will merge this into 7.0.x, and then I've updated this branch too. 


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "borinquenkid (via GitHub)" <gi...@apache.org>.
borinquenkid commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5123320172

   The TestLens failures for `GrailsUtilStackFiltererSpec > installed DefaultStackTraceFilterer emits Full Stack Trace by default` and `GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer` are a pre-existing bug on `8.0.x`, unrelated to this PR's changes -- reproducible on plain `8.0.x` today. `DefaultStackTraceFilterer.STACK_LOG` routes through a jcl-over-slf4j commons-logging binding, so the tests' `System.setErr()` capture never observes the emitted message.
   
   Fix: #16067 (replaces the `System.err` capture with a Logback appender attached directly to the logger). Once that merges, rebasing onto `8.0.x` should clear this failure here.


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5122846331

   > @jdaugherty @matrei we should pin all or nearly all of the actions. It is consistent and the most secure, if GitHub or and apache repo get hacked.
   
   For the github actions, the ASF does not require a pin so this isn't a concern. There hasn't been a case for this previously, and until the ASF reverses policy here we shouldn't try to complicate our setup more.  
   
   As for our own actions (grails-github-actions), we already have problems doing reviews and we don't want to have to update everything + get reviews for our own action changes.  I do not want to have the extra work here and we have to update them more often now that we're pinning docker images in those actions.  I 100% disagree with pinning versions of our own code since that code has to be used already across all of our releases.
   
   For anything else, I 100% agree since they're external.  If the platform is hacked, we have larger problems than the action itself.


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3616606381


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,473 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add(sanitizeViolation("${path}: malformed skill front matter: ${exception.message}".toString()))
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add(sanitizeViolation("${path}: skill front matter must be a YAML mapping".toString()))
+            return [:]
+        }

Review Comment:
   Fixed in 8bc7b8fdc1 with the same boundary-only sanitization change. YAML mapping-type violations are stored raw and sanitized only when rendering the exception or report. The regression verifies that front-matter diagnostics are not double escaped.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -128,33 +131,38 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
 
         project.tasks.withType(Pmd).configureEach {
             it.group = 'verification'
-            it.onlyIf { !project.hasProperty('skipCodeStyle') }
+            it.onlyIf { !skipCodeStyle.present }
             it.ignoreFailures = ignoreFailures.get()
 
             if (it.name.contains('Test') || it.name.contains('test')) {
                 it.enabled = testStylingEnabled.get()
             }
 
+            it.exclude { org.gradle.api.file.FileTreeElement element ->
+                element.file.toPath().toAbsolutePath().normalize().startsWith(projectBuildDirectory.get())
+            }

Review Comment:
   Reviewed and intentionally left unchanged. The Provider.get call preserves late layout.buildDirectory changes, which existing TestKit coverage requires. Eager resolution breaks that contract; safe per-task memoization would require mutable custom state and add configuration-cache risk for one inexpensive path conversion per visited source, while PMD analysis dominates runtime. The current provider-based implementation is the smaller and more idiomatic tradeoff.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,473 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add(sanitizeViolation("${path}: malformed skill front matter: ${exception.message}".toString()))
+            return [:]
+        }

Review Comment:
   Fixed in 8bc7b8fdc1. Front-matter violations now remain raw until the exception and Markdown report output boundaries, so each surface escapes pipes exactly once. The new TestKit regression uses a duplicate quoted YAML key containing a pipe and asserts single escaping in both outputs. Thanks for catching this.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,473 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add(sanitizeViolation("${path}: malformed skill front matter: ${exception.message}".toString()))
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add(sanitizeViolation("${path}: skill front matter must be a YAML mapping".toString()))
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add(sanitizeViolation("${path}: skill front matter field '${key}' must be a string".toString()))
+            }

Review Comment:
   Fixed in 8bc7b8fdc1. Non-string front-matter field violations now follow the same raw-storage and output-boundary sanitization model as every other convention violation. The public TestKit coverage protects single escaping on both output surfaces.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,473 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add(sanitizeViolation("${path}: malformed skill front matter: ${exception.message}".toString()))
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add(sanitizeViolation("${path}: skill front matter must be a YAML mapping".toString()))
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add(sanitizeViolation("${path}: skill front matter field '${key}' must be a string".toString()))
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile
+        if (!target.toPath().startsWith(canonicalRoot.toPath())) {
+            violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString())
+            return
+        }
+        if (target.isFile() && target.name ==~ /.*\.ya?ml/) {
+            validateActionManifest(root, target, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        ['action.yml', 'action.yaml'].each { String manifestName ->
+            File manifest = new File(target, manifestName)
+            if (manifest.isFile()) {
+                validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static void validateProperties(File root, List<File> files, List<String> violations) {
+        files.findAll { File file -> file.name.startsWith('messages') && file.name.endsWith('.properties') }.sort().each { File file ->
+            Map<String, Integer> keys = [:]
+            logicalPropertiesLines(file).each { PropertiesLine line ->
+                String key = propertyKey(line.content)
+                if (!key) {
+                    return
+                }
+                if (keys.containsKey(key)) {
+                    violations.add("${relativePath(root, file)}:${line.number}: duplicate message key '${key}' (first declared at line ${keys[key]})".toString())
+                } else {
+                    keys[key] = line.number
+                }
+            }
+        }
+    }
+
+    private static List<PropertiesLine> logicalPropertiesLines(File file) {
+        List<PropertiesLine> result = []
+        String content = null
+        int start = 0
+        file.readLines().eachWithIndex { String line, int index ->
+            if (content == null) {

Review Comment:
   Addressed in 8bc7b8fdc1. Message property files are now read explicitly with UTF-8, matching the Grails message-source and source-bundle encoding contract. TestKit writes UTF-8 non-ASCII duplicate keys and verifies them under an ISO-8859-1 Gradle JVM default. Groovy 5 already decoded that fixture correctly before the change, so this is explicit deterministic contract hardening rather than a reproduced Java 21 failure.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677421153


##########
build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy:
##########
@@ -141,15 +163,496 @@ class GrailsViolationAggregationPluginSpec extends Specification {
         !testProjectDir.resolve('CHECKSTYLE_VIOLATIONS.md').toFile().exists()
         !testProjectDir.resolve('CODENARC_VIOLATIONS.md').toFile().exists()
 
-        and: "checkstyle report contains the violation"
+        and: "legacy Checkstyle XML is ignored"
         def checkstyleMd = new File(violationsDir, 'CHECKSTYLE_VIOLATIONS.md').text
-        checkstyleMd.contains('## Module: app-module')
-        checkstyleMd.contains('JavadocPackageCheck')
+        !checkstyleMd.contains('Legacy report.')
 
-        and: "codenarc report contains the violation"
+        and: "legacy CodeNarc XML is ignored"
         def codenarcMd = new File(violationsDir, 'CODENARC_VIOLATIONS.md').text
-        codenarcMd.contains('## Module: app-module')
-        codenarcMd.contains('EmptyClass')
+        !codenarcMd.contains('Legacy report')
+    }
+
+    def "aggregateAnalysisViolations recognizes the PMD project allowlist and reports disabled SpotBugs"() {
+        given:
+        testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd.projects=:app-module
+grails.code-analysis.ignoreFailures=true
+pmdVersion=7.25.0

Review Comment:
   Fixed. `build-logic/plugins/build.gradle` already loads the root `gradle.properties`, so the `test` task now forwards the values as system properties and the specs read them:
   
   ```groovy
   tasks.named('test') {
       systemProperty 'grails.test.pmdVersion', gradleProperties.pmdVersion
       systemProperty 'grails.test.codenarcVersion', gradleProperties.codenarcVersion
   }
   ```
   
   No fixture pins a tool version literal any more, so the next bump cannot leave the suite silently exercising an old toolchain. `mavenCentral()` stays in the fixtures because they do have to resolve the analyzers to run them, which is inherent to a TestKit test that actually executes PMD or CodeNarc.



##########
build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPluginSpec.groovy:
##########
@@ -0,0 +1,142 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import java.nio.file.Path
+
+import org.gradle.testkit.runner.GradleRunner
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class GrailsCodeAnalysisPluginSpec extends Specification {
+
+    @TempDir
+    Path testProjectDir
+
+    def "PMD project allowlist enables only listed projects"() {
+        given:
+        writeMultiProjectBuild('grails.code-analysis.enabled.pmd.projects=:selected')
+
+        when:
+        def result = run('tasks', '--all', '--configuration-cache')
+
+        then:
+        result.output.contains('selected:pmdMain')
+        !result.output.contains('excluded:pmdMain')
+    }
+
+    def "global PMD opt-in remains compatible with project selection"() {

Review Comment:
   Fixed by making the fixture match the name rather than by renaming it. The feature now sets the global flag and a project-scoped opt-in at the same time and asserts both projects still get `pmdMain`, so the OR contract of `isToolEnabled` is genuinely verified. It also covers the third arm added by the per-project extension change in the other thread, since `isToolEnabled` is now global property OR project-paths property OR extension flag.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile
+        if (!target.toPath().startsWith(canonicalRoot.toPath())) {
+            violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString())
+            return
+        }
+        if (target.isFile() && target.name ==~ /.*\.ya?ml/) {

Review Comment:
   All three now have coverage: the direct-file local-action branch (a `uses: ./.github/workflows/x.yml` reference, including a regression asserting that an external non-SHA action inside the referenced file is still reported, so a break in the recursion cannot go silent), the trailing-`@` guard, and an even-backslash line in `continues()` that must not continue onto the next line.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -128,33 +131,38 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
 
         project.tasks.withType(Pmd).configureEach {
             it.group = 'verification'
-            it.onlyIf { !project.hasProperty('skipCodeStyle') }
+            it.onlyIf { !skipCodeStyle.present }
             it.ignoreFailures = ignoreFailures.get()
 
             if (it.name.contains('Test') || it.name.contains('test')) {
                 it.enabled = testStylingEnabled.get()
             }
 
+            it.exclude { org.gradle.api.file.FileTreeElement element ->

Review Comment:
   Fixed, `FileTreeElement` is imported and used by simple name. Same cleanup applied to the fully-qualified `Property<Boolean>` that had crept into the `isToolEnabled` signature.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677923074


##########
.github/workflows/release-close.yml:
##########
@@ -45,7 +45,7 @@ jobs:
           ref: ${{ env.TAG }}
           token: ${{ secrets.GITHUB_TOKEN }}
       - name: "⚙️ Run post-release"
-        uses: apache/grails-github-actions/post-release@asf
+        uses: apache/grails-github-actions/post-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Addressed in `7a0aceb5ff`. First-party `actions/*` and `apache/grails-github-actions/*` are back on version/branch refs (no SHA pins). `RepositoryConventionsTask` exempts those namespaces from the 40-hex rule.



##########
.github/workflows/groovy-joint-workflow.yml:
##########
@@ -105,7 +105,7 @@ jobs:
     runs-on: ubuntu-latest
     services:
       mongodb:
-        image: mongo:8
+        image: mongo@sha256:951c2ff9fc6bdb6cb89b1dfea4a0e8ae3ee4fb287c0bf579b2bba54c7803f75d # mongo:8

Review Comment:
   Third-party action pins remain intentional. Dependabot for action digests is a follow-up if we want automated bump PRs; not required for this conventions PR.



##########
.github/workflows/gradle.yml:
##########
@@ -950,7 +950,7 @@ jobs:
           compression-level: 0 # Already a zip
       - name: "🚀 Publish to Github Pages"
         if: github.event_name == 'push'
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Addressed in `7a0aceb5ff`. First-party `actions/*` and `apache/grails-github-actions/*` are back on version/branch refs (no SHA pins). `RepositoryConventionsTask` exempts those namespaces from the 40-hex rule.



##########
.github/workflows/gradle.yml:
##########
@@ -923,7 +923,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -812,7 +812,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -694,7 +694,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -628,7 +628,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -547,7 +547,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -495,7 +495,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -323,7 +323,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5027003210

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 8bc7b8fdc108416b8a1797484e2dd6679b106789
   ▶️ Tests:  43618 executed
   ⚪️ Checks: 59/59 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5820089937

   I've pushed a set of updates to this branch: `f760fd63e4` through `9fe96f9f48`. Summary:
   
   **8.0.x merge (`f760fd63e4`)**
   - Resolved conflicts in `AGENTS.md`, `build-logic/plugins/build.gradle`, and `gradle/rat-root-config.gradle`. This includes the benchmarks move to `grails-test-examples/benchmarks`.
   - Dropped the RAT exclusion for `grails-bom/micronaut`, which no longer exists.
   
   **Review follow-ups that were marked resolved but not actually addressed (`61a5a2196d`)**
   - `.asf.yaml` is no longer excluded from RAT. It carries a license header and ships in the source zip, so RAT audits it.
   - `.worktrees/` is now excluded from the repository-conventions scans as well as from RAT and the zip. Each worktree is a complete checkout.
   - Restored the SpotBugs report block formatting in `GrailsCodeAnalysisPlugin`.
   - Removed an unused `ignoreFailures` provider.
   - Three specs could not fail when the behavior they claimed to cover broke. They now fail as intended, and each was verified by removing the behavior it covers:
     - the local-workflow recursion spec
     - the UTF-8 skill-decoding spec
     - the PMD opt-in spec, now a table covering each enable path alone and combined
   
   **GitHub Actions references**
   - Every `actions/*` SHA pin that landed on 8.0.x is reverted to its major version tag, taken from the pin's own `# vX.Y.Z` comment, so no action changes major version.
   - `validateRepositoryConventions` now rejects commit SHAs for `actions/*` and `apache/*`, so the pins can't come back.
   - Third-party actions, including `github/*`, stay SHA-pinned.
   - `validateActions` still passes, since it auto-approves these namespaces.
   - `AGENTS.md` now documents this policy.
   
   **Code review findings (`13b8c54c3d`)**
   - `.claude/worktrees/` (Claude Code's default worktree location) is excluded like `.worktrees/`. `.claude/settings.local.json` is ignored by git and RAT.
   - A `SKILL.md` symlinked into `.agents/skills` is validated by its repository path instead of being silently skipped.
   - `validateRepositoryConventions` is now kept up to date by Gradle and walks the checkout once. The walk skips nested build output and `node_modules`.
   - A `./...` action reference with no `action.yml` or `action.yaml` is now a violation.
   - `findRootGrailsCoreDir` fails with a clear error instead of returning null. The nearest `.asf.yaml` wins, so a nested worktree resolves to itself.
   - The redundant root check is replaced with `GradleUtils.isRootGrailsCoreDir`.
   - Malformed or non-mapping skill front matter now produces one violation, not four.
   - An explicit `-Pgrails.code-analysis.enabled.pmd=true|false` (or `spotbugs`) overrides module opt-ins.
   - "Modules analyzed" lists only modules whose report was read.
   - Removed a stray `worktrees/*` zip exclude.
   
   **Up-to-date analyzers (`937dc7472c`)**
   - The aggregate lane no longer deletes analyzer outputs on every run.
     - On the full repository, a second `aggregateStyleViolations aggregateAnalysisViolations` takes 15s instead of 3m 6s, with every CodeNarc, Checkstyle, and PMD task UP-TO-DATE.
   - Report markers now live in each module's own `build/`. Gradle only removes a NO-SOURCE task's previous outputs inside build directories it owns. With the markers in the root `build/`, deleting a module's sources left its old findings in the report.
   - New `cleanViolationReports` task forces full re-analysis.
     - The root project had no `clean`, so `./gradlew clean` never touched these reports. The plugin now registers a root `clean` that runs it.
     - `./gradlew clean aggregateViolations` now really starts fresh.
   - New `-PskipCodeAnalysis` skips PMD and SpotBugs only. `-PskipCodeStyle` still skips every analyzer, so existing CI jobs are unchanged. Skipped runs write "skipped" reports instead of reusing an earlier run's results.
   
   **Docs (`9fe96f9f48`)**
   - `DEVELOPMENT.md` documents `aggregateViolations`, `cleanViolationReports`, `validateRepositoryConventions`, both skip flags, and the per-module PMD/SpotBugs opt-in with its override properties.
   - `AGENTS.md` and the violation-fixer skill are updated to match.
   
   **Verification**
   - `build-logic` check: 180 tests, 0 failures.
   - `validateRepositoryConventions`, `validateActions`, and `rat` pass on the merged tree.
   - PMD passes on the four opted-in modules.
   - The PR description is updated to match.
   
   Still open: `snakeyamlVersion` in `gradle.properties` duplicates the version Spring Boot's BOM already manages. Leaving that for a follow-up.
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5835338143

   @matrei thanks for the review. Everything is addressed except the sign-off in item 1, which is still open. Changes are in `d19c00c9db` through `1d6d2396d1`.
   
   **1. `actions/*` SHAs vs 7.0.x**
   - 7.0.x is aligned first, as you suggested.
     - `ci/unpin-first-party-actions-7.0.x` replaces all 97 `actions/*` SHA pins on 7.0.x.
     - It applies the same `AGENTS.md` rule change.
   - Both branches reference each `actions/*` action by the full tag of its latest release: `checkout@v7.0.1`, `setup-java@v6.0.1`, `cache@v6.1.0`, `upload-artifact@v7.0.1`, `download-artifact@v8.0.1`. The ASF allows every version in that namespace.
   - The gate now also rejects floating major tags such as `@v7` for `actions/*`. `apache/*` still accepts any version or branch.
   - @jamesfredley, can you confirm you agree with this policy?
   
   **2. The profile jar ships the wrapper**
   - The description now explains the fix: the old copy source was `install/grails-wrapper`, which never exists.
   - It is also listed under a new Release notes section.
   
   **3. Writers can publish a stale report**
   - `writeStyleViolations` and `writeAnalysisViolations` are no longer in the `verification` group.
   - A writer requested without its aggregate task is now skipped, including from the configuration cache.
   - I kept them as separate tasks rather than folding them into the aggregate. Under `--continue`, the aggregate doesn't run once an analyzer fails, and the writer still has to produce the report that explains the failure.
   
   **4. `enablePmd()` / `enableSpotbugs()`**
   - Done, following the `withSourcesJar()` pattern you described.
     - Both methods apply and configure the tool immediately.
     - The `-P` properties are read in `apply()`, and an all-project `false` makes both methods no-ops.
     - Report locations and the build-directory exclude resolve lazily.
   - The `afterEvaluate` caveat is gone from both docs.
   - New specs cover:
     - direct `tasks.named('pmdMain')` customization
     - a `reportsDirectory` set after `enablePmd()`
     - applying the plugin from `gradle.projectsEvaluated`
     - the full property table for both tools
   
   **5. Nits**
   - Report names are readable: `__grails-datasource-pmdMain.xml`.
     - A project path that doesn't decode back to itself fails the build, so names can't collide.
     - Test reports are now recognized by the task name alone. Otherwise `grails-testing-support-core` would be misread as a test report.
   - The unrelated `—` → `-` edits in `AGENTS.md` are reverted.
   - The description no longer says "again" for `zip -y`.
   - The ZIP layout change has a Release notes line.
   - The docs now say the all-project property, when set, wins over both `.projects` and the module opt-ins. There's also a table row where `false` is combined with both.
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5038311275

   @jdaugherty no rush and you will find this somewhat incomplete, with several things carved out to issues.  I am sure it needs some polish somewhere.
   
   @borinquenkid Agreed, this adds two skills.md files, but more will be better.  


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677926230


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -161,11 +163,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // Redirect XML report output to a single directory to consolidate
             // reports across all subprojects into one known location
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('checkstyle')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)

Review Comment:
   Addressed in `7a0aceb5ff` with restored layout/report path construction and formatting cleanups in the aggregation/analysis plugins.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -161,11 +163,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // Redirect XML report output to a single directory to consolidate
             // reports across all subprojects into one known location
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('checkstyle')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)
+                            .reportsDirectory
+                            .file("checkstyle/${GradleUtils.reportFileName(project, task.name)}")

Review Comment:
   Addressed in `7a0aceb5ff` with restored layout/report path construction and formatting cleanups in the aggregation/analysis plugins.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -201,11 +205,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // reports across all subprojects into one known location
             task.reports.xml.required.set(true)
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('codenarc')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)

Review Comment:
   Addressed in `7a0aceb5ff` with restored layout/report path construction and formatting cleanups in the aggregation/analysis plugins.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -201,11 +205,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // reports across all subprojects into one known location
             task.reports.xml.required.set(true)
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('codenarc')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)
+                            .reportsDirectory
+                            .file("codenarc/${GradleUtils.reportFileName(project, task.name)}")

Review Comment:
   Addressed in `7a0aceb5ff` with restored layout/report path construction and formatting cleanups in the aggregation/analysis plugins.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -74,6 +74,8 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
 
     static final String DEFAULT_JACOCO_EXCLUDED_CLASS_PREFIXES = 'org.grails.orm.hibernate.support.hibernate7.'
 
+    private static final String CANONICAL_ROOT_MARKER = '.github/workflows'

Review Comment:
   Addressed in `7a0aceb5ff`. Canonical-root detection uses `GradleUtils.findRootGrailsCoreDir` / `.asf.yaml` consistently. `9a8fbeed87` also keeps `.asf.yaml` in the source ZIP and terminates root search at the filesystem root.



##########
build-logic/plugins/build.gradle:
##########
@@ -40,6 +40,7 @@ dependencies {
     implementation "org.cyclonedx.bom:org.cyclonedx.bom.gradle.plugin:${gradleProperties.gradleCycloneDxPluginVersion}"
     implementation "com.github.spotbugs.snom:spotbugs-gradle-plugin:${gradleProperties.spotbugsPluginVersion}"
     implementation "org.sonatype.gradle.plugins:scan-gradle-plugin:${gradleProperties.sonatypeScanPluginVersion}"
+    implementation 'org.yaml:snakeyaml:2.4'

Review Comment:
   Addressed in `7a0aceb5ff`. SnakeYAML is managed via `snakeyamlVersion` (2.6) rather than a hardcoded 2.4 literal.



##########
gradle.properties:
##########
@@ -72,6 +72,9 @@ pmdVersion=7.25.0
 spotbugsPluginVersion=6.4.8
 sonatypeScanPluginVersion=3.1.6
 
+# PMD is enforced only for projects with a clean baseline. Add project paths after clearing debt.
+grails.code-analysis.enabled.pmd.projects=:grails-data-graphql-core,:grails-data-mongodb-spring-data,:grails-datasource,:grails-testing-support-core

Review Comment:
   Addressed in `7a0aceb5ff`. Central `gradle.properties` allowlist removed; modules opt in with `grailsCodeAnalysis { pmdEnabled = true }`. Global `-P` overrides remain.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {
+            it.doLast {
+                deleteReports(codenarcMarkers.files, codenarcReports.files)
+                deleteReports(checkstyleMarkers.files, checkstyleReports.files)
+            }
+        }
 
-        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+        def writerTask = root.tasks.register('writeStyleViolations') {
             it.group = 'verification'
-            it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/'
-            it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md'))
-            it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md'))
+            it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/'
+            it.inputs.files(codenarcMarkers).optional()
+            it.inputs.files(checkstyleMarkers).optional()
+            it.inputs.property('ignoreFailures', ignoreFailures)
+            it.outputs.file(codenarcMarkdown)
+            it.outputs.file(checkstyleMarkdown)
+            it.outputs.upToDateWhen { false }
+            it.doFirst {
+                codenarcMarkdown.get().asFile.delete()
+                checkstyleMarkdown.get().asFile.delete()
+            }
             it.doLast {
-                parseStyleViolations(styleXmlDir.get(), violationsDir.get(),
-                    checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get())
+                parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(),
+                    checkStyleTests.get(), codenarcEnabled.get(),
+                    checkstyleEnabled.get(), ignoreFailures.get())
             }
         }
-        root.subprojects { Project sub ->
-            sub.pluginManager.withPlugin('codenarc') {
-                aggregateTask.configure {
-                    it.dependsOn(sub.tasks.withType(CodeNarc))
-                }
+        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+            it.group = 'verification'
+            it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/'
+            it.dependsOn(writerTask)
+        }
+        if (root.tasks.names.contains('validateRepositoryConventions')) {
+            aggregateTask.configure { it.dependsOn(root.tasks.named('validateRepositoryConventions')) }
+        }
+        def finalizeTask = root.tasks.register('finalizeStyleViolations') {
+            it.group = 'verification'
+            it.dependsOn(writerTask)
+        }
+        root.allprojects { Project sub ->
+            sub.tasks.withType(CodeNarc).all { CodeNarc codeNarcTask ->

Review Comment:
   Addressed in `7a0aceb5ff`. Quality wiring uses `configureEach` and collection-based `dependsOn`/`mustRunAfter` so ordinary tasks no longer realize every analyzer.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5123254528

   @jdaugherty I am fine leaving the Apache Grails actions unpinned. 


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677416550


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {
+            it.doLast {
+                deleteReports(codenarcMarkers.files, codenarcReports.files)
+                deleteReports(checkstyleMarkers.files, checkstyleReports.files)
+            }
+        }
 
-        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+        def writerTask = root.tasks.register('writeStyleViolations') {
             it.group = 'verification'
-            it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/'
-            it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md'))
-            it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md'))
+            it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/'
+            it.inputs.files(codenarcMarkers).optional()
+            it.inputs.files(checkstyleMarkers).optional()
+            it.inputs.property('ignoreFailures', ignoreFailures)
+            it.outputs.file(codenarcMarkdown)
+            it.outputs.file(checkstyleMarkdown)
+            it.outputs.upToDateWhen { false }
+            it.doFirst {
+                codenarcMarkdown.get().asFile.delete()
+                checkstyleMarkdown.get().asFile.delete()
+            }
             it.doLast {
-                parseStyleViolations(styleXmlDir.get(), violationsDir.get(),
-                    checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get())
+                parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(),
+                    checkStyleTests.get(), codenarcEnabled.get(),
+                    checkstyleEnabled.get(), ignoreFailures.get())
             }
         }
-        root.subprojects { Project sub ->
-            sub.pluginManager.withPlugin('codenarc') {
-                aggregateTask.configure {
-                    it.dependsOn(sub.tasks.withType(CodeNarc))
-                }
+        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+            it.group = 'verification'
+            it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/'
+            it.dependsOn(writerTask)
+        }
+        if (root.tasks.names.contains('validateRepositoryConventions')) {

Review Comment:
   Both fixed, and the second observation resolved the first. The `aggregateStyleViolations` to `validateRepositoryConventions` edge is removed entirely, so the name-based re-discovery disappeared with it and there is no ordering dependency on registration order left.
   
   `aggregateViolations` still depends on the conventions task directly, so the umbrella gate is unchanged, and standalone `aggregateStyleViolations` no longer drags a full RAT run along with it.
   
   One important consequence I nearly shipped without noticing, caught by a review pass over the final diff: no workflow actually invokes `aggregateViolations`. `codestyle.yml` runs `aggregateStyleViolations` and `codeanalysis.yml` runs `aggregateAnalysisViolations`, so dropping the edge would have left `validateRepositoryConventions` running in exactly zero CI jobs and the whole gate unenforced on PRs. The edge is still removed as you asked, and the core `codestyle.yml` job now invokes the task explicitly instead. There is a regression test asserting both directions: `aggregateViolations` runs the validator, and `aggregateStyleViolations` alone does not, so the edge cannot be quietly reintroduced.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'

Review Comment:
   Fixed. The excludes are now anchored to real build-output roots instead of any path segment named `build` or `generated`: the plugin computes each project's configured build directory relative to the root at configuration time and excludes those paths specifically.
   
   A composite action at `.github/actions/build/action.yml` is therefore scanned by the standalone sweep, not just when a workflow happens to reference it. Covered by a spec that asserts exactly that pair - the checked-in `build`-named action directory is scanned, a real build-output directory is not.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {

Review Comment:
   Fixed. A leading UTF-8 BOM is stripped before the `---` check, and the two structural failures now produce one specific, actionable violation each instead of three phantom missing-key violations:
   
   - `<path>: skill front matter must start on line 1`
   - `<path>: skill front matter block is unterminated`
   
   `frontMatter` returns `null` for these cases and the caller skips the per-key checks, so the diagnostic is exactly one line and it names the real problem. One spec per case, including a BOM-prefixed file that now passes cleanly.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)

Review Comment:
   Fixed. The character class is extracted into a single `SKILL_DIRECTORY_NAME` constant that both `AGENT_SKILL_PATH` and the discovery-side check are built from, so the two cannot drift again.
   
   A non-conforming directory now produces one explicit violation naming the offending directory and the required pattern, rather than an unfixable downstream complaint. Covered by a spec using a directory containing a dot.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile
+        if (!target.toPath().startsWith(canonicalRoot.toPath())) {
+            violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString())
+            return
+        }
+        if (target.isFile() && target.name ==~ /.*\.ya?ml/) {
+            validateActionManifest(root, target, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        ['action.yml', 'action.yaml'].each { String manifestName ->
+            File manifest = new File(target, manifestName)
+            if (manifest.isFile()) {
+                validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static void validateProperties(File root, List<File> files, List<String> violations) {
+        files.findAll { File file -> file.name.startsWith('messages') && file.name.endsWith('.properties') }.sort().each { File file ->
+            Map<String, Integer> keys = [:]
+            logicalPropertiesLines(file).each { PropertiesLine line ->
+                String key = propertyKey(line.content)
+                if (!key) {
+                    return
+                }
+                if (keys.containsKey(key)) {
+                    violations.add("${relativePath(root, file)}:${line.number}: duplicate message key '${key}' (first declared at line ${keys[key]})".toString())
+                } else {
+                    keys[key] = line.number
+                }
+            }
+        }
+    }
+
+    private static List<PropertiesLine> logicalPropertiesLines(File file) {
+        List<PropertiesLine> result = []
+        String content = null
+        int start = 0
+        file.readLines(StandardCharsets.UTF_8.name()).eachWithIndex { String line, int index ->
+            if (content == null) {
+                String trimmed = line.trim()
+                if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) {
+                    return
+                }
+                content = line
+                start = index + 1
+            } else {
+                content += line.replaceFirst(/^\s+/, '')
+            }
+            if (continues(content)) {
+                content = content.substring(0, content.length() - 1)
+            } else {
+                result << new PropertiesLine(start, content)
+                content = null
+            }
+        }
+        if (content != null) {
+            result << new PropertiesLine(start, content)
+        }
+        result
+    }
+
+    private static boolean continues(String line) {
+        int count = 0
+        for (int index = line.length() - 1; index >= 0 && line.charAt(index) == '\\'; index--) {
+            count++
+        }
+        count % 2 == 1
+    }
+
+    private static String propertyKey(String line) {
+        Properties properties = new Properties()
+        try {
+            properties.load(new StringReader(line))
+        } catch (IllegalArgumentException ignored) {

Review Comment:
   Fixed. The swallow is gone. `propertyKey` now lets `IllegalArgumentException` propagate and the caller reports it as `<path>:<line>: malformed properties line: <reason>`, so a bad `\uXXXX` escape is surfaced instead of masking duplicates of that key. Covered by a spec with a malformed unicode escape.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile
+        if (!target.toPath().startsWith(canonicalRoot.toPath())) {
+            violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString())
+            return
+        }
+        if (target.isFile() && target.name ==~ /.*\.ya?ml/) {
+            validateActionManifest(root, target, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        ['action.yml', 'action.yaml'].each { String manifestName ->
+            File manifest = new File(target, manifestName)
+            if (manifest.isFile()) {
+                validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static void validateProperties(File root, List<File> files, List<String> violations) {
+        files.findAll { File file -> file.name.startsWith('messages') && file.name.endsWith('.properties') }.sort().each { File file ->

Review Comment:
   Agreed and widened. The plugin now feeds the task `**/grails-app/i18n/**/*.properties` instead of `**/messages*.properties`, and the task-side predicate accepts any `.properties` file it is handed, so `spring-security-core.properties` and its locale variants are covered. As you noted they are clean today, and the run confirms it. Spec added for a non-`messages` bundle with a duplicate key.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()

Review Comment:
   Fixed, thank you - I had only done half the job. `skill.readLines()`, `agents.text`, `manifest.text` in `parseYaml`, and the report write all now specify UTF-8 explicitly. The regression that pins an ISO-8859-1 Gradle JVM default was extended to write non-ASCII content into a SKILL.md description and a workflow, and asserts it round-trips correctly through both the violation text and `REPOSITORY_CONVENTIONS.md`.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5820121339

   @matrei  @jamesfredley can you please re-review this since I've made changes to it? 


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "borinquenkid (via GitHub)" <gi...@apache.org>.
borinquenkid commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5024334449

   > @jdaugherty @matrei @borinquenkid @sbglasius this plus the newly created issues, should get us closer to PRs automatically following the expected syntax and passing all of the automated checkers. This PR was the next logical step.
   > 
   > Should save us immense time on PR reviews, once we get this gradually in place.
   
   This is a good beginning. In a follow up start adding SKILL.md for automating the clean up of some of these issues. The CodeNarc script is broken.


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5207640660

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 74298a3fb8b201196f78da3b592d45a708a52dda
   ▶️ Tests:  59440 executed
   ⚪️ Checks: 58/58 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677405547


##########
gradle/rat-root-config.gradle:
##########
@@ -20,6 +20,9 @@ apply plugin: 'org.nosphere.apache.rat'
 tasks.named('rat') {
     def allExcludes = [
             '.asf.yaml', // ASF metadata for github integration excluded from src zip
+            '.omo/**', // local agent work state excluded from src zip
+            '.claude/**', '.clinerules', '.cursorrules', '.windsurfrules', // local agent configuration excluded from src zip

Review Comment:
   Corrected. The important detail is that these are not untracked local files: `CLAUDE.md`, `GEMINI.md`, `.clinerules`, `.cursorrules`, and `.windsurfrules` are tracked symlinks to `AGENTS.md`, and `.claude/skills/*/SKILL.md` are tracked symlinks into `.agents/skills/`. Gitignoring them would be wrong, and excluding them from RAT was unnecessary because they resolve to files that already carry the license header.
   
   So the exclusions are now split by what each mechanism is actually for, and the comments say the true reason:
   
   - `.omo/**` and the new `.worktrees/**` - untracked local agent state and worktrees. Gitignored (that part of your comment was correct and is now done) and excluded from both RAT and the src zip.
   - `.claude/settings.json` - a real JSON file, and JSON cannot carry a license header, same rationale as the existing `spring-configuration-metadata.json` entry.
   - The symlinks - excluded from RAT only, and they ship in the src zip again per your other comment.
   
   On that last one: I first tried dropping the RAT exclusion entirely on the theory that RAT would follow the link to `AGENTS.md`. It does not, portably. `./gradlew rat` then failed with 8 unapproved licenses, because on a platform without symlink support git materializes each one as a plain text file containing the link target. The exclusion is kept with that written down as the reason rather than the previous, inaccurate "excluded from src zip".



##########
.github/workflows/vulnerability-scan.yml:
##########
@@ -36,9 +36,9 @@ jobs:
       contents: read
     steps:
       - name: "📥 Checkout repository"
-        uses: actions/checkout@v6
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/release.yml:
##########
@@ -690,7 +698,7 @@ jobs:
         # Runs on the JDK 21 pin, where settings.gradle auto-prunes the Micronaut island.
         run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH}
       - name: "🚀 Publish to GitHub Pages"
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Agreed, reverted. All `apache/grails-github-actions/*` references are back to `@asf`, and `RepositoryConventionsTask` now exempts the `apache` owner namespace from the SHA rule, so the gate no longer encodes the opposite of this decision. A reference with an empty ref is still a violation, but any version or branch ref is accepted.



##########
.github/workflows/release.yml:
##########
@@ -765,7 +773,7 @@ jobs:
           ref: ${{ env.TAG }}
           token: ${{ secrets.GITHUB_TOKEN }}
       - name: "⚙️ Run post-release"
-        uses: apache/grails-github-actions/post-release@asf
+        uses: apache/grails-github-actions/post-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Agreed, reverted. All `apache/grails-github-actions/*` references are back to `@asf`, and `RepositoryConventionsTask` now exempts the `apache` owner namespace from the SHA rule, so the gate no longer encodes the opposite of this decision. A reference with an empty ref is still a violation, but any version or branch ref is accepted.



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr
           ${DIST_NAME}-${VERSION}-src.zip
           grails
           -x 'grails/.git/*'
           -x 'grails/.github/*'
+          -x 'grails/.omo/*'

Review Comment:
   You are right, and the premise was wrong on my side. These are tracked symlinks to `AGENTS.md` (and, under `.claude/skills/`, to `.agents/skills/`), so there is nothing to gitignore and nothing to decide about checking them in. They are already checked in deliberately.
   
   The zip exclusions for them are removed, so they ship again. What remains excluded from the zip is `.omo/*` and the new `.worktrees/*`, both genuinely local and untracked, plus `.asf.yaml` (see the separate thread below).
   
   `.gitignore` does now gain `.omo/` and `.worktrees/`, which is the part of your comment that was correct: those are the genuinely local, untracked ones.



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr
           ${DIST_NAME}-${VERSION}-src.zip
           grails
           -x 'grails/.git/*'
           -x 'grails/.github/*'
+          -x 'grails/.omo/*'
+          -x 'grails/.claude/*'
+          -x 'grails/.clinerules'
+          -x 'grails/.cursorrules'
+          -x 'grails/.windsurfrules'
+          -x 'grails/CLAUDE.md'

Review Comment:
   Agreed, and removed from the exclude list. `CLAUDE.md`, `GEMINI.md`, `.clinerules`, `.cursorrules`, `.windsurfrules`, and `.claude/**` all ship again.
   
   This works cleanly because the step keeps `zip -y`: they are symlinks, so they are stored as links pointing at `AGENTS.md` and `.agents/skills/**`, which are themselves inside the zip. No dangling links and no duplicated content.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677408470


##########
.github/workflows/gradle.yml:
##########
@@ -950,7 +950,7 @@ jobs:
           compression-level: 0 # Already a zip
       - name: "🚀 Publish to Github Pages"
         if: github.event_name == 'push'
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Agreed, reverted. All `apache/grails-github-actions/*` references are back to `@asf`, and `RepositoryConventionsTask` now exempts the `apache` owner namespace from the SHA rule, so the gate no longer encodes the opposite of this decision. A reference with an empty ref is still a violation, but any version or branch ref is accepted.



##########
.github/workflows/gradle.yml:
##########
@@ -923,7 +923,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -812,7 +812,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -694,7 +694,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -628,7 +628,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5122706383

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI - Groovy Joint Validation Build / build\_grails](https://github.com/apache/grails-core/actions/runs/30483964245/job/90686903927?pr=16025) > :grails-core:test
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer | ❌ | 0%&nbsp;🟢 |
   
   🏷️ Commit: 7a0aceb5ff226125049d933e60ac70a83c67754c
   ▶️ Tests:  2495 executed
   🟡 Checks: 21/53 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer</strong> (:grails-core:test in <a href="https://github.com/apache/grails-core/actions/runs/30483964245/job/90686903927?pr=16025">CI - Groovy Joint Validation Build / build_grails</a>)</summary>
   
   ```
   Condition not satisfied:
   
   baos.toString().contains(StackTraceFilterer.FULL_STACK_TRACE_MESSAGE)
   |    |          |        |                  |
   |    ""         false    |                  Full Stack Trace:
   |                        interface org.grails.exceptions.reporting.StackTraceFilterer
   java.io.ByteArrayOutputStream@4a42bb32
   
   	at org.apache.grails.core.GrailsBootstrapRegistryInitializerSpec.defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer(GrailsBootstrapRegistryInitializerSpec.groovy:257)
   ```
   
   </details>
   
   ### Muted Tests
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer <!-- uniqueId=[engine:spock]/[spec:org.apache.grails.core.GrailsBootstrapRegistryInitializerSpec]/[feature:$spock_feature_0_11] -->
   
   Reuse successful test results:
   
   🔲 ♻️ Only rerun the tests that failed or were muted before
   
   Click the checkbox to trigger a rerun:
   
   🔲 **Rerun jobs**
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3671243486


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {
+            it.doLast {
+                deleteReports(codenarcMarkers.files, codenarcReports.files)
+                deleteReports(checkstyleMarkers.files, checkstyleReports.files)
+            }
+        }
 
-        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+        def writerTask = root.tasks.register('writeStyleViolations') {
             it.group = 'verification'
-            it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/'
-            it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md'))
-            it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md'))
+            it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/'
+            it.inputs.files(codenarcMarkers).optional()
+            it.inputs.files(checkstyleMarkers).optional()
+            it.inputs.property('ignoreFailures', ignoreFailures)
+            it.outputs.file(codenarcMarkdown)
+            it.outputs.file(checkstyleMarkdown)
+            it.outputs.upToDateWhen { false }
+            it.doFirst {
+                codenarcMarkdown.get().asFile.delete()
+                checkstyleMarkdown.get().asFile.delete()
+            }
             it.doLast {
-                parseStyleViolations(styleXmlDir.get(), violationsDir.get(),
-                    checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get())
+                parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(),
+                    checkStyleTests.get(), codenarcEnabled.get(),
+                    checkstyleEnabled.get(), ignoreFailures.get())
             }
         }
-        root.subprojects { Project sub ->
-            sub.pluginManager.withPlugin('codenarc') {
-                aggregateTask.configure {
-                    it.dependsOn(sub.tasks.withType(CodeNarc))
-                }
+        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+            it.group = 'verification'
+            it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/'
+            it.dependsOn(writerTask)
+        }
+        if (root.tasks.names.contains('validateRepositoryConventions')) {
+            aggregateTask.configure { it.dependsOn(root.tasks.named('validateRepositoryConventions')) }
+        }
+        def finalizeTask = root.tasks.register('finalizeStyleViolations') {
+            it.group = 'verification'
+            it.dependsOn(writerTask)
+        }
+        root.allprojects { Project sub ->
+            sub.tasks.withType(CodeNarc).all { CodeNarc codeNarcTask ->

Review Comment:
   `tasks.withType(CodeNarc).all { ... }` (and the same for Checkstyle at 189, Pmd at 267, SpotBugs at 276) eagerly realizes and fully configures every quality task in every module on every invocation — including `./gradlew help` — defeating task-configuration avoidance repo-wide. The previous wiring was lazy (`pluginManager.withPlugin(...) { aggregateTask.configure { it.dependsOn(sub.tasks.withType(CodeNarc)) } }`). Please use `configureEach` for the per-task wiring and collection-based `dependsOn`/`mustRunAfter` for the aggregate/writer tasks. Markers of never-realized tasks are a non-issue: the parser already skips non-existent markers, and a task that was never realized never ran.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {

Review Comment:
   Making `cleanAggregateStyleReports`/`cleanAggregateAnalysisReports` a dependency of every quality task means `./gradlew :grails-core:build` deletes the XML reports and markers of all 60+ modules, and each quality task's own outputs are deleted before its up-to-date check — so no style/analysis task can ever be UP-TO-DATE, and a single-module build invalidates the whole repo's analysis state. `GradleUtils.configureReportMarker` already deletes each task's own report in `doFirst`, so the global deletion is largely redundant. If the goal is purging stale markers from renamed/removed tasks, scope that cleanup to the aggregate lane instead of hooking every quality task.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })

Review Comment:
   `root.tasks.matching { it.name == 'rat' }` has to realize every root-project task to evaluate the predicate. `tasks.named { it == 'rat' }` (`TaskCollection.named(Spec<String>)`, Gradle 8.6+) filters by name without realization. Separately: why `dependsOn` rat at all? This task reads nothing from rat's output — if the intent is ordering, `mustRunAfter` is the right relationship, and the hard dependency silently no-ops when rat isn't registered.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {

Review Comment:
   This tree is missing the `**/.gradle/**` exclude the action.yml tree above has. Since `conventionSources` is an input property it gets snapshotted on every execution, and `.gradle` contains files the daemon mutates during the build (execution history, file-watching journal), which invites nondeterministic input fingerprints. Mirror the exclusions from the tree above.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {
+            it.doLast {
+                deleteReports(codenarcMarkers.files, codenarcReports.files)
+                deleteReports(checkstyleMarkers.files, checkstyleReports.files)
+            }
+        }
 
-        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+        def writerTask = root.tasks.register('writeStyleViolations') {
             it.group = 'verification'
-            it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/'
-            it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md'))
-            it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md'))
+            it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/'
+            it.inputs.files(codenarcMarkers).optional()
+            it.inputs.files(checkstyleMarkers).optional()
+            it.inputs.property('ignoreFailures', ignoreFailures)
+            it.outputs.file(codenarcMarkdown)
+            it.outputs.file(checkstyleMarkdown)
+            it.outputs.upToDateWhen { false }
+            it.doFirst {
+                codenarcMarkdown.get().asFile.delete()
+                checkstyleMarkdown.get().asFile.delete()
+            }
             it.doLast {
-                parseStyleViolations(styleXmlDir.get(), violationsDir.get(),
-                    checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get())
+                parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(),
+                    checkStyleTests.get(), codenarcEnabled.get(),
+                    checkstyleEnabled.get(), ignoreFailures.get())
             }
         }
-        root.subprojects { Project sub ->
-            sub.pluginManager.withPlugin('codenarc') {
-                aggregateTask.configure {
-                    it.dependsOn(sub.tasks.withType(CodeNarc))
-                }
+        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+            it.group = 'verification'
+            it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/'
+            it.dependsOn(writerTask)
+        }
+        if (root.tasks.names.contains('validateRepositoryConventions')) {
+            aggregateTask.configure { it.dependsOn(root.tasks.named('validateRepositoryConventions')) }
+        }
+        def finalizeTask = root.tasks.register('finalizeStyleViolations') {
+            it.group = 'verification'
+            it.dependsOn(writerTask)
+        }
+        root.allprojects { Project sub ->
+            sub.tasks.withType(CodeNarc).all { CodeNarc codeNarcTask ->
+                codenarcMarkers.from(GradleUtils.reportMarker(sub, 'codenarc', codeNarcTask.name))
+                def reportLocation = codeNarcTask.reports.xml.outputLocation
+                codenarcReports.from { reportLocation.get().asFile }
+                codeNarcTask.dependsOn(cleanupTask)
+                codeNarcTask.finalizedBy(finalizeTask)

Review Comment:
   Because every quality task is `finalizedBy` the report writer and the cleanup task wipes all markers first, `./gradlew :grails-core:check` regenerates `CODENARC_VIOLATIONS.md`/`CHECKSTYLE_VIOLATIONS.md` from that one module's data — indistinguishable from a clean full-repo run. These are exactly the files the pre-commit gate tells contributors to check, so silently replacing a complete report with a partial-but-green one is misleading. Only rewrite the aggregate markdown when the aggregate lane runs, or record the contributing modules in the report header so partial scope is visible.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {
+            it.doLast {
+                deleteReports(codenarcMarkers.files, codenarcReports.files)
+                deleteReports(checkstyleMarkers.files, checkstyleReports.files)
+            }
+        }
 
-        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+        def writerTask = root.tasks.register('writeStyleViolations') {
             it.group = 'verification'
-            it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/'
-            it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md'))
-            it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md'))
+            it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/'
+            it.inputs.files(codenarcMarkers).optional()
+            it.inputs.files(checkstyleMarkers).optional()
+            it.inputs.property('ignoreFailures', ignoreFailures)
+            it.outputs.file(codenarcMarkdown)
+            it.outputs.file(checkstyleMarkdown)
+            it.outputs.upToDateWhen { false }
+            it.doFirst {
+                codenarcMarkdown.get().asFile.delete()
+                checkstyleMarkdown.get().asFile.delete()
+            }
             it.doLast {
-                parseStyleViolations(styleXmlDir.get(), violationsDir.get(),
-                    checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get())
+                parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(),
+                    checkStyleTests.get(), codenarcEnabled.get(),
+                    checkstyleEnabled.get(), ignoreFailures.get())
             }
         }
-        root.subprojects { Project sub ->
-            sub.pluginManager.withPlugin('codenarc') {
-                aggregateTask.configure {
-                    it.dependsOn(sub.tasks.withType(CodeNarc))
-                }
+        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+            it.group = 'verification'
+            it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/'
+            it.dependsOn(writerTask)
+        }
+        if (root.tasks.names.contains('validateRepositoryConventions')) {

Review Comment:
   `apply()` already holds this `TaskProvider`; re-discovering it by name creates a hidden ordering dependency on registration order — pass it in as a parameter instead. Also, the asymmetry looks accidental: `aggregateStyleViolations` depends on the conventions task (and transitively forces a full RAT run) while `aggregateAnalysisViolations` doesn't, and `aggregateViolations` already depends on the conventions task directly, so this extra edge is redundant for the umbrella and only taxes standalone style runs.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set

Review Comment:
   `import java.util.Set` is redundant (`java.util.*` is default-imported in Groovy), and the `java.*` block sits after the third-party imports, inverting the ordering used by the neighboring build-logic sources. build-logic isn't covered by `codeStyle`, so this needs aligning manually.



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr

Review Comment:
   `zip -y` stores symlinks, but the normalization step above (`find . -depth \( -type f -o -type d \) ... touch`) never touches them — symlinks match neither type, and `touch` without `-h` would dereference anyway. Today every repo symlink happens to be covered by the `-x` list, so this is latent; the first symlink added outside it makes the signed source zip non-byte-reproducible (and ships a dangling link if its target is excluded). Add `-o -type l` with `touch -h -d`, or a guard step that fails on non-excluded symlinks.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {

Review Comment:
   A UTF-8 BOM, front matter that doesn't start on line 1, or an unterminated `---` block all fall through to `[:]`, which the caller then reports as missing 'name'/'description'/'license' — three violations for keys plainly present in the file, which no editor view can explain. Please strip a leading BOM and emit a dedicated "front matter missing/unterminated/must start on line 1" violation for these cases so the diagnostic is actionable.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile
+        if (!target.toPath().startsWith(canonicalRoot.toPath())) {
+            violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString())
+            return
+        }
+        if (target.isFile() && target.name ==~ /.*\.ya?ml/) {
+            validateActionManifest(root, target, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        ['action.yml', 'action.yaml'].each { String manifestName ->
+            File manifest = new File(target, manifestName)
+            if (manifest.isFile()) {
+                validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static void validateProperties(File root, List<File> files, List<String> violations) {
+        files.findAll { File file -> file.name.startsWith('messages') && file.name.endsWith('.properties') }.sort().each { File file ->

Review Comment:
   This only scans `messages*.properties`, but bundles like grails-spring-security's `spring-security-core.properties` (plus its locale variants) are loaded by the same MessageSource machinery and stay permanently unscanned. Suggest widening to all `*.properties` under `grails-app/i18n` — they're currently clean, so widening won't break the build.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile

Review Comment:
   The containment check uses `canonicalRoot`, but `validateActionManifest` receives the original non-canonical `root`, and `isWorkflowManifest` classifies via `relativePath(root, file)`. With a symlink component in the checkout path (e.g. `/tmp` on macOS), a `uses: ./.github/workflows/x.yml` reference relativizes to a `../..`-style path, gets misclassified as a composite action (validating nothing), and `validatedManifests` then dedup-skips the file when the outer loop reaches it directly. Canonicalize `root` once and use it consistently. Latent today (no `./` workflow refs), but this is the designated local-action path.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()

Review Comment:
   `if: always()` runs this step even when the run is **cancelled**, so the cancel-in-progress concurrency this workflow declares (added specifically over resource limits) frees nothing — every superseded PR run still executes the full advisory SpotBugs pass on the doomed runner. Use `success() || failure()` on the advisory run/upload/summary steps, here and in the gradle-plugin variant below.



##########
AGENTS.md:
##########
@@ -79,6 +82,19 @@ export GRADLE_OPTS="-Xms2G -Xmx5G"
 | **hibernate-developer** | `.agents/skills/hibernate-developer/SKILL.md` | Hibernate 7 mapping, binders, generators |
 | **violation-fixer** | `.agents/skills/violation-fixer/SKILL.md` | Fix style/analysis violations (CodeNarc, Checkstyle, PMD, SpotBugs) |
 | **test-fixer** | `.agents/skills/test-fixer/SKILL.md` | Aggregate and fix test failures |
+| **mono-repo-integration** | `.agents/skills/mono-repo-integration/SKILL.md` | Integrate a standalone repository into the monorepo |
+
+## Repository Conventions
+
+Run `./gradlew validateRepositoryConventions` to check canonical skill metadata and AGENTS.md synchronization, external GitHub Action SHA pins in workflows and local composite actions, immutable Docker digests, duplicate message keys, and RAT license provenance. The task writes `build/reports/violations/REPOSITORY_CONVENTIONS.md` and is included by `aggregateStyleViolations` and `aggregateViolations`.
+
+Review-only checklist:

Review Comment:
   These five labels don't carry enough meaning on their own — "Quote and type preference" or "Compatibility" can't be acted on without the PR description in hand, and that doesn't live in the repo. Either expand each into a one-line actionable statement (e.g. point quote/type preference at the Groovy Style section) with a note that these are human-review concerns intentionally not mechanized, or drop the list.



##########
AGENTS.md:
##########
@@ -52,7 +55,7 @@ export GRADLE_OPTS="-Xms2G -Xmx5G"
 9. **Test via public APIs** - Tests must exercise behavior through the same APIs an end user calls; never invoke internal implementations, package-private methods, or bypass the public surface directly
 10. **Always review and extend tests** - Review existing unit and functional tests before making changes; every code change must include new or enhanced tests that cover the affected behavior
 11. **Every code touch must update all tests for the changed class** - When a class is modified, find and update every test that covers it — unit, integration, and TCK. Do not leave any existing test out of sync with the new code.
-12. **Clean violations before commit** - Before every automated commit, run `./gradlew clean aggregateViolations :grails-test-report:check --continue` from the root and ensure that `build/reports/violations/CHECKSTYLE_VIOLATIONS.md`, `build/reports/violations/CODENARC_VIOLATIONS.md`, `build/reports/violations/PMD_VIOLATIONS.md`, and `build/reports/violations/SPOTBUGS_VIOLATIONS.md` report no issues. Also review the test result reports under `grails-test-report/build/reports/tests/` and ensure there are no failures. The aggregate reports are wired as test finalizers and will be attempted after failures, but `--continue` is required for comprehensive full-suite reports.
+12. **Clean violations before commit** - Before every automated commit, run `./gradlew clean aggregateViolations :grails-test-report:check --continue` from the root. Ensure Checkstyle, CodeNarc, and PMD reports have no issues for their enabled projects; PMD is enforced only for the project paths in `grails.code-analysis.enabled.pmd.projects`. Disabled tools report their disabled status, not a clean result. Also review the test result reports under `grails-test-report/build/reports/tests/` and ensure there are no failures. The aggregate reports are wired as test finalizers and will be attempted after failures, but `--continue` is required for comprehensive full-suite reports.

Review Comment:
   The rewritten gate says to check "Checkstyle, CodeNarc, and PMD reports" — it never mentions `build/reports/violations/REPOSITORY_CONVENTIONS.md`, the one report this PR introduces, produced by the same command. It also dropped SpotBugs from the checklist even though staged SpotBugs enablement is now a supported property. Suggest: "Ensure the Checkstyle, CodeNarc, REPOSITORY_CONVENTIONS, and PMD/SpotBugs reports (for their enabled projects) have no issues" — here and in the Pull Request Guidelines step below.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -243,9 +382,11 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
     }
 
     @CompileDynamic
-    private static void parseStyleViolations(Directory styleXmlDir, Directory violationsDir,
-            boolean checkStyleTests, boolean codenarcEnabled, boolean checkstyleEnabled) {
+    private static void parseStyleViolations(Set<File> codenarcMarkers, Set<File> checkstyleMarkers, Directory rootDirectory,
+            Directory violationsDir, boolean checkStyleTests,
+            boolean codenarcEnabled, boolean checkstyleEnabled, boolean ignoreFailures) {

Review Comment:
   `ignoreFailures` is declared as an input on `writeStyleViolations` (line 153) and threaded in here, but the method never reads it — the only throw is the unconditional missing-reports one, while `parseAnalysisViolations` enforces `!ignoreFailures && hasFindings`. Either the style writer was meant to mirror that gate (missing behavior plus its test), or the parameter and the input declaration should be removed — as written the declared input cannot influence the task's output.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -128,33 +131,38 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
 
         project.tasks.withType(Pmd).configureEach {
             it.group = 'verification'
-            it.onlyIf { !project.hasProperty('skipCodeStyle') }
+            it.onlyIf { !skipCodeStyle.present }
             it.ignoreFailures = ignoreFailures.get()
 
             if (it.name.contains('Test') || it.name.contains('test')) {
                 it.enabled = testStylingEnabled.get()
             }
 
+            it.exclude { org.gradle.api.file.FileTreeElement element ->
+                element.file.toPath().toAbsolutePath().normalize().startsWith(projectBuildDirectory.get())
+            }
+
             it.reports.xml.required.set(true)
             it.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeAnalysisExtension)
-                            .reportsDirectory.get()
-                            .dir('pmd')
-                            .file("${project.name}-${it.name}.xml")
+                            project.extensions.getByType(GrailsCodeAnalysisExtension)
+                            .reportsDirectory
+                            .file("pmd/${GradleUtils.reportFileName(project, it.name)}")
             )
+            GradleUtils.configureReportMarker(it, project.rootProject.layout.projectDirectory, it.reports.xml.outputLocation,
+                    GradleUtils.reportMarker(project, 'pmd', it.name))
         }
     }
 
     static void configureSpotbugs(Project project) {

Review Comment:
   `configureSpotbugs` was materially rewired here (projects allowlist, marker creation, report relocation via `reports.maybeCreate` — a different path than PMD), and `parseAnalysisViolations` grew a SpotBugs branch, yet no test anywhere runs a SpotBugs task or feeds SpotBugs XML through aggregation — and no CI run gates it (the advisory steps are continue-on-error). A single TestKit feature (allowlist one project, run `spotbugsMain` on a trivially buggy class, assert `SPOTBUGS_VIOLATIONS.md` content plus the marker) would cover the whole chain.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile
+        if (!target.toPath().startsWith(canonicalRoot.toPath())) {
+            violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString())
+            return
+        }
+        if (target.isFile() && target.name ==~ /.*\.ya?ml/) {

Review Comment:
   Three implemented branches have no spec coverage: this direct-file branch (`uses: ./.github/workflows/x.yml` — a regression here silently stops recursing into locally referenced workflows), the trailing-`@` guard at line 344, and even-backslash (escaped, non-continuing) lines in `continues()`. Each is a small addition to the existing fixtures.



##########
build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPluginSpec.groovy:
##########
@@ -0,0 +1,142 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import java.nio.file.Path
+
+import org.gradle.testkit.runner.GradleRunner
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class GrailsCodeAnalysisPluginSpec extends Specification {
+
+    @TempDir
+    Path testProjectDir
+
+    def "PMD project allowlist enables only listed projects"() {
+        given:
+        writeMultiProjectBuild('grails.code-analysis.enabled.pmd.projects=:selected')
+
+        when:
+        def result = run('tasks', '--all', '--configuration-cache')
+
+        then:
+        result.output.contains('selected:pmdMain')
+        !result.output.contains('excluded:pmdMain')
+    }
+
+    def "global PMD opt-in remains compatible with project selection"() {

Review Comment:
   The name promises the global-flag/allowlist interaction, but the fixture sets only `grails.code-analysis.enabled.pmd=true` — no test anywhere sets both properties simultaneously, so the OR contract of `isToolEnabled` is unverified. Set both here (global=true plus `projects=:selected`, assert both projects still get `pmdMain`) or rename the feature so the suite doesn't claim coverage it lacks.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)

Review Comment:
   This character class (`[A-Za-z0-9_-]`) is narrower than skill discovery, which accepts any directory name (`[^/]+` at line 78). A skill directory containing e.g. a dot passes discovery and the name==directory check, yet can never be matched in AGENTS.md — producing a "missing canonical skill path" violation no edit can fix. Align the two patterns, or reject non-conforming directory names with an explicit violation.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()

Review Comment:
   The UTF-8 fix covers the properties path only: `skill.readLines()` here, `agents.text` (line 108), `manifest.text` in `parseYaml` (line 206), and the report write (line 454) still use the JVM default charset. Under the ISO-8859-1 daemon configuration the new regression test pins, non-ASCII SKILL.md/YAML content mis-decodes silently (ISO-8859-1 maps every byte, so no error) and the report is written in Latin-1. Please use UTF-8 explicitly for every read and the write.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Map<?, ?> workflow = (Map<?, ?>) document
+        validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests)
+        Object jobs = workflow.get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            String jobLocation = "\$.jobs.${jobName}"
+            if (jobDefinition.containsKey('uses')) {
+                validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations,
+                        validatedManifests)
+            }
+            validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateCompositeActionUses(File root, Object document, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        if (runs instanceof Map) {
+            validateStepUses(root, ((Map<?, ?>) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations,
+                    validatedManifests)
+        }
+    }
+
+    private static void validateStepUses(File root, Object steps, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(steps instanceof Iterable)) {
+            return
+        }
+        int index = 0
+        ((Iterable<?>) steps).each { Object step ->
+            if (step instanceof Map && ((Map<?, ?>) step).containsKey('uses')) {
+                validateActionUse(root, ((Map<?, ?>) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles,
+                        violations, validatedManifests)
+            }
+            index++
+        }
+    }
+
+    private static void validateActionUse(File root, Object value, String location, String path, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        if (!(value instanceof String)) {
+            violations.add("${path}:${location}: 'uses' must be a string".toString())
+            return
+        }
+        String use = (String) value
+        if (use.startsWith('./')) {
+            validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        if (use.startsWith('docker://')) {
+            if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) {
+                violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString())
+            }
+            return
+        }
+        int separator = use.lastIndexOf('@')
+        if (separator <= 0 || separator == use.length() - 1) {
+            violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString())
+            return
+        }
+        String action = use.substring(0, separator)
+        String sha = use.substring(separator + 1)
+        if (!COMMIT_SHA.matcher(sha).matches()) {
+            violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString())
+        } else if (actionShas.containsKey(action) && actionShas[action] != sha) {
+            violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString())
+        } else {
+            actionShas[action] = sha
+            actionFiles[action] = path
+        }
+    }
+
+    private static void validateLocalAction(File root, String use, String location, String path,
+            Map<String, String> actionShas, Map<String, String> actionFiles, List<String> violations,
+            Set<String> validatedManifests) {
+        File canonicalRoot = root.canonicalFile
+        File target = new File(root, use.substring(2)).canonicalFile
+        if (!target.toPath().startsWith(canonicalRoot.toPath())) {
+            violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString())
+            return
+        }
+        if (target.isFile() && target.name ==~ /.*\.ya?ml/) {
+            validateActionManifest(root, target, actionShas, actionFiles, violations, validatedManifests)
+            return
+        }
+        ['action.yml', 'action.yaml'].each { String manifestName ->
+            File manifest = new File(target, manifestName)
+            if (manifest.isFile()) {
+                validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static void validateProperties(File root, List<File> files, List<String> violations) {
+        files.findAll { File file -> file.name.startsWith('messages') && file.name.endsWith('.properties') }.sort().each { File file ->
+            Map<String, Integer> keys = [:]
+            logicalPropertiesLines(file).each { PropertiesLine line ->
+                String key = propertyKey(line.content)
+                if (!key) {
+                    return
+                }
+                if (keys.containsKey(key)) {
+                    violations.add("${relativePath(root, file)}:${line.number}: duplicate message key '${key}' (first declared at line ${keys[key]})".toString())
+                } else {
+                    keys[key] = line.number
+                }
+            }
+        }
+    }
+
+    private static List<PropertiesLine> logicalPropertiesLines(File file) {
+        List<PropertiesLine> result = []
+        String content = null
+        int start = 0
+        file.readLines(StandardCharsets.UTF_8.name()).eachWithIndex { String line, int index ->
+            if (content == null) {
+                String trimmed = line.trim()
+                if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) {
+                    return
+                }
+                content = line
+                start = index + 1
+            } else {
+                content += line.replaceFirst(/^\s+/, '')
+            }
+            if (continues(content)) {
+                content = content.substring(0, content.length() - 1)
+            } else {
+                result << new PropertiesLine(start, content)
+                content = null
+            }
+        }
+        if (content != null) {
+            result << new PropertiesLine(start, content)
+        }
+        result
+    }
+
+    private static boolean continues(String line) {
+        int count = 0
+        for (int index = line.length() - 1; index >= 0 && line.charAt(index) == '\\'; index--) {
+            count++
+        }
+        count % 2 == 1
+    }
+
+    private static String propertyKey(String line) {
+        Properties properties = new Properties()
+        try {
+            properties.load(new StringReader(line))
+        } catch (IllegalArgumentException ignored) {

Review Comment:
   Catching `IllegalArgumentException` and returning null makes the caller skip the line silently, so a malformed `\uXXXX` escape masks duplicates of that key — even though the bundle will still fail at runtime when `Properties`/MessageSource loads it. An unparseable logical line should itself be reported as a violation rather than discarded.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'

Review Comment:
   `**/build/**` and `**/generated/**` match checked-in directories too, not just build output. A composite action at `.github/actions/build/action.yml` (the exact name the spec fixtures use) would be invisible to the standalone scan and only validated if a scanned workflow happens to reference it via `uses: ./...`. Consider anchoring the excludes to actual build-output roots or filtering on git-tracked status.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)

Review Comment:
   This can't distinguish commit SHAs from annotated-tag-object SHAs. The repo already contains one the gate blesses: `release-notes.yml:150` pins `release-drafter@e1247478...`, which is the v7.3.1 tag object, not the peeled commit (`693d20e7...`) — that file's own bump checklist documents peeling it. A tag-object pin is still immutable but becomes unfetchable if the tag is deleted and diverges from every other pin in the repo. Worth normalizing that pin in this PR or documenting that tag-object SHAs pass the gate.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy:
##########
@@ -0,0 +1,474 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.yaml.snakeyaml.LoaderOptions
+import org.yaml.snakeyaml.Yaml
+import org.yaml.snakeyaml.constructor.SafeConstructor
+import org.yaml.snakeyaml.error.YAMLException
+
+import java.nio.charset.StandardCharsets
+import java.util.Set
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+@CompileStatic
+abstract class RepositoryConventionsTask extends DefaultTask {
+
+    private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/)
+    private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/)
+    private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/)
+    private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/)
+
+    @Internal
+    abstract DirectoryProperty getRepositoryDirectory()
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getConventionSources()
+
+    @OutputFile
+    abstract RegularFileProperty getReportFile()
+
+    @TaskAction
+    void validateRepositoryConventions() {
+        File root = repositoryDirectory.get().asFile
+        List<File> files = conventionSources.files.toList()
+        List<String> violations = []
+        validateSkills(root, files, violations)
+        validateActions(root, files, violations)
+        validateProperties(root, files, violations)
+        writeReport(violations)
+        if (!violations.isEmpty()) {
+            List<String> safeViolations = violations.collect { String violation -> sanitizeViolation(violation) }
+            throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}")
+        }
+    }
+
+    private static void validateSkills(File root, List<File> files, List<String> violations) {
+        List<File> skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort()
+        Map<String, File> names = [:]
+        Set<String> canonicalPaths = []
+        skills.each { File skill ->
+            String path = relativePath(root, skill)
+            String directoryName = skill.parentFile.name
+            Map<String, String> metadata = frontMatter(skill, path, violations)
+            ['name', 'description', 'license'].each { String key ->
+                if (!metadata[key]) {
+                    violations.add("${path}: skill front matter is missing '${key}'".toString())
+                }
+            }
+            String name = metadata['name']
+            if (name && name != directoryName) {
+                violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString())
+            }
+            if (name && names.containsKey(name)) {
+                violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString())
+            } else if (name) {
+                names[name] = skill
+            }
+            canonicalPaths << path
+        }
+
+        File agents = new File(root, 'AGENTS.md')
+        if (!agents.isFile()) {
+            violations << 'AGENTS.md: file is missing'
+            return
+        }
+        Set<String> documentedPaths = []
+        Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text)
+        while (matcher.find()) {
+            documentedPaths << matcher.group()
+        }
+        canonicalPaths.each { String path ->
+            if (!documentedPaths.contains(path)) {
+                violations.add("AGENTS.md: missing canonical skill path '${path}'".toString())
+            }
+        }
+        documentedPaths.each { String path ->
+            if (!new File(root, path).isFile()) {
+                violations.add("AGENTS.md: skill path '${path}' does not exist".toString())
+            }
+        }
+    }
+
+    private static Map<String, String> frontMatter(File skill, String path, List<String> violations) {
+        List<String> lines = skill.readLines()
+        if (lines.isEmpty() || lines[0] != '---') {
+            return [:]
+        }
+        int end = -1
+        for (int index = 1; index < lines.size(); index++) {
+            if (lines[index] == '---') {
+                end = index
+                break
+            }
+        }
+        if (end < 0) {
+            return [:]
+        }
+        Object document
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n'))
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed skill front matter: ${exception.message}".toString())
+            return [:]
+        }
+        if (!(document instanceof Map)) {
+            violations.add("${path}: skill front matter must be a YAML mapping".toString())
+            return [:]
+        }
+        Map<String, String> values = [:]
+        ['name', 'description', 'license'].each { String key ->
+            Object value = ((Map<?, ?>) document).get(key)
+            if (value instanceof String) {
+                values[key] = (String) value
+            } else if (value != null) {
+                violations.add("${path}: skill front matter field '${key}' must be a string".toString())
+            }
+        }
+        values
+    }
+
+    private static void validateActions(File root, List<File> files, List<String> violations) {
+        Map<String, String> actionShas = [:]
+        Map<String, String> actionFiles = [:]
+        Set<String> validatedManifests = []
+        files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest ->
+            validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests)
+        }
+    }
+
+    private static void validateActionManifest(File root, File manifest, Map<String, String> actionShas,
+            Map<String, String> actionFiles, List<String> violations, Set<String> validatedManifests) {
+        String canonicalPath = manifest.canonicalPath
+        if (!validatedManifests.add(canonicalPath)) {
+            return
+        }
+        String path = relativePath(root, manifest)
+        Object document = parseYaml(manifest, path, violations)
+        if (document != null) {
+            validateDockerActionImage(document, path, violations)
+            if (isWorkflowManifest(root, manifest)) {
+                validateWorkflowContainerImages(document, path, violations)
+                validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            } else {
+                validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests)
+            }
+        }
+    }
+
+    private static boolean isActionManifest(File root, File file) {
+        String path = relativePath(root, file)
+        isWorkflowManifest(root, file) ||
+                path ==~ /(?:^|.*\/)action\.ya?ml$/
+    }
+
+    private static boolean isWorkflowManifest(File root, File file) {
+        relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/
+    }
+
+    private static Object parseYaml(File manifest, String path, List<String> violations) {
+        try {
+            LoaderOptions options = new LoaderOptions()
+            options.setAllowDuplicateKeys(false)
+            new Yaml(new SafeConstructor(options)).load(manifest.text)
+        } catch (YAMLException exception) {
+            violations.add("${path}: malformed YAML: ${exception.message}".toString())
+            null
+        }
+    }
+
+    private static void validateDockerActionImage(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object runs = ((Map<?, ?>) document).get('runs')
+        Object using = runs instanceof Map ? ((Map<?, ?>) runs).get('using') : null
+        if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) {
+            return
+        }
+        Object image = ((Map<?, ?>) runs).get('image')
+        String location = '$.runs.image'
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: Docker action image must be a string".toString())
+        } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) {
+            violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString())
+        }
+    }
+
+    private static void validateWorkflowContainerImages(Object document, String path, List<String> violations) {
+        if (!(document instanceof Map)) {
+            return
+        }
+        Object jobs = ((Map<?, ?>) document).get('jobs')
+        if (!(jobs instanceof Map)) {
+            return
+        }
+        ((Map<?, ?>) jobs).each { Object jobName, Object job ->
+            if (!(job instanceof Map)) {
+                return
+            }
+            String jobLocation = "\$.jobs.${jobName}"
+            Map<?, ?> jobDefinition = (Map<?, ?>) job
+            if (jobDefinition.containsKey('container')) {
+                Object container = jobDefinition.get('container')
+                if (container instanceof Map) {
+                    validateContainerImage(((Map<?, ?>) container).get('image'), "${jobLocation}.container.image", path, violations)
+                } else {
+                    validateContainerImage(container, "${jobLocation}.container", path, violations)
+                }
+            }
+            Object services = jobDefinition.get('services')
+            if (services instanceof Map) {
+                ((Map<?, ?>) services).each { Object serviceName, Object service ->
+                    if (service instanceof Map && ((Map<?, ?>) service).containsKey('image')) {
+                        validateContainerImage(((Map<?, ?>) service).get('image'), "${jobLocation}.services.${serviceName}.image", path,
+                                violations)
+                    }
+                }
+            }
+        }
+    }
+
+    private static void validateContainerImage(Object image, String location, String path, List<String> violations) {
+        if (!(image instanceof String)) {
+            violations.add("${path}:${location}: container image must be a string".toString())
+        } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) {

Review Comment:
   This validates the literal expression text, so a digest-pinned matrix image (`image: ${{ matrix.mongo-image }}` with digest-pinned matrix values) can never pass even though it's still immutable. Is that strictness intended? If so it's worth documenting so the first matrix-image job author understands the rejection; otherwise accept `${{ ... }}` expressions here.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd.projects=" -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: core-spotbugs-advisory-reports
+          path: build/reports/violations/
+      - name: "📋 Publish SpotBugs Advisory Report in Job Summary"
+        if: always()

Review Comment:
   If the advisory Gradle run crashes before the writer produces the markdown (the known #16014 failure modes), this summary emits a bare heading on a green job — SpotBugs advisory coverage can silently stop producing reports for months. Give the run step an `id` and echo `steps.<id>.outcome` into the summary, as vulnerability-scan.yml already does; same for the gradle-plugin variant.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd.projects=" -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: core-spotbugs-advisory-reports
+          path: build/reports/violations/

Review Comment:
   This uploads the whole violations directory, but the advisory pass runs with PMD disabled and the report writer rewrites `PMD_VIOLATIONS.md` to "PMD is disabled." — so the artifact contradicts the enforced PMD step that just ran in this same job. Scope the upload to `SPOTBUGS_VIOLATIONS.md`, the only file this pass legitimately produces (same for the gradle-plugin variant).



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr
           ${DIST_NAME}-${VERSION}-src.zip
           grails
           -x 'grails/.git/*'

Review Comment:
   `gradle/rat-root-config.gradle` line 22 excludes `.asf.yaml` with the comment "excluded from src zip", but it's neither in this `-x` list nor removed by the cleanup step — so it ships unaudited, the exact RAT-vs-zip divergence this PR is eliminating for the agent files. Add it here or correct the RAT comment.



##########
AGENTS.md:
##########
@@ -79,6 +82,19 @@ export GRADLE_OPTS="-Xms2G -Xmx5G"
 | **hibernate-developer** | `.agents/skills/hibernate-developer/SKILL.md` | Hibernate 7 mapping, binders, generators |
 | **violation-fixer** | `.agents/skills/violation-fixer/SKILL.md` | Fix style/analysis violations (CodeNarc, Checkstyle, PMD, SpotBugs) |
 | **test-fixer** | `.agents/skills/test-fixer/SKILL.md` | Aggregate and fix test failures |
+| **mono-repo-integration** | `.agents/skills/mono-repo-integration/SKILL.md` | Integrate a standalone repository into the monorepo |
+
+## Repository Conventions
+
+Run `./gradlew validateRepositoryConventions` to check canonical skill metadata and AGENTS.md synchronization, external GitHub Action SHA pins in workflows and local composite actions, immutable Docker digests, duplicate message keys, and RAT license provenance. The task writes `build/reports/violations/REPOSITORY_CONVENTIONS.md` and is included by `aggregateStyleViolations` and `aggregateViolations`.

Review Comment:
   This enforcement collides with open #15977, which removes the hardcoded skill index from AGENTS.md by design ("the directory is the source of truth") and replaces it with a glob the validator's regex does not match. If #15977 rebases onto this, `validateRepositoryConventions` fails for every skill except grails-developer; if this rebases onto #15977, it silently re-imposes the index that PR removes. That's stronger than a documentation overlap — it encodes one side of a contested convention into CI. Let's decide which model wins before either merges; the validator could equally accept a discovery-based AGENTS.md.



##########
.github/workflows/release.yml:
##########
@@ -224,7 +224,7 @@ jobs:
         # staged from a JDK 25 runner. This is a NEW reproducibility pin -
         # keep $JAVA_VERSION_MICRONAUT synced with the secondary JDK in
         # etc/bin/Dockerfile so verifiers can reproduce the resulting JARs.
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   This isn't a pin-in-place: `@v4` -> v5.2.0 crosses a **major** version of setup-java (Node 24 runtime, v5 behavior changes) in the two steps explicitly commented as reproducibility pins for staged release JARs, without being called out anywhere. If it stays, please confirm v5 resolves identical Liberica archives for both `JAVA_VERSION` values — and note v5.2.0 is already four minors behind v5.6.0, which illustrates the maintenance burden of SHA-pinning this namespace.



##########
build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy:
##########
@@ -73,11 +76,33 @@ class GrailsViolationAggregationPluginSpec extends Specification {
         result.output.contains('aggregateStyleViolations')
         result.output.contains('aggregateAnalysisViolations')
         result.output.contains('aggregateViolations')
+        result.output.contains('validateRepositoryConventions')
         result.output.contains('aggregateJacocoCoverage')
     }
 
-    def "aggregateStyleViolations writes CodeNarc and Checkstyle reports to build/reports/violations/"() {
-        given: "a root project with a subproject that has codestyle XML reports"
+    def "canonical roots report a missing AGENTS.md"() {
+        given:
+        testProjectDir.resolve('settings.gradle').toFile().text = ''
+        testProjectDir.resolve('.github/workflows').toFile().mkdirs()
+        testProjectDir.resolve('build.gradle').toFile().text = """
+            plugins {
+                id 'org.apache.grails.gradle.grails-violation-aggregation'
+            }
+        """
+
+        when:
+        def result = GradleRunner.create()
+                .withProjectDir(testProjectDir.toFile())
+                .withArguments('validateRepositoryConventions', '--stacktrace')
+                .withPluginClasspath()
+                .buildAndFail()
+
+        then:
+        result.output.contains('AGENTS.md: file is missing')
+    }
+
+    def "nested builds ignore stale legacy style XML without AGENTS.md"() {

Review Comment:
   The base test this replaces asserted real Checkstyle XML content landed in `CHECKSTYLE_VIOLATIONS.md` ("JavadocPackageCheck", "## Module: app-module"). This fixture has no sources, so `checkstyleMain`/`codenarcMain` are NO-SOURCE and the marker-based Checkstyle parse branch now runs with data in no test at all (the renamed-reports feature covers CodeNarc only). The style-side missing-report failure is also untested — only the PMD side is. Coverage dropped exactly where report location changed; please add a Checkstyle positive-path feature asserting report content and module resolution from the marker.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -128,33 +131,38 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
 
         project.tasks.withType(Pmd).configureEach {
             it.group = 'verification'
-            it.onlyIf { !project.hasProperty('skipCodeStyle') }
+            it.onlyIf { !skipCodeStyle.present }
             it.ignoreFailures = ignoreFailures.get()
 
             if (it.name.contains('Test') || it.name.contains('test')) {
                 it.enabled = testStylingEnabled.get()
             }
 
+            it.exclude { org.gradle.api.file.FileTreeElement element ->

Review Comment:
   Inline fully-qualified `org.gradle.api.file.FileTreeElement` — the file otherwise uses a full explicit-import block. Add the import and use the simple name; build-logic isn't covered by `codeStyle`, so it won't be auto-flagged.



##########
build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy:
##########
@@ -141,15 +163,496 @@ class GrailsViolationAggregationPluginSpec extends Specification {
         !testProjectDir.resolve('CHECKSTYLE_VIOLATIONS.md').toFile().exists()
         !testProjectDir.resolve('CODENARC_VIOLATIONS.md').toFile().exists()
 
-        and: "checkstyle report contains the violation"
+        and: "legacy Checkstyle XML is ignored"
         def checkstyleMd = new File(violationsDir, 'CHECKSTYLE_VIOLATIONS.md').text
-        checkstyleMd.contains('## Module: app-module')
-        checkstyleMd.contains('JavadocPackageCheck')
+        !checkstyleMd.contains('Legacy report.')
 
-        and: "codenarc report contains the violation"
+        and: "legacy CodeNarc XML is ignored"
         def codenarcMd = new File(violationsDir, 'CODENARC_VIOLATIONS.md').text
-        codenarcMd.contains('## Module: app-module')
-        codenarcMd.contains('EmptyClass')
+        !codenarcMd.contains('Legacy report')
+    }
+
+    def "aggregateAnalysisViolations recognizes the PMD project allowlist and reports disabled SpotBugs"() {
+        given:
+        testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd.projects=:app-module
+grails.code-analysis.ignoreFailures=true
+pmdVersion=7.25.0

Review Comment:
   These fixtures hard-code `mavenCentral()` plus `pmdVersion=7.25.0` / `codenarcVersion=3.6.0-groovy-4.0` as string literals, so the build-logic suite now needs network access, and the literals will silently diverge from root `gradle.properties` on the next bump — the tests would keep exercising an old toolchain. `build-logic/plugins/build.gradle` already loads `../../gradle.properties`; inject those values into the specs (e.g. via a system property on the test task) instead of re-pinning them.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5544934910

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Functional Tests \(Java 21, indy\=false\)](https://github.com/apache/grails-core/actions/runs/33898056532/job/101105315328?pr=16025) > :grails-test-examples-scaffolding:integrationTest
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | UserControllerSpec > User list | ❌ | [4%&nbsp;🟡](https://github.com/apache/grails-core/issues/16030) |
   
   🏷️ Commit: 80733473c22856278ec9171b4647a9fa53188071
   ▶️ Tests:  79109 executed
   🟡 Checks: 75/87 completed
   
   ### Test Failures
   
   <details><summary><strong>UserControllerSpec > User list</strong> (:grails-test-examples-scaffolding:integrationTest in <a href="https://github.com/apache/grails-core/actions/runs/33898056532/job/101105315328?pr=16025">CI / Functional Tests (Java 21, indy=false)</a>)</summary>
   
   ```
   geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
   	at geb.waiting.Wait.waitFor(Wait.groovy:128)
   	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
   	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
   	at geb.Page.waitFor(Page.groovy:120)
   	at com.example.pages.LoginPage.login(LoginPage.groovy:39)
   	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)
   Caused by: Assertion failed: 
   
   title != pageTitle && $('input', name: 'username').empty
   |     |  |         |
   |     |  |         false
   |     |  'Please sign in'
   |     false
   'Please sign in'
   
   	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:39)
   	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
   	at geb.waiting.Wait.waitFor(Wait.groovy:117)
   	... 5 more
   ```
   
   </details>
   
   ### Rerun Controls
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 UserControllerSpec > User list <!-- uniqueId=[engine:spock]/[spec:com.example.UserControllerSpec]/[feature:$spock_feature_1_0] -->
   
   Reuse successful test results:
   
   🔲 ♻️ Only rerun the tests that failed or were muted before
   
   Click the checkbox to trigger a rerun:
   
   🔲 **Rerun jobs**
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5123237616

   Thanks for the detailed review. Addressed on chore/automate-project-conventions:
   
   **7a0aceb5ff - main review feedback**
   - First-party actions/* and apache/grails-github-actions/* back on version/branch refs; third-party stay SHA-pinned
   - PMD opt-in via per-project grailsCodeAnalysis extension (no central project-name list)
   - Lazy aggregation (configureEach), aggregate-only cleanup/Markdown, no direct-task report clobbering
   - Validator UTF-8, i18n grails-app/i18n/**/*.properties, worktree hygiene, skill-discovery compatibility with #15977
   - Release packaging: symlink-safe timestamps, agent guidance kept in source zip, SnakeYAML managed at 2.6
   - Expanded TestKit coverage and CI wiring for conventions + advisory SpotBugs
   
   **9a8fbeed87 - source-root marker**
   - Keep .asf.yaml in the signed source ZIP (it is the findRootGrailsCoreDir marker); RAT-only exclude
   - Terminate root search at the filesystem root; GradleUtilsSpec covers both paths
   
   Open inline threads have been resolved against the current head. Happy to follow up on anything still unclear.


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5122505746

   Thanks for the very thorough pass, @jdaugherty. All 69 threads are answered individually and pushed in 7a0aceb. The description is rewritten, because several claims in it were no longer true. Summary of what changed and the three places I did something other than what was literally asked.
   
   ## The pinning reversal
   
   Every `actions/*` and `apache/*` pin this PR introduced is reverted. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml` and `release-publish-docs.yml` now have a **zero diff** against `8.0.x`. That also removes the `actions/setup-java` v4 to v5 major-version crossing you spotted inside the two release reproducibility pins, which was an unintended side effect of mechanically swapping tags for the SHAs they currently resolve to.
   
   Rather than leave the validator encoding the opposite of the policy, `RepositoryConventionsTask` now exempts the `actions` and `apache` owner namespaces from the 40-hex requirement and from the cross-file SHA consistency check, while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced.
   
   ## Three places I did not do exactly what was asked
   
   1. **`codeanalysis.yml` `upload-artifact` pins.** Every `actions/*` reference in that file is already SHA-pinned on `8.0.x`; the new advisory steps copy the convention already in force in the same file. Converting only the new lines would leave two styles three lines apart, and converting the file means deciding whether `setup-java` lands on `@v4` or `@v5` there, which is exactly the silent major-version decision you flagged elsewhere. The gate no longer enforces SHAs for `actions/*`, so normalizing that file is unblocked as a standalone change. Happy to do it here if you would rather.
   
   2. **`release-drafter`.** Documented rather than re-pinned. There is now a comment on the `COMMIT_SHA` constant recording that a 40-hex ref may be a commit or an annotated-tag object, that both are immutable, and that the validator cannot distinguish them.
   
   3. **The `.gitignore` and RAT requests.** The premise turned out to be wrong on my side: `CLAUDE.md`, `GEMINI.md`, `.clinerules`, `.cursorrules`, `.windsurfrules` are tracked symlinks to `AGENTS.md`, and `.claude/skills/*/SKILL.md` are tracked symlinks into `.agents/skills/`. So they cannot be gitignored, and per your other comment they now ship in the source zip again, stored as links by `zip -y`. I did try dropping their RAT exclusion on the theory that RAT would follow the link to the licensed target; `./gradlew rat` then failed with 8 unapproved licenses, because on a platform without symlink support git materializes each one as a plain text file containing the target path. The exclusion is kept with that written down as the reason. `.gitignore` gained only the genuinely local `.omo/` and `.worktrees/`.
   
   ## Two things the final review caught that your comments led to
   
   Both came out of re-reviewing the finished diff, and both were caused by fixes to your feedback rather than by the original code.
   
   **The conventions gate would have run in zero CI jobs.** Removing the redundant `aggregateStyleViolations` edge, as you asked, was correct for the task graph but no workflow actually invokes `aggregateViolations`. `codestyle.yml` runs `aggregateStyleViolations` and `codeanalysis.yml` runs `aggregateAnalysisViolations`, so the entire feature would have been unenforced on PRs. The edge stays removed; the core `codestyle.yml` job now invokes `validateRepositoryConventions` explicitly and publishes its report. There is a regression test asserting both directions so the edge cannot be quietly reintroduced.
   
   **The `Modules analyzed:` header alone did not fix the partial-report problem.** Because the reports are assembled from marker files on disk, a module analyzed in an earlier run still contributed its stale markers, so a single-module run could produce a report listing modules it never ran. I ended up taking both of the options you offered in that thread: the header stays, and `finalizeStyleViolations` / `finalizeAnalysisViolations` and all four analyzer `finalizedBy` registrations are gone. Only the aggregate lane writes the Markdown now. One consequence worth knowing: `--continue` is now required to get a report when an analyzer fails, which the documented commands and every workflow already pass, and that is documented in `AGENTS.md` and the skill.
   
   ## Other notable changes
   
   - PMD and SpotBugs enablement moved out of `gradle.properties` entirely to a per-project `grailsCodeAnalysis { pmdEnabled = true }` opt-in. The root aggregator derives the enabled set from applied plugins, so adding a module is a one-line change in that module and nothing central is edited. The `-P` properties remain as overrides. One caveat: analyzer tasks are now registered during `afterEvaluate`, so per-task customization must be wrapped in `afterEvaluate { }`. That is documented.
   - Configuration avoidance restored via `configureEach` and collection-based edges; the cleanup task no longer wipes every module's reports or defeats up-to-date checks.
   - SnakeYAML now uses a managed `snakeyamlVersion=2.6` property instead of the hardcoded 2.4.
   - The `AGENTS.md` skill-index requirement is dropped, so this no longer collides with #15977. Either an explicit index or directory-based discovery passes. Whichever of us rebases second: the front-matter rules still require string `name`, `description` and `license` on every canonical `SKILL.md`.
   
   ## Verification
   
   `../gradlew :build-logic:test` from `build-logic`: **85 tests, 0 failures, 0 errors, 0 skipped**, up from 77. That includes the SpotBugs and Checkstyle end-to-end paths you called out as missing, the style-side missing-report case, aggregate report immutability under a direct analyzer run, and the CI wiring in both directions. Fixtures no longer hardcode tool versions. `./gradlew rat` passes.
   
   No runtime module behavior changes, so CI will re-verify the full suite on the updated branch.
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677406935


##########
.github/workflows/release.yml:
##########
@@ -247,7 +247,7 @@ jobs:
         # downstream checksum/artifact-list combination steps all expect the
         # default JDK 21 toolchain. Also keeps any future steps that touch the
         # repository's own (non-Micronaut) Gradle config on the documented JDK.
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   Reverted to `actions/setup-java@v4`, which is also what it was before this PR. See the related thread on the major-version crossing: reverting fixes that too, since `@v4` restores the exact pre-PR resolution behavior for both reproducibility pins.
   
   `RepositoryConventionsTask` now exempts the `actions` namespace, so the gate agrees with the policy.



##########
.github/workflows/release.yml:
##########
@@ -224,7 +224,7 @@ jobs:
         # staged from a JDK 25 runner. This is a NEW reproducibility pin -
         # keep $JAVA_VERSION_MICRONAUT synced with the secondary JDK in
         # etc/bin/Dockerfile so verifiers can reproduce the resulting JARs.
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   Reverted to `actions/setup-java@v4`, restoring the pre-PR behavior for this reproducibility pin. Details in the related thread you opened about the v4 to v5 major-version crossing.



##########
.github/workflows/release.yml:
##########
@@ -87,7 +87,7 @@ jobs:
           cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
           develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
       - name: "⚙️ Run pre-release"

Review Comment:
   Agreed, reverted. All `apache/grails-github-actions/*` references are back to `@asf`, and `RepositoryConventionsTask` now exempts the `apache` owner namespace from the SHA rule, so the gate no longer encodes the opposite of this decision. A reference with an empty ref is still a violation, but any version or branch ref is accepted.



##########
.github/workflows/release-publish-docs.yml:
##########
@@ -74,7 +74,7 @@ jobs:
         # The docs themselves have no code dependency on Micronaut.
         run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH}
       - name: "🚀 Publish to GitHub Pages"
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Agreed, reverted. All `apache/grails-github-actions/*` references are back to `@asf`, and `RepositoryConventionsTask` now exempts the `apache` owner namespace from the SHA rule, so the gate no longer encodes the opposite of this decision. A reference with an empty ref is still a violation, but any version or branch ref is accepted.



##########
.github/workflows/release-close.yml:
##########
@@ -45,7 +45,7 @@ jobs:
           ref: ${{ env.TAG }}
           token: ${{ secrets.GITHUB_TOKEN }}
       - name: "⚙️ Run post-release"
-        uses: apache/grails-github-actions/post-release@asf
+        uses: apache/grails-github-actions/post-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Agreed, reverted. All `apache/grails-github-actions/*` references are back to `@asf`, and `RepositoryConventionsTask` now exempts the `apache` owner namespace from the SHA rule, so the gate no longer encodes the opposite of this decision. A reference with an empty ref is still a violation, but any version or branch ref is accepted.



##########
.github/workflows/groovy-joint-workflow.yml:
##########
@@ -105,7 +105,7 @@ jobs:
     runs-on: ubuntu-latest
     services:
       mongodb:
-        image: mongo:8
+        image: mongo@sha256:951c2ff9fc6bdb6cb89b1dfea4a0e8ae3ee4fb287c0bf579b2bba54c7803f75d # mongo:8

Review Comment:
   Thanks, keeping this one. Dependabot is not currently configured to propose these updates. Rather than add that here and widen this PR further, I would rather do it as its own change so the config and its noise profile can be reviewed on its own merits. Happy to open an issue for it if you want it tracked.
   
   The digest is the multi-arch manifest-list digest, as you verified, so it resolves correctly on every runner architecture.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "matrei (via GitHub)" <gi...@apache.org>.
matrei commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5834174230

   > @matrei we had agreed to not pin actions by sha for the github ones and somehow that got merged when the build was broken. Are you reversing that decision or only worried about the merge conflict? I can fix 7.x separately.
   
   @jdaugherty I think it's fine if you fix it separately in 7.x.


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty merged PR #16025:
URL: https://github.com/apache/grails-core/pull/16025


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5873550240

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 83d8f1c562b9cecb2c762427388c84845416926b
   ▶️ Tests:  96554 executed
   ⚪️ Checks: 91/91 completed
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677921288


##########
gradle/rat-root-config.gradle:
##########
@@ -20,6 +20,9 @@ apply plugin: 'org.nosphere.apache.rat'
 tasks.named('rat') {
     def allExcludes = [
             '.asf.yaml', // ASF metadata for github integration excluded from src zip
+            '.omo/**', // local agent work state excluded from src zip
+            '.claude/**', '.clinerules', '.cursorrules', '.windsurfrules', // local agent configuration excluded from src zip

Review Comment:
   Addressed in `7a0aceb5ff`. Local agent state (`.omo/`) and standard worktrees (`.worktrees/`) are gitignored; agent guidance files remain tracked symlinks so they ship with source for development.



##########
.github/workflows/vulnerability-scan.yml:
##########
@@ -36,9 +36,9 @@ jobs:
       contents: read
     steps:
       - name: "📥 Checkout repository"
-        uses: actions/checkout@v6
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/release.yml:
##########
@@ -690,7 +698,7 @@ jobs:
         # Runs on the JDK 21 pin, where settings.gradle auto-prunes the Micronaut island.
         run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH}
       - name: "🚀 Publish to GitHub Pages"
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Addressed in `7a0aceb5ff`. First-party `actions/*` and `apache/grails-github-actions/*` are back on version/branch refs (no SHA pins). `RepositoryConventionsTask` exempts those namespaces from the 40-hex rule.



##########
.github/workflows/release.yml:
##########
@@ -765,7 +773,7 @@ jobs:
           ref: ${{ env.TAG }}
           token: ${{ secrets.GITHUB_TOKEN }}
       - name: "⚙️ Run post-release"
-        uses: apache/grails-github-actions/post-release@asf
+        uses: apache/grails-github-actions/post-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Addressed in `7a0aceb5ff`. First-party `actions/*` and `apache/grails-github-actions/*` are back on version/branch refs (no SHA pins). `RepositoryConventionsTask` exempts those namespaces from the 40-hex rule.



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr
           ${DIST_NAME}-${VERSION}-src.zip
           grails
           -x 'grails/.git/*'
           -x 'grails/.github/*'
+          -x 'grails/.omo/*'

Review Comment:
   Addressed in `7a0aceb5ff`. Local agent state (`.omo/`) and standard worktrees (`.worktrees/`) are gitignored; agent guidance files remain tracked symlinks so they ship with source for development.



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr
           ${DIST_NAME}-${VERSION}-src.zip
           grails
           -x 'grails/.git/*'
           -x 'grails/.github/*'
+          -x 'grails/.omo/*'
+          -x 'grails/.claude/*'
+          -x 'grails/.clinerules'
+          -x 'grails/.cursorrules'
+          -x 'grails/.windsurfrules'
+          -x 'grails/CLAUDE.md'

Review Comment:
   Addressed in `7a0aceb5ff`. `CLAUDE.md` (and the other tracked agent guidance symlinks) ship in the source zip again; RAT still skips them because they cannot carry a license header as symlinks.



##########
.github/workflows/release.yml:
##########
@@ -247,7 +247,7 @@ jobs:
         # downstream checksum/artifact-list combination steps all expect the
         # default JDK 21 toolchain. Also keeps any future steps that touch the
         # repository's own (non-Micronaut) Gradle config on the documented JDK.
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/release.yml:
##########
@@ -224,7 +224,7 @@ jobs:
         # staged from a JDK 25 runner. This is a NEW reproducibility pin -
         # keep $JAVA_VERSION_MICRONAUT synced with the secondary JDK in
         # etc/bin/Dockerfile so verifiers can reproduce the resulting JARs.
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/release.yml:
##########
@@ -87,7 +87,7 @@ jobs:
           cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
           develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
       - name: "⚙️ Run pre-release"

Review Comment:
   Addressed in `7a0aceb5ff`. First-party `actions/*` and `apache/grails-github-actions/*` are back on version/branch refs (no SHA pins). `RepositoryConventionsTask` exempts those namespaces from the 40-hex rule.



##########
.github/workflows/release-publish-docs.yml:
##########
@@ -74,7 +74,7 @@ jobs:
         # The docs themselves have no code dependency on Micronaut.
         run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH}
       - name: "🚀 Publish to GitHub Pages"
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   Addressed in `7a0aceb5ff`. First-party `actions/*` and `apache/grails-github-actions/*` are back on version/branch refs (no SHA pins). `RepositoryConventionsTask` exempts those namespaces from the 40-hex rule.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677924784


##########
.github/workflows/gradle.yml:
##########
@@ -242,7 +242,7 @@ jobs:
           distribution: 'liberica'
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -193,7 +193,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -143,7 +143,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -81,7 +81,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/gradle.yml:
##########
@@ -37,7 +37,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd.projects=" -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -95,3 +113,22 @@ jobs:
             file="grails-gradle/build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Gradle Plugin SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        working-directory: grails-gradle
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload Gradle Plugin SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
.github/workflows/vulnerability-scan.yml:
##########
@@ -36,9 +36,9 @@ jobs:
       contents: read
     steps:
       - name: "📥 Checkout repository"
-        uses: actions/checkout@v6
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
       - name: "☕️ Setup JDK"
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   Addressed in `7a0aceb5ff`. Workflows for `actions/*` and project actions use version/branch refs again so they track ASF-approved upstreams; third-party actions remain SHA-pinned.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -170,16 +178,25 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
             def xmlReport = spotBugsReports.maybeCreate('xml')
             xmlReport.required.set(true)
             xmlReport.outputLocation.set(
-                project.extensions.getByType(GrailsCodeAnalysisExtension)
-                        .reportsDirectory.get()
-                        .dir('spotbugs')
-                        .file("${project.name}-${it.name}.xml")
+                        project.extensions.getByType(GrailsCodeAnalysisExtension)
+                        .reportsDirectory
+                        .file("spotbugs/${GradleUtils.reportFileName(project, it.name)}")

Review Comment:
   Addressed in `7a0aceb5ff` with restored layout/report path construction and formatting cleanups in the aggregation/analysis plugins.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -170,16 +178,25 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
             def xmlReport = spotBugsReports.maybeCreate('xml')
             xmlReport.required.set(true)
             xmlReport.outputLocation.set(
-                project.extensions.getByType(GrailsCodeAnalysisExtension)
-                        .reportsDirectory.get()
-                        .dir('spotbugs')
-                        .file("${project.name}-${it.name}.xml")
+                        project.extensions.getByType(GrailsCodeAnalysisExtension)

Review Comment:
   Addressed in `7a0aceb5ff` with restored layout/report path construction and formatting cleanups in the aggregation/analysis plugins.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5123299670

   ## 🚨 TestLens detected 1 failed test 🚨
   
   Here is what you can do:
   
   1) Inspect the test failures carefully.
   2) If you are convinced that some of the tests are flaky, you can mute them below.
   3) Finally, trigger a rerun by checking the rerun checkbox.
   
   ### Test Summary
   
   #### [CI / Build Grails-Core \(ubuntu-latest, 21\)](https://github.com/apache/grails-core/actions/runs/30489699251/job/90704259138?pr=16025) > :grails-core:test
   
   | Test | Runs | Flakiness |
   |---|---|--:|
   | GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer | ❌ | 0%&nbsp;🟢 |
   
   🏷️ Commit: 9a8fbeed877e490792f12a4d8d6cf069a7e1ca8f
   ▶️ Tests:  2959 executed
   🟡 Checks: 20/53 completed
   
   ### Test Failures
   
   <details>
   
   <summary><strong>GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer</strong> (:grails-core:test in <a href="https://github.com/apache/grails-core/actions/runs/30489699251/job/90704259138?pr=16025">CI / Build Grails-Core (ubuntu-latest, 21)</a>)</summary>
   
   ```
   Condition not satisfied:
   
   baos.toString().contains(StackTraceFilterer.FULL_STACK_TRACE_MESSAGE)
   |    |          |        |                  |
   |    ""         false    |                  Full Stack Trace:
   |                        interface org.grails.exceptions.reporting.StackTraceFilterer
   java.io.ByteArrayOutputStream@68d9a4dd
   
   	at org.apache.grails.core.GrailsBootstrapRegistryInitializerSpec.defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer(GrailsBootstrapRegistryInitializerSpec.groovy:257)
   ```
   
   </details>
   
   ### Muted Tests
   > [!NOTE]
   > Checks are currently running using the configuration below.
   
   Select tests to mute in this pull request:
   
   🔲 GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer <!-- uniqueId=[engine:spock]/[spec:org.apache.grails.core.GrailsBootstrapRegistryInitializerSpec]/[feature:$spock_feature_0_11] -->
   
   Reuse successful test results:
   
   🔲 ♻️ Only rerun the tests that failed or were muted before
   
   Click the checkbox to trigger a rerun:
   
   🔲 **Rerun jobs**
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677411983


##########
.github/workflows/gradle.yml:
##########
@@ -143,7 +143,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -81,7 +81,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -37,7 +37,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd.projects=" -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

Review Comment:
   This one is worth separating out, because it is not a pin this PR introduced. Every `actions/*` reference in `codeanalysis.yml` is already SHA-pinned on `8.0.x` today, including this `upload-artifact` line at the enforced-report step. The advisory steps this PR adds simply copy the convention already in force in the same file.
   
   I deliberately did not convert them. Converting only the new lines would leave two styles for the same action three lines apart, and converting the whole file means also deciding whether `setup-java` here lands on `@v4` (as `release.yml` and `vulnerability-scan.yml` use) or `@v5` (what the current pin resolves to), which is exactly the kind of silent major-version decision you flagged elsewhere in this review.
   
   The gate no longer enforces SHAs for `actions/*`, so normalizing this file is now unblocked and safe to do as a standalone change. Say the word and I will either do it here or open an issue.



##########
.github/workflows/codeanalysis.yml:
##########
@@ -95,3 +113,22 @@ jobs:
             file="grails-gradle/build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Gradle Plugin SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        working-directory: grails-gradle
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload Gradle Plugin SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

Review Comment:
   Same as the core-job thread above: this `upload-artifact` pin already exists on `8.0.x` in this file, and the new advisory step matches it rather than introducing a third style. The validator no longer enforces SHAs for `actions/*`, so converting this file wholesale is unblocked and I would rather do it as its own change than fold a major-version decision for `setup-java` into this PR. Happy to do it either way.



##########
.github/workflows/vulnerability-scan.yml:
##########
@@ -36,9 +36,9 @@ jobs:
       contents: read
     steps:
       - name: "📥 Checkout repository"
-        uses: actions/checkout@v6
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
       - name: "☕️ Setup JDK"
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -170,16 +178,25 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
             def xmlReport = spotBugsReports.maybeCreate('xml')
             xmlReport.required.set(true)
             xmlReport.outputLocation.set(
-                project.extensions.getByType(GrailsCodeAnalysisExtension)
-                        .reportsDirectory.get()
-                        .dir('spotbugs')
-                        .file("${project.name}-${it.name}.xml")
+                        project.extensions.getByType(GrailsCodeAnalysisExtension)
+                        .reportsDirectory
+                        .file("spotbugs/${GradleUtils.reportFileName(project, it.name)}")

Review Comment:
   Restored. It now reads:
   
   ```groovy
   xmlReport.outputLocation.set(
           project.extensions.getByType(GrailsCodeAnalysisExtension)
                   .reportsDirectory.get()
                   .dir('spotbugs')
                   .file(GradleUtils.reportFileName(project, it.name))
   )
   ```
   
   The only thing kept from my version is `GradleUtils.reportFileName(project, taskName)` in place of the old `${project.name}-${task.name}.xml`, which is the collision fix for nested projects sharing a leaf name. Same shape applied to the PMD, Checkstyle, and CodeNarc blocks.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677413436


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -170,16 +178,25 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
             def xmlReport = spotBugsReports.maybeCreate('xml')
             xmlReport.required.set(true)
             xmlReport.outputLocation.set(
-                project.extensions.getByType(GrailsCodeAnalysisExtension)
-                        .reportsDirectory.get()
-                        .dir('spotbugs')
-                        .file("${project.name}-${it.name}.xml")
+                        project.extensions.getByType(GrailsCodeAnalysisExtension)

Review Comment:
   Fixed. The indentation is back to the original, and the chain is back to `.reportsDirectory.get().dir(...).file(...)` rather than a GString path. Worth noting the `.get()` is load-bearing: `DirectoryProperty.dir(String)` returns a `Provider<Directory>`, which has no `file(String)`, so the original form is the one that actually compiles under `@CompileStatic`.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -161,11 +163,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // Redirect XML report output to a single directory to consolidate
             // reports across all subprojects into one known location
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('checkstyle')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)

Review Comment:
   Fixed, indentation restored to the original.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -161,11 +163,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // Redirect XML report output to a single directory to consolidate
             // reports across all subprojects into one known location
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('checkstyle')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)
+                            .reportsDirectory
+                            .file("checkstyle/${GradleUtils.reportFileName(project, task.name)}")

Review Comment:
   Restored to `.reportsDirectory.get().dir('checkstyle').file(...)`. The file name still comes from `GradleUtils.reportFileName(project, task.name)` so nested projects with the same leaf name cannot collide, but the call shape and indentation are back to the original.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -201,11 +205,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // reports across all subprojects into one known location
             task.reports.xml.required.set(true)
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('codenarc')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)

Review Comment:
   Fixed, indentation restored to the original.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -201,11 +205,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // reports across all subprojects into one known location
             task.reports.xml.required.set(true)
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('codenarc')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)
+                            .reportsDirectory
+                            .file("codenarc/${GradleUtils.reportFileName(project, task.name)}")

Review Comment:
   Restored to `.reportsDirectory.get().dir('codenarc').file(...)`, same as the Checkstyle block.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -74,6 +74,8 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
 
     static final String DEFAULT_JACOCO_EXCLUDED_CLASS_PREFIXES = 'org.grails.orm.hibernate.support.hibernate7.'
 
+    private static final String CANONICAL_ROOT_MARKER = '.github/workflows'

Review Comment:
   Good catch, fixed. `CANONICAL_ROOT_MARKER` is deleted and the canonical-root test now goes through `GradleUtils.findRootGrailsCoreDir(project)`, comparing the located ASF root against the project directory. `grails-gradle` and the other independent builds still do not get the conventions task, and the spec that proves that is unchanged.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3670757375


##########
.github/workflows/release.yml:
##########
@@ -690,7 +698,7 @@ jobs:
         # Runs on the JDK 21 pin, where settings.gradle auto-prunes the Micronaut island.
         run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH}
       - name: "🚀 Publish to GitHub Pages"
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   We should not be pinning our own actions.



##########
.github/workflows/vulnerability-scan.yml:
##########
@@ -36,9 +36,9 @@ jobs:
       contents: read
     steps:
       - name: "📥 Checkout repository"
-        uses: actions/checkout@v6
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/release.yml:
##########
@@ -87,7 +87,7 @@ jobs:
           cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
           develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
       - name: "⚙️ Run pre-release"

Review Comment:
   We should never pin our own actions



##########
.github/workflows/release.yml:
##########
@@ -247,7 +247,7 @@ jobs:
         # downstream checksum/artifact-list combination steps all expect the
         # default JDK 21 toolchain. Also keeps any future steps that touch the
         # repository's own (non-Micronaut) Gradle config on the documented JDK.
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -694,7 +694,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -950,7 +950,7 @@ jobs:
           compression-level: 0 # Already a zip
       - name: "🚀 Publish to Github Pages"
         if: github.event_name == 'push'
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   We should not pin our own actions



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr
           ${DIST_NAME}-${VERSION}-src.zip
           grails
           -x 'grails/.git/*'
           -x 'grails/.github/*'
+          -x 'grails/.omo/*'

Review Comment:
   These should be gitignored, or we should discuss as a team on when to check these in.



##########
.github/workflows/gradle.yml:
##########
@@ -923,7 +923,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -81,7 +81,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -161,11 +163,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // Redirect XML report output to a single directory to consolidate
             // reports across all subprojects into one known location
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('checkstyle')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)

Review Comment:
   The formatting has been messed up and needs fixed.



##########
.github/workflows/vulnerability-scan.yml:
##########
@@ -36,9 +36,9 @@ jobs:
       contents: read
     steps:
       - name: "📥 Checkout repository"
-        uses: actions/checkout@v6
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
       - name: "☕️ Setup JDK"
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -143,7 +143,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/release.yml:
##########
@@ -224,7 +224,7 @@ jobs:
         # staged from a JDK 25 runner. This is a NEW reproducibility pin -
         # keep $JAVA_VERSION_MICRONAUT synced with the secondary JDK in
         # etc/bin/Dockerfile so verifiers can reproduce the resulting JARs.
-        uses: actions/setup-java@v4
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
gradle/rat-root-config.gradle:
##########
@@ -20,6 +20,9 @@ apply plugin: 'org.nosphere.apache.rat'
 tasks.named('rat') {
     def allExcludes = [
             '.asf.yaml', // ASF metadata for github integration excluded from src zip
+            '.omo/**', // local agent work state excluded from src zip
+            '.claude/**', '.clinerules', '.cursorrules', '.windsurfrules', // local agent configuration excluded from src zip

Review Comment:
   We need to update the .gitignore file to exclude these as well or make sure our release process explicitly removes them. I don't see entries for a lot of these.



##########
.github/workflows/release-publish-docs.yml:
##########
@@ -74,7 +74,7 @@ jobs:
         # The docs themselves have no code dependency on Micronaut.
         run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH}
       - name: "🚀 Publish to GitHub Pages"
-        uses: apache/grails-github-actions/deploy-github-pages@asf
+        uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   we should never pin our own actions



##########
.github/workflows/release.yml:
##########
@@ -765,7 +773,7 @@ jobs:
           ref: ${{ env.TAG }}
           token: ${{ secrets.GITHUB_TOKEN }}
       - name: "⚙️ Run post-release"
-        uses: apache/grails-github-actions/post-release@asf
+        uses: apache/grails-github-actions/post-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   we should not be pinning our own actions



##########
gradle.properties:
##########
@@ -72,6 +72,9 @@ pmdVersion=7.25.0
 spotbugsPluginVersion=6.4.8
 sonatypeScanPluginVersion=3.1.6
 
+# PMD is enforced only for projects with a clean baseline. Add project paths after clearing debt.
+grails.code-analysis.enabled.pmd.projects=:grails-data-graphql-core,:grails-data-mongodb-spring-data,:grails-datasource,:grails-testing-support-core

Review Comment:
   I don't think we should be centrally setting this.  Before it was opt in on a project base. this list is going to grow unreasonable large and it's hard coding project names which make this fragile.  



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr
           ${DIST_NAME}-${VERSION}-src.zip
           grails
           -x 'grails/.git/*'
           -x 'grails/.github/*'
+          -x 'grails/.omo/*'
+          -x 'grails/.claude/*'
+          -x 'grails/.clinerules'
+          -x 'grails/.cursorrules'
+          -x 'grails/.windsurfrules'
+          -x 'grails/CLAUDE.md'

Review Comment:
   CLAUDE.md should be in the source zip - we're using it to develop so it shouldn't be excluded.



##########
.github/workflows/groovy-joint-workflow.yml:
##########
@@ -105,7 +105,7 @@ jobs:
     runs-on: ubuntu-latest
     services:
       mongodb:
-        image: mongo:8
+        image: mongo@sha256:951c2ff9fc6bdb6cb89b1dfea4a0e8ae3ee4fb287c0bf579b2bba54c7803f75d # mongo:8

Review Comment:
   Have we configured dependabot to auto-suggest these updates?  I agree with pinning this one though.



##########
.github/workflows/release-close.yml:
##########
@@ -45,7 +45,7 @@ jobs:
           ref: ${{ env.TAG }}
           token: ${{ secrets.GITHUB_TOKEN }}
       - name: "⚙️ Run post-release"
-        uses: apache/grails-github-actions/post-release@asf
+        uses: apache/grails-github-actions/post-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf

Review Comment:
   we should never pin our own actions



##########
.github/workflows/gradle.yml:
##########
@@ -628,7 +628,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -812,7 +812,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -193,7 +193,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -323,7 +323,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -495,7 +495,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -170,16 +178,25 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
             def xmlReport = spotBugsReports.maybeCreate('xml')
             xmlReport.required.set(true)
             xmlReport.outputLocation.set(
-                project.extensions.getByType(GrailsCodeAnalysisExtension)
-                        .reportsDirectory.get()
-                        .dir('spotbugs')
-                        .file("${project.name}-${it.name}.xml")
+                        project.extensions.getByType(GrailsCodeAnalysisExtension)

Review Comment:
   The formatting here has been messed up and should be restored.



##########
.github/workflows/gradle.yml:
##########
@@ -547,7 +547,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/codeanalysis.yml:
##########
@@ -95,3 +113,22 @@ jobs:
             file="grails-gradle/build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Gradle Plugin SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        working-directory: grails-gradle
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload Gradle Plugin SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -74,6 +74,8 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
 
     static final String DEFAULT_JACOCO_EXCLUDED_CLASS_PREFIXES = 'org.grails.orm.hibernate.support.hibernate7.'
 
+    private static final String CANONICAL_ROOT_MARKER = '.github/workflows'

Review Comment:
   We already have a utility in GradleUtil to find the root directory. We should be consistent and use that instead.



##########
.github/workflows/gradle.yml:
##########
@@ -242,7 +242,7 @@ jobs:
           distribution: 'liberica'
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/gradle.yml:
##########
@@ -37,7 +37,7 @@ jobs:
           distribution: liberica
           java-version: 21
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
.github/workflows/codeanalysis.yml:
##########
@@ -60,6 +60,24 @@ jobs:
             file="build/reports/violations/$report"
             [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true
           done
+      - name: "🔎 Run Core SpotBugs Advisory"
+        if: always()
+        continue-on-error: true
+        run: |
+          rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md
+          ./gradlew aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd.projects=" -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true
+      - name: "📤 Upload SpotBugs Advisory Reports"
+        if: always()
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

Review Comment:
   for actions, we had been specifying the non-commit version so they pull the latest.  This aligned with the ASF approvals upstream. 



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -161,11 +163,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // Redirect XML report output to a single directory to consolidate
             // reports across all subprojects into one known location
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('checkstyle')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)
+                            .reportsDirectory
+                            .file("checkstyle/${GradleUtils.reportFileName(project, task.name)}")

Review Comment:
   We shoudl restore the .dir().file() like before



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -201,11 +205,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // reports across all subprojects into one known location
             task.reports.xml.required.set(true)
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('codenarc')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)

Review Comment:
   Formatting needs restored.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy:
##########
@@ -201,11 +205,12 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
             // reports across all subprojects into one known location
             task.reports.xml.required.set(true)
             task.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeStyleExtension)
-                            .reportsDirectory.get()
-                            .dir('codenarc')
-                            .file("${project.name}-${task.name}.xml")
+                            project.extensions.getByType(GrailsCodeStyleExtension)
+                            .reportsDirectory
+                            .file("codenarc/${GradleUtils.reportFileName(project, task.name)}")

Review Comment:
   .dir().file()



##########
build-logic/plugins/build.gradle:
##########
@@ -40,6 +40,7 @@ dependencies {
     implementation "org.cyclonedx.bom:org.cyclonedx.bom.gradle.plugin:${gradleProperties.gradleCycloneDxPluginVersion}"
     implementation "com.github.spotbugs.snom:spotbugs-gradle-plugin:${gradleProperties.spotbugsPluginVersion}"
     implementation "org.sonatype.gradle.plugins:scan-gradle-plugin:${gradleProperties.sonatypeScanPluginVersion}"
+    implementation 'org.yaml:snakeyaml:2.4'

Review Comment:
   We should be pulling the version from our dependencies or set it in our bom. 2.4 mismatches the upstream bom version (2.6)



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -170,16 +178,25 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
             def xmlReport = spotBugsReports.maybeCreate('xml')
             xmlReport.required.set(true)
             xmlReport.outputLocation.set(
-                project.extensions.getByType(GrailsCodeAnalysisExtension)
-                        .reportsDirectory.get()
-                        .dir('spotbugs')
-                        .file("${project.name}-${it.name}.xml")
+                        project.extensions.getByType(GrailsCodeAnalysisExtension)
+                        .reportsDirectory
+                        .file("spotbugs/${GradleUtils.reportFileName(project, it.name)}")

Review Comment:
   We should restore the original format .dir().file() instead of using a gstring



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5821789079

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 9fe96f9f489371ff1401e0a58eed399c0ae5b2f5
   ▶️ Tests:  94291 executed
   ⚪️ Checks: 91/91 completed
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5836651951

   ## ✅ All tests passed ✅
   
   🏷️ Commit: 6ce0279f495d4c7d1d9eb719021e261aca9f9c1a
   ▶️ Tests:  96074 executed
   ⚪️ Checks: 91/91 completed
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5835051327

   ## 🚨 All tests passed but jobs failed 🚨
   
   ### Failed Jobs without Test Failures
   
   ❌ [Validate GitHub Actions / Validate Actions](https://github.com/apache/grails-core/actions/runs/36153830246/job/108133423481?pr=16025)
   
   🏷️ Commit: 1d6d2396d10d31c93f274959300da23c3b75e6b6
   ▶️ Tests:  2793 executed
   🟡 Checks: 17/85 completed
   
   ### Rerun Controls
   
   Click the checkbox to trigger a rerun:
   
   🔲 **Rerun jobs**
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5820041604

   ## 🚨 All tests passed but jobs failed 🚨
   
   ### Failed Jobs without Test Failures
   
   ❌ [CI / Hibernate5 Functional Tests \(Java 21, indy\=true\)](https://github.com/apache/grails-core/actions/runs/36042610247/job/107778627622?pr=16025)
   
   🏷️ Commit: 937dc7472c9630bf221cc87236f46233d2cc7fe3
   ▶️ Tests:  195 executed
   🟡 Checks: 13/85 completed
   
   ### Rerun Controls
   
   Click the checkbox to trigger a rerun:
   
   🔲 **Rerun jobs**
   
   ---
   _Learn more about TestLens at [testlens.app/docs](https://testlens.app/docs/features/pr-comment/)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5024251213

   @jdaugherty @matrei @borinquenkid @sbglasius this plus the newly created issues, should get us closer to PRs automatically following the expected syntax and passing all of the automated checkers.  This PR was the next logical step. 
   
   Should save us immense time on PR reviews, once we get this gradually in place.  


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jdaugherty (via GitHub)" <gi...@apache.org>.
jdaugherty commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5024773793

   I'd like to review this before merging, but am currently traveling. I can take a look at this by Friday.


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "testlens-app[bot] (via GitHub)" <gi...@apache.org>.
testlens-app[bot] commented on PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#issuecomment-5023630320

   ## ✅ All tests passed ✅
   
   🏷️ Commit: adbe27cde5d6196d89eeb2ddc9ec0255b059df02
   ▶️ Tests:  56427 executed
   ⚪️ Checks: 60/60 completed
   
   ---
   _Learn more about TestLens at [testlens.app](https://testlens.app)._
   


-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677927543


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {

Review Comment:
   Addressed in `7a0aceb5ff`. Aggregate cleanup is scoped to the aggregate lane only; direct module quality tasks no longer wipe repo-wide markers/reports.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy:
##########
@@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
         }
 
         def violationsDir = project.layout.buildDirectory.dir('reports/violations')
-        def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style')
-        def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis')
-
-        def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir)
-        def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir)
+        TaskProvider<RepositoryConventionsTask> repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ?
+                registerRepositoryConventions(project, violationsDir) : null
+        def styleTask = registerStyleAggregation(project, violationsDir)
+        def analysisTask = registerAnalysisAggregation(project, violationsDir)
         registerJacocoAggregation(project, violationsDir)
 
         project.tasks.register('aggregateViolations') { Task task ->
             task.group = 'verification'
             task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/'
             task.dependsOn(styleTask, analysisTask)
+            if (repositoryConventionsTask) {
+                task.dependsOn(repositoryConventionsTask)
+            }
+        }
+    }
+
+    private static TaskProvider<RepositoryConventionsTask> registerRepositoryConventions(Project root, Provider<Directory> violationsDir) {
+        root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task ->
+            task.group = 'verification'
+            task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md'
+            task.repositoryDirectory.set(root.layout.projectDirectory)
+            task.conventionSources.from(
+                    root.file('AGENTS.md'),
+                    root.fileTree('.agents/skills') { include '*/SKILL.md' },
+                    root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' },
+                    root.fileTree('.') {
+                        include '**/action.yml', '**/action.yaml'
+                        exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**'
+                    },
+                    root.fileTree('.') {
+                        include '**/messages*.properties'
+                        exclude '**/build/**', '**/generated/**'
+                    }
+            )
+            task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') })
+            task.outputs.upToDateWhen { false }
+            task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' })
         }
     }
 
-    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> styleXmlDir, Provider<Directory> violationsDir) {
-        // Wire property flags as Providers — values are resolved at task execution time, not at apply() time,
-        // and Providers are configuration-cache safe to capture in task actions
+    private static TaskProvider<Task> registerStyleAggregation(Project root, Provider<Directory> violationsDir) {
+        Directory rootDirectory = root.layout.projectDirectory
         def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY)
+        def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY)
         def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true)
         def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true)
+        def codenarcMarkers = root.files()
+        def checkstyleMarkers = root.files()
+        def codenarcReports = root.files()
+        def checkstyleReports = root.files()
+        def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md')
+        def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md')
+        def cleanupTask = root.tasks.register('cleanAggregateStyleReports') {
+            it.doLast {
+                deleteReports(codenarcMarkers.files, codenarcReports.files)
+                deleteReports(checkstyleMarkers.files, checkstyleReports.files)
+            }
+        }
 
-        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+        def writerTask = root.tasks.register('writeStyleViolations') {
             it.group = 'verification'
-            it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/'
-            it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md'))
-            it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md'))
+            it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/'
+            it.inputs.files(codenarcMarkers).optional()
+            it.inputs.files(checkstyleMarkers).optional()
+            it.inputs.property('ignoreFailures', ignoreFailures)
+            it.outputs.file(codenarcMarkdown)
+            it.outputs.file(checkstyleMarkdown)
+            it.outputs.upToDateWhen { false }
+            it.doFirst {
+                codenarcMarkdown.get().asFile.delete()
+                checkstyleMarkdown.get().asFile.delete()
+            }
             it.doLast {
-                parseStyleViolations(styleXmlDir.get(), violationsDir.get(),
-                    checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get())
+                parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(),
+                    checkStyleTests.get(), codenarcEnabled.get(),
+                    checkstyleEnabled.get(), ignoreFailures.get())
             }
         }
-        root.subprojects { Project sub ->
-            sub.pluginManager.withPlugin('codenarc') {
-                aggregateTask.configure {
-                    it.dependsOn(sub.tasks.withType(CodeNarc))
-                }
+        def aggregateTask = root.tasks.register('aggregateStyleViolations') {
+            it.group = 'verification'
+            it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/'
+            it.dependsOn(writerTask)
+        }
+        if (root.tasks.names.contains('validateRepositoryConventions')) {
+            aggregateTask.configure { it.dependsOn(root.tasks.named('validateRepositoryConventions')) }
+        }
+        def finalizeTask = root.tasks.register('finalizeStyleViolations') {
+            it.group = 'verification'
+            it.dependsOn(writerTask)
+        }
+        root.allprojects { Project sub ->
+            sub.tasks.withType(CodeNarc).all { CodeNarc codeNarcTask ->
+                codenarcMarkers.from(GradleUtils.reportMarker(sub, 'codenarc', codeNarcTask.name))
+                def reportLocation = codeNarcTask.reports.xml.outputLocation
+                codenarcReports.from { reportLocation.get().asFile }
+                codeNarcTask.dependsOn(cleanupTask)
+                codeNarcTask.finalizedBy(finalizeTask)

Review Comment:
   Addressed in `7a0aceb5ff`. Only the aggregate lane writes Markdown reports; direct analyzer finalizers were removed; aggregate reports include a Modules analyzed header.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677419634


##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr

Review Comment:
   Fixed. The normalization step gained a symlink pass:
   
   ```bash
   find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
   find . -depth -type l -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} +
   ```
   
   This matters more now than when you wrote the comment, because the exclusions for `CLAUDE.md` and the other symlinks were dropped in response to your other thread, so they are genuinely stored as links by `zip -y` rather than being skipped. Their targets (`AGENTS.md`, `.agents/skills/**`) are inside the zip, so no dangling links either.



##########
.github/workflows/release.yml:
##########
@@ -373,11 +374,18 @@ jobs:
           find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} +
       - name: "📦 Create source distribution ZIP"
         run: >
-          zip -r
+          zip -yr
           ${DIST_NAME}-${VERSION}-src.zip
           grails
           -x 'grails/.git/*'

Review Comment:
   Fixed by making the comment true rather than by editing the comment: `-x 'grails/.asf.yaml'` is now in the zip exclude list. It is GitHub-side ASF metadata in the same category as `.github/**`, which is already excluded, so shipping it was the anomaly.



##########
AGENTS.md:
##########
@@ -79,6 +82,19 @@ export GRADLE_OPTS="-Xms2G -Xmx5G"
 | **hibernate-developer** | `.agents/skills/hibernate-developer/SKILL.md` | Hibernate 7 mapping, binders, generators |
 | **violation-fixer** | `.agents/skills/violation-fixer/SKILL.md` | Fix style/analysis violations (CodeNarc, Checkstyle, PMD, SpotBugs) |
 | **test-fixer** | `.agents/skills/test-fixer/SKILL.md` | Aggregate and fix test failures |
+| **mono-repo-integration** | `.agents/skills/mono-repo-integration/SKILL.md` | Integrate a standalone repository into the monorepo |
+
+## Repository Conventions
+
+Run `./gradlew validateRepositoryConventions` to check canonical skill metadata and AGENTS.md synchronization, external GitHub Action SHA pins in workflows and local composite actions, immutable Docker digests, duplicate message keys, and RAT license provenance. The task writes `build/reports/violations/REPOSITORY_CONVENTIONS.md` and is included by `aggregateStyleViolations` and `aggregateViolations`.

Review Comment:
   You are right that this was stronger than a documentation overlap, and it should not have been the validator's call to make. Fixed by removing the requirement.
   
   `validateRepositoryConventions` no longer requires a skill to be listed in AGENTS.md. It now only validates the direction that is true under both models: a skill path that AGENTS.md *does* reference must exist, so a dangling reference (like the `codebase-memory` one #15977 fixes) still cannot survive. Per-skill front matter, name-to-directory match, and name uniqueness are unchanged, since those are uncontested.
   
   That means #15977's discovery-based AGENTS.md and the current explicit index both pass this gate, and neither PR has to win before the other can merge. One note for whichever rebases second: the front-matter rules do require `name`, `description`, and `license` as strings on every canonical `SKILL.md`, so any new skill in #15977 missing `license` will need it added.



##########
AGENTS.md:
##########
@@ -52,7 +55,7 @@ export GRADLE_OPTS="-Xms2G -Xmx5G"
 9. **Test via public APIs** - Tests must exercise behavior through the same APIs an end user calls; never invoke internal implementations, package-private methods, or bypass the public surface directly
 10. **Always review and extend tests** - Review existing unit and functional tests before making changes; every code change must include new or enhanced tests that cover the affected behavior
 11. **Every code touch must update all tests for the changed class** - When a class is modified, find and update every test that covers it — unit, integration, and TCK. Do not leave any existing test out of sync with the new code.
-12. **Clean violations before commit** - Before every automated commit, run `./gradlew clean aggregateViolations :grails-test-report:check --continue` from the root and ensure that `build/reports/violations/CHECKSTYLE_VIOLATIONS.md`, `build/reports/violations/CODENARC_VIOLATIONS.md`, `build/reports/violations/PMD_VIOLATIONS.md`, and `build/reports/violations/SPOTBUGS_VIOLATIONS.md` report no issues. Also review the test result reports under `grails-test-report/build/reports/tests/` and ensure there are no failures. The aggregate reports are wired as test finalizers and will be attempted after failures, but `--continue` is required for comprehensive full-suite reports.
+12. **Clean violations before commit** - Before every automated commit, run `./gradlew clean aggregateViolations :grails-test-report:check --continue` from the root. Ensure Checkstyle, CodeNarc, and PMD reports have no issues for their enabled projects; PMD is enforced only for the project paths in `grails.code-analysis.enabled.pmd.projects`. Disabled tools report their disabled status, not a clean result. Also review the test result reports under `grails-test-report/build/reports/tests/` and ensure there are no failures. The aggregate reports are wired as test finalizers and will be attempted after failures, but `--continue` is required for comprehensive full-suite reports.

Review Comment:
   Fixed in both places. Rule 12 and Pull Request Guidelines step 4 now name the Checkstyle, CodeNarc, REPOSITORY_CONVENTIONS, and PMD/SpotBugs reports, note that PMD and SpotBugs apply to their enabled projects, and state that a disabled tool reports its disabled status rather than a clean result. The numbered list in the guidelines also had two items numbered `6`, which is fixed while I was in there.
   
   The PMD/SpotBugs enablement wording changed with the other review item: it is now the per-project `grailsCodeAnalysis { pmdEnabled = true }` opt-in in each module's own `build.gradle`, with the `-P` properties documented as overrides.



##########
AGENTS.md:
##########
@@ -79,6 +82,19 @@ export GRADLE_OPTS="-Xms2G -Xmx5G"
 | **hibernate-developer** | `.agents/skills/hibernate-developer/SKILL.md` | Hibernate 7 mapping, binders, generators |
 | **violation-fixer** | `.agents/skills/violation-fixer/SKILL.md` | Fix style/analysis violations (CodeNarc, Checkstyle, PMD, SpotBugs) |
 | **test-fixer** | `.agents/skills/test-fixer/SKILL.md` | Aggregate and fix test failures |
+| **mono-repo-integration** | `.agents/skills/mono-repo-integration/SKILL.md` | Integrate a standalone repository into the monorepo |
+
+## Repository Conventions
+
+Run `./gradlew validateRepositoryConventions` to check canonical skill metadata and AGENTS.md synchronization, external GitHub Action SHA pins in workflows and local composite actions, immutable Docker digests, duplicate message keys, and RAT license provenance. The task writes `build/reports/violations/REPOSITORY_CONVENTIONS.md` and is included by `aggregateStyleViolations` and `aggregateViolations`.
+
+Review-only checklist:

Review Comment:
   Fair, they were labels rather than guidance. Each one is now a one-line actionable statement pointing at a concrete in-repo anchor - quote and type preference at the `## Groovy Style` section, test sufficiency at Critical Rules 9, 10, and 13, compatibility at the `## Technology Stack` table, and so on - with a leading sentence stating explicitly that these are human-review concerns that are intentionally not mechanized, and that everything mechanizable is enforced by `validateRepositoryConventions`.



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy:
##########
@@ -128,33 +131,38 @@ class GrailsCodeAnalysisPlugin implements Plugin<Project> {
 
         project.tasks.withType(Pmd).configureEach {
             it.group = 'verification'
-            it.onlyIf { !project.hasProperty('skipCodeStyle') }
+            it.onlyIf { !skipCodeStyle.present }
             it.ignoreFailures = ignoreFailures.get()
 
             if (it.name.contains('Test') || it.name.contains('test')) {
                 it.enabled = testStylingEnabled.get()
             }
 
+            it.exclude { org.gradle.api.file.FileTreeElement element ->
+                element.file.toPath().toAbsolutePath().normalize().startsWith(projectBuildDirectory.get())
+            }
+
             it.reports.xml.required.set(true)
             it.reports.xml.outputLocation.set(
-                    project.extensions.getByType(GrailsCodeAnalysisExtension)
-                            .reportsDirectory.get()
-                            .dir('pmd')
-                            .file("${project.name}-${it.name}.xml")
+                            project.extensions.getByType(GrailsCodeAnalysisExtension)
+                            .reportsDirectory
+                            .file("pmd/${GradleUtils.reportFileName(project, it.name)}")
             )
+            GradleUtils.configureReportMarker(it, project.rootProject.layout.projectDirectory, it.reports.xml.outputLocation,
+                    GradleUtils.reportMarker(project, 'pmd', it.name))
         }
     }
 
     static void configureSpotbugs(Project project) {

Review Comment:
   Fair, that was a real gap: the path was rewired and nothing exercised it. There is now a TestKit feature that opts a fixture project into SpotBugs, runs `spotbugsMain` against a deliberately buggy class, and asserts both the marker and the `SPOTBUGS_VIOLATIONS.md` content, so the allowlist, the `reports.maybeCreate('xml')` relocation, the marker write, and the `parseAnalysisViolations` SpotBugs branch are all covered end to end rather than only in CI passes that cannot fail.



##########
build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy:
##########
@@ -73,11 +76,33 @@ class GrailsViolationAggregationPluginSpec extends Specification {
         result.output.contains('aggregateStyleViolations')
         result.output.contains('aggregateAnalysisViolations')
         result.output.contains('aggregateViolations')
+        result.output.contains('validateRepositoryConventions')
         result.output.contains('aggregateJacocoCoverage')
     }
 
-    def "aggregateStyleViolations writes CodeNarc and Checkstyle reports to build/reports/violations/"() {
-        given: "a root project with a subproject that has codestyle XML reports"
+    def "canonical roots report a missing AGENTS.md"() {
+        given:
+        testProjectDir.resolve('settings.gradle').toFile().text = ''
+        testProjectDir.resolve('.github/workflows').toFile().mkdirs()
+        testProjectDir.resolve('build.gradle').toFile().text = """
+            plugins {
+                id 'org.apache.grails.gradle.grails-violation-aggregation'
+            }
+        """
+
+        when:
+        def result = GradleRunner.create()
+                .withProjectDir(testProjectDir.toFile())
+                .withArguments('validateRepositoryConventions', '--stacktrace')
+                .withPluginClasspath()
+                .buildAndFail()
+
+        then:
+        result.output.contains('AGENTS.md: file is missing')
+    }
+
+    def "nested builds ignore stale legacy style XML without AGENTS.md"() {

Review Comment:
   Correct on both counts, and restored. There is now a Checkstyle positive-path feature with real sources that asserts report content and module resolution through the marker, so the marker-based Checkstyle parse branch is exercised with data again rather than running NO-SOURCE. The style-side missing-report failure is covered too, so it is no longer only the PMD side.



-- 
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: notifications-unsubscribe@grails.apache.org

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


Re: [PR] Automate repository conventions and stage static analysis enforcement [grails-core]

Posted by "jamesfredley (via GitHub)" <gi...@apache.org>.
jamesfredley commented on code in PR #16025:
URL: https://github.com/apache/grails-core/pull/16025#discussion_r3677410203


##########
.github/workflows/gradle.yml:
##########
@@ -547,7 +547,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -495,7 +495,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -323,7 +323,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -242,7 +242,7 @@ jobs:
           distribution: 'liberica'
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



##########
.github/workflows/gradle.yml:
##########
@@ -193,7 +193,7 @@ jobs:
           distribution: liberica
           java-version: ${{ matrix.java }}
       - name: "🗄️ Restore dependency jar cache"
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3

Review Comment:
   Agreed, reverted. Every `actions/*` reference this PR had pinned is back to its original version reference. `gradle.yml`, `vulnerability-scan.yml`, `release-close.yml`, and `release-publish-docs.yml` now show no diff at all against `8.0.x`.
   
   The validator was changed to match the policy rather than fight it: `RepositoryConventionsTask` now carries `EXEMPT_ACTION_OWNERS = ['actions', 'apache']`, which skips both the 40-hex SHA requirement and the cross-file SHA-consistency check for those namespaces while still requiring a non-empty `@ref`. Third-party actions and Docker digests stay enforced. New specs cover the accept, reject, and trailing-`@` cases.



-- 
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: notifications-unsubscribe@grails.apache.org

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