Files
tech-log-backend/docs/superpowers/plans/2026-08-01-release-hygiene-refactoring.md
T

511 lines
24 KiB
Markdown

# 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<Map<String, String>> 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<String> 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<String,String>` 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.