# Release Hygiene Refactoring Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make every release-hygiene path truthful by fixing the sample-off architecture gate, aligning the Gradle 9.0.0 wrapper and CI validation, making Docker cache stages valid without `.git`, completing SpotBugs analysis classpaths, and removing the observed Gradle 10 deprecation. **Architecture:** Leaf-specific architecture rules move to their owning leaf while root tests remain cross-module. Build inputs become explicit: Docker copies registry inputs, evidence-only Git validation executes only in evidence tasks, wrapper bytes/checksums are fixed, and SpotBugs derives auxiliary inputs from the source set it analyzes. **Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle 9.0.0 Groovy DSL, ArchUnit 1.3.0, SpotBugs Gradle plugin 6.5.6/SpotBugs 4.10.2, Bash, Docker/BuildKit, GitHub Actions. ## Global Constraints - Preserve all 19 leaf identities and production dependency edges from `src/config/architecture/modules.json`. - `domain-core` and `application-core` gain no framework, transport, database, or cloud dependency. - Do not weaken an architecture rule with a global `allowEmptyShould(true)`. - Keep Gradle at exactly `9.0.0` in this plan. - Set `distributionSha256Sum=8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b`. - The official Gradle 9.0.0 wrapper JAR SHA-256 is `76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3`. - Pin `gradle/actions/wrapper-validation` to commit `3f131e8634966bd73d06cc69884922b02e6faf92` in workflows that invoke Gradle. - Docker images do not receive `.git`; full evidence revisions arrive through `-PgitRevision`/CI attestation. - SpotBugs dependency scopes are not widened to silence missing-class output. - Agents do not stage, commit, amend, or push; commit steps from the generic workflow are replaced by diff/status evidence. --- ### Task 1: Move the Object Storage Architecture Rule to Its Owning Leaf **Files:** - Create: `src/adapter/outbound/objectstorage/src/test/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageArchitectureTest.java` - Modify: `src/adapter/outbound/objectstorage/build.gradle` - Modify: `src/adapter/outbound/objectstorage/gradle.lockfile` - Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java:1586-1608` **Interfaces:** - Consumes: production classes under `dev.caskeleton.adapter.outbound.objectstorage..` and application/shared contracts already on the Object Storage test classpath. - Produces: an owner-local ArchUnit rule named `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES`; a sample-off root suite with no Object Storage presence requirement. - [ ] **Step 1: Reproduce the existing failing regression** Run: ```bash cd src ./gradlew :app-bootstrap:sampleOffTest --tests '*CleanArchitectureTest' --console=plain ``` Expected: FAIL only at `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES` because no matching classes are present. - [ ] **Step 2: Add the owner-local test before removing the root rule** Create a package-local ArchUnit test that imports production classes from the Object Storage package and applies this rule: ```java @AnalyzeClasses(packages = "dev.caskeleton.adapter.outbound.objectstorage") class ObjectStorageArchitectureTest { @ArchTest static final ArchRule OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES = methods() .that() .areDeclaredInClassesThat() .resideInAPackage("..adapter.outbound.objectstorage..") .and() .areDeclaredInClassesThat() .haveSimpleNameEndingWith("Adapter") .and() .arePublic() .and() .areNotStatic() .should() .notHaveRawReturnType( JavaClass.Predicates.resideInAnyPackage( "..adapter.outbound..", "..adapter.inbound.web..", "..adapter.outbound.persistence..")) .allowEmptyShould(false); } ``` Add the owner-local test dependency: ```groovy testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' ``` Refresh only the Object Storage leaf lock state with its existing `resolveAndLockAll --write-locks` task. This is a test-scope dependency; do not add a production project or external dependency edge. - [ ] **Step 3: Run the owner test while the root regression remains red** Run: ```bash cd src ./gradlew :adapter:outbound:objectstorage:resolveAndLockAll --write-locks --console=plain ./gradlew :adapter:outbound:objectstorage:test --tests '*ObjectStorageArchitectureTest' --console=plain ``` Expected: PASS with matching production adapter methods. - [ ] **Step 4: Remove only the misplaced root rule** Delete the `OBJECT_STORAGE_ADAPTER_METHOD_RETURNS_ONLY_APPLICATION_OR_PRIMITIVES` field from `CleanArchitectureTest`; do not change neighboring cross-module rules. - [ ] **Step 5: Verify both ownership paths** Run: ```bash cd src ./gradlew :adapter:outbound:objectstorage:test :app-bootstrap:sampleOffTest --console=plain ``` Expected: PASS, zero failed tests. - [ ] **Step 6: Record diff evidence without committing** Run `git diff --check` and `git status --short`; retain the output for the task review. ### Task 2: Align and Validate the Gradle 9.0.0 Wrapper **Files:** - Create: `.github/scripts/verify-gradle-wrapper.sh` - Modify: `src/gradle/wrapper/gradle-wrapper.properties` - Regenerate: `src/gradle/wrapper/gradle-wrapper.jar`, `src/gradlew`, `src/gradlew.bat` - Modify: `.github/workflows/ci-quality-gates.yml` - Modify: `.github/workflows/dependency-vulnerability.yml` - Modify: `.github/workflows/jpa-r2-evidence.yml` - Modify: `.github/workflows/object-storage-qualification.yml` - Modify: `.github/workflows/redis-production-readiness.yml` - Lock without modification: `.github/workflows/link-check.yml` - Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java` **Interfaces:** - Consumes: repository root as argument 1, wrapper properties/JAR, and every YAML workflow under `.github/workflows`. - Produces: executable `verify-gradle-wrapper.sh` with exit 0 only for the exact Gradle 9.0.0 wrapper, the reviewed six-file workflow path/SHA-256 lock, the repository's restricted canonical workflow grammar, and jobs where an unconditional pinned validation step gates every reachable Gradle invocation. - [ ] **Step 1: Write failing executable-contract tests** Add a `DeveloperExperienceContractTest` case that runs: ```java Process process = new ProcessBuilder("bash", ".github/scripts/verify-gradle-wrapper.sh", REPOSITORY_ROOT.toString()) .directory(REPOSITORY_ROOT.toFile()) .redirectErrorStream(true) .start(); assertThat(process.waitFor()).as(new String(process.getInputStream().readAllBytes(), UTF_8)).isZero(); ``` Add a second case that copies wrapper properties/JAR and workflows to `@TempDir`, changes the distribution checksum, runs the script against that fixture root, and asserts a non-zero exit. The production mutation this test catches is accepting a wrong wrapper or distribution checksum. - [ ] **Step 2: Verify RED** Run: ```bash cd src ./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain ``` Expected: FAIL because `.github/scripts/verify-gradle-wrapper.sh` does not exist and the checked-in wrapper is not the Gradle 9.0.0 JAR. - [ ] **Step 3: Implement the wrapper verifier** The Bash script must: ```text 1. require exactly one repository-root argument; 2. require the exact ordered eight-line wrapper-properties file, including the Gradle 9.0.0 URL and distribution checksum from Global Constraints; 3. reject duplicate, alternate-separator, escaped, continued, reordered, or extra properties; 4. compare the wrapper JAR SHA-256 with the exact Gradle 9.0.0 JAR hash; 5. enumerate every top-level `.yml`/`.yaml` workflow, reject symlinks/special files, and compare the exact sorted six-path set and SHA-256 values to the verifier's embedded reviewed workflow lock; additions, removals, renames, or byte changes are failures; 6. structurally validate the supported block grammar before admission and emit specific diagnostics for recognized noncanonical `jobs`/job/`steps` containers, flow collections, aliases, anchors, tags, merge keys, encoded or multiline action scalars, and quoted/escaped run scalars; YAML semantics outside this deliberately partial diagnostic parser remain covered by the primary byte lock rather than an overclaim of complete Bash YAML parsing; 7. require every Gradle-running job to order checkout, the exact wrapper-validation action with stable `id: gradle-wrapper-validation`, and every Gradle invocation; 8. accept the validation step only with its exact canonical name/id/uses fields and no `if`, `continue-on-error`, `with`, `env`, timeout, or other weakening field; 9. finalize every Gradle step, not only the first. A Gradle step may have no condition or exactly `${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}`; bare `always()`, failure/cancelled paths, `continue-on-error`, and other reachability expressions fail closed; 10. treat literal run-block body text only as shell data, never as an action field, and require each raw Gradle reference admitted by the gate to resolve to a canonical job; 11. print `gradle-wrapper-contract: PASS` only when every check succeeds. ``` For an intentional workflow edit, review the complete workflow diff, verify that no workflow path is a symlink/special file, regenerate the entire sorted `sha256sum` list with: ```bash find .github/workflows -mindepth 1 -maxdepth 1 \ \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing find .github/workflows -mindepth 1 -maxdepth 1 -type f \ \( -name '*.yml' -o -name '*.yaml' \) -print0 \ | LC_ALL=C sort -z | xargs -0 sha256sum ``` Replace the complete sorted embedded array in the same reviewed change. Never refresh only the failing digest as a build-unblock shortcut. - [ ] **Step 4: Regenerate the wrapper twice and add the distribution checksum** Run in `src/`: ```bash ./gradlew wrapper --gradle-version 9.0.0 --distribution-type bin ./gradlew wrapper --gradle-version 9.0.0 --distribution-type bin ``` Then add the exact `distributionSha256Sum` property immediately after `distributionUrl`. - [ ] **Step 5: Add the pinned validation action to every Gradle workflow job** After each checkout step and before setup/cache/build invokes Gradle, add: ```yaml - name: Validate Gradle wrapper id: gradle-wrapper-validation uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 ``` Jobs without a Gradle invocation do not need the action. A sanitizer that intentionally executes after a failed test must use the exact guarded condition shown above so wrapper-validation failure still prevents Gradle. Preserve that behavior in Redis rather than using bare `always()`. - [ ] **Step 6: Verify GREEN and mutation rejection** Run: ```bash bash .github/scripts/verify-gradle-wrapper.sh . cd src ./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain ``` Expected: script prints `gradle-wrapper-contract: PASS`; focused tests pass; executable mutations reject checksum/property overrides, missing validation per job, named/anonymous/quoted/escaped and continued action variants, encoded run scalars, block/alias/merge/flow YAML forms, validation-step control fields, Gradle steps reachable after validation failure, custom-shell or alternate-wrapper paths, duplicate encoded jobs, workflow additions/removals/symlinks, and otherwise innocuous byte drift through the primary workflow lock. - [ ] **Step 7: Record diff evidence without committing** Run `sha256sum src/gradle/wrapper/gradle-wrapper.jar`, `git diff --check`, and `git status --short`. ### Task 3: Make Docker Build Configuration Inputs Explicit **Files:** - Modify: `src/Dockerfile:39-66` - Modify: `src/Dockerfile.sample:50-75` - Modify: `src/build.gradle:2153-2181` and all Redis evidence consumers - Modify: `src/adapter/outbound/cache-redis/build.gradle` (leaf evidence consumers) - Modify: `src/app-bootstrap/build.gradle` - Modify: `src/sample-portfolio/build.gradle` - Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java` **Interfaces:** - Consumes: `config/**`, Gradle source/build files, `-PgitRevision`, and the `bootJar` archive provider. - Produces: `:app-bootstrap:stageDockerJar` and `:sample-portfolio:stageDockerJar`, each writing exactly `build/docker/application.jar`; evidence metadata is resolved only when a Redis evidence task executes. - [ ] **Step 1: Write failing build-contract tests** Add tests that split each Dockerfile at its first `RUN ./gradlew` and assert the preceding section uses repository-preserving `WORKDIR /build/src` and contains `COPY config/ ./config/`. Add tests that require the Dockerfiles to run `stageDockerJar` and copy the exact `build/docker/application.jar`, with no `ls | grep | head` selection. Add a test that runs `./gradlew help -PgitRevision=0123456789abcdef0123456789abcdef01234567` from a temporary Git-less copy containing the same files as the dependency-cache stage. Add three self-contained evidence-task fixtures under temporary repository roots: one uses a `.git` directory, one uses a worktree `.git` metadata file, and one uses a dangling `.git` symlink. All prepend a fake `git` to `PATH` and require the exact named failure for `rev-parse` or `status` process errors; the symlink fixture must also prove the link entry exists with `NOFOLLOW_LINKS`. These tests must copy the minimum build/registry inputs and invoke the fixture wrapper; they must not assert or execute the ambient checkout's `.git`. - [ ] **Step 2: Verify RED** Run: ```bash cd src ./gradlew :app-bootstrap:test --tests '*DeveloperExperienceContractTest' --console=plain ``` Expected: FAIL because neither cache stage copies `config/**`, both select JARs with shell matching, and Git is resolved during configuration. - [ ] **Step 3: Add deterministic Docker staging tasks** In both executable modules register: ```groovy tasks.register('stageDockerJar', Sync) { dependsOn tasks.named('bootJar') from(tasks.named('bootJar').flatMap { it.archiveFile }) into(layout.buildDirectory.dir('docker')) rename { 'application.jar' } } ``` - [ ] **Step 4: Update both Dockerfiles** Use `WORKDIR /build/src` so repository-relative registry paths resolve under `/build/src/**`, copy `config/` before the first Gradle invocation, invoke the correct `stageDockerJar` task with the existing release/revision properties, and copy only the fixed `build/docker/application.jar` path into the runtime stage. - [ ] **Step 5: Move Redis Git evidence resolution to execution time** Replace the eager `String` values with closures/providers invoked from evidence task actions: ```groovy Closure> resolveRedisSourceEvidence = { File gitMetadata = rootProject.file('../.git') if (!java.nio.file.Files.exists( gitMetadata.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { String attested = providers.gradleProperty('gitRevision') .orElse(providers.environmentVariable('GITHUB_SHA')) .orElse(providers.environmentVariable('GIT_SHA')) .getOrElse('') if (!(attested ==~ /[0-9a-f]{40}/)) { throw new GradleException( 'Redis evidence requires an exact 40-character source revision.') } return [revision: attested, treeState: 'ATTESTED'] } String headFailure = 'Redis evidence failed to resolve checked-out Git HEAD.' def headExecution try { headExecution = providers.exec { commandLine 'git', 'rev-parse', 'HEAD' ignoreExitValue = true } if (headExecution.result.get().exitValue != 0) { throw new GradleException(headFailure) } } catch (GradleException exception) { if (exception.message == headFailure) { throw exception } throw new GradleException(headFailure, exception) } String checkedOut = headExecution.standardOutput.asText.getOrElse('').trim() if (!(checkedOut ==~ /[0-9a-f]{40}/)) { throw new GradleException(headFailure) } String supplied = providers.gradleProperty('gitRevision') .orElse(providers.environmentVariable('GITHUB_SHA')) .orElse(providers.environmentVariable('GIT_SHA')) .orElse(checkedOut) .getOrElse('') if (!(supplied ==~ /[0-9a-f]{40}/)) { throw new GradleException('Redis evidence requires an exact 40-character source revision.') } if (!checkedOut.isBlank() && supplied != checkedOut) { throw new GradleException('Redis evidence source revision does not match checked-out HEAD.') } String statusFailure = 'Redis evidence failed to inspect checked-out Git status.' def statusExecution try { statusExecution = providers.exec { commandLine 'git', 'status', '--porcelain', '--untracked-files=normal' ignoreExitValue = true } if (statusExecution.result.get().exitValue != 0) { throw new GradleException(statusFailure) } } catch (GradleException exception) { if (exception.message == statusFailure) { throw exception } throw new GradleException(statusFailure, exception) } String treeState = statusExecution.standardOutput.asText.getOrElse('').isBlank() ? 'CLEAN' : 'DIRTY' [revision: supplied, treeState: treeState] } ``` Each evidence-producing root `doLast` and each leaf evidence test's root-suite `afterSuite` resolves this once and uses the returned values for all generated/validated artifacts. The resolver is exposed as `rootProject.ext.resolveRedisSourceEvidence`; eager scalar ext properties are removed. Non-evidence tasks never call the closure. Any repository-root `.git` filesystem entry is detected without following symbolic links, so a directory, worktree metadata file, or dangling symlink always selects the checkout branch. Both Git processes must start, exit zero, and return valid evidence before `CLEAN` or `DIRTY` can be emitted. `ATTESTED` is reserved for a truly absent `.git` entry in an explicitly Git-less build with an exact supplied revision; a Git execution failure must never fall back to it. - [ ] **Step 6: Verify GREEN without `.git` and verify evidence mismatch failure** Run the focused contract test, `./gradlew help` in the Git-less fixture with a 40-character `gitRevision`, and one Redis evidence task in the real checkout. The Git-less help invocation must pass; a Git-less Redis evidence task with a short revision must fail with the named message. Separate self-contained fixtures must cover a `.git` directory whose `rev-parse` fails, a `.git` worktree file whose `status` fails, and a dangling `.git` symlink whose Git invocation fails. Each fixture must assert the corresponding named fail-closed diagnostic instead of accepting a generic non-zero exit. - [ ] **Step 7: Run actual Docker smoke when Docker is available** Run both image builds with `--no-cache`. If Docker is unavailable, record the exact blocker and leave these commands as remaining risk; do not claim Docker success from string tests. - [ ] **Step 8: Record diff evidence without committing** Run `git diff --check` and `git status --short`. ### Task 4: Complete SpotBugs Auxiliary Classpaths and Remove the Gradle 10 Warning **Files:** - Modify: `src/build.gradle:208-360` - Modify: `src/build.gradle:1760-1795` - Test/verify: app-bootstrap redisComposition, inbound GraphQL main, inbound gRPC main SpotBugs tasks **Interfaces:** - Consumes: every leaf's `SourceSetContainer` and the SpotBugs task named for each source set. - Produces: each SpotBugs task's `auxClassPaths` containing `sourceSet.runtimeClasspath - sourceSet.output` and a required XML report whose analysis errors/missing classes are checked after execution; `verifyApplicationCoreDependencyPurity` uses a configuration-time `Project` reference and declares its execution-time configuration traversal incompatible with the configuration cache. - [ ] **Step 1: Capture the failing static-analysis evidence** Run clean focused SpotBugs tasks and save output. Expected RED messages name Spring Session, `io.micrometer.context.ContextSnapshot`, and protobuf types as classes needed for analysis. - [ ] **Step 2: Capture the Gradle 10 deprecation RED** Run: ```bash cd src ./gradlew verifyApplicationCoreDependencyPurity --warning-mode=fail --console=plain ``` Expected: FAIL on execution-time `Task.project` access. - [ ] **Step 3: Configure source-set-derived auxiliary classpaths** After applying SpotBugs in each leaf, configure: ```groovy sourceSets.configureEach { sourceSet -> String taskName = "spotbugs${sourceSet.name.capitalize()}" tasks.named(taskName, com.github.spotbugs.snom.SpotBugsTask) { auxClassPaths.from(sourceSet.runtimeClasspath - sourceSet.output) def xmlAnalysisReport = reports.maybeCreate('xml') xmlAnalysisReport.required.set(true) doLast { List analysisFailures = spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile) if (!analysisFailures.isEmpty()) { throw new GradleException( "${path}: SpotBugs analysis incomplete:\n " + analysisFailures.join('\n ')) } } } } ``` Do not add compile/runtime dependencies solely for SpotBugs. The XML parser fails on a missing or malformed report, malformed `Errors` counts, any `MissingClass`, and any analysis `Error`; ordinary `BugInstance` findings remain governed by the existing main/test severity policy. Wire an executable `verifySpotBugsAnalysisFailureContract` fixture into every leaf `check` so clean and advisory-bug-only reports pass while missing-class and analysis-error reports fail. - [ ] **Step 4: Remove execution-time project access** Resolve `Project applicationCoreProject = project(':application-core')` before registering `verifyApplicationCoreDependencyPurity`; capture that variable in `doLast` instead of calling `project(...)` from the task action. Because the action still traverses project configurations at execution time, declare `notCompatibleWithConfigurationCache('Inspects project configurations at execution time')` rather than making an unsupported compatibility claim. - [ ] **Step 5: Verify GREEN** Run `verifySpotBugsAnalysisFailureContract`, the three clean focused SpotBugs tasks, and `verifyApplicationCoreDependencyPurity --warning-mode=fail`. Expected: exit 0, XML `Errors errors="0" missingClasses="0"`, and no missing-analysis-class/deprecation output. - [ ] **Step 6: Run release-hygiene aggregate verification** Run: ```bash cd src ./gradlew clean check :app-bootstrap:sampleOffTest verifyPublicPathSnapshot verifyDependencyLocks --no-daemon --console=plain --warning-mode=fail cd .. bash .github/scripts/verify-gate-matrix.sh bash .github/scripts/verify-gradle-wrapper.sh . ``` Expected: every command exits 0; no skipped mandatory gate, missing SpotBugs class, or Gradle deprecation. - [ ] **Step 7: Record final diff evidence without committing** Run `git diff --check`, `git diff --stat`, and `git status --short`. Dispatch the complete diff for architecture/spec and code-quality review. ## Plan Self-Review - Spec coverage: every release-hygiene design decision maps to Tasks 1-4. - Type consistency: both executable modules expose the same `stageDockerJar` task and output path; Redis evidence uses one `Map` resolver contract. - Architecture: no production dependency edge changes are required. - Test discipline: each behavior has a named failing command or executable mutation fixture before implementation. - Commit policy: all generic commit steps are replaced with diff/status evidence.