Files
tech-log-backend/docs/superpowers/plans/2026-08-01-release-hygiene-refactoring.md
T
DongHyeonkaandClaude Opus 5 5f10b791d3 chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:48:43 +09:00

24 KiB

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:

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:

@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:

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:

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:

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:

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:

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:

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:

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/:

./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:

- 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 .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:

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:

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:

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:

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:

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:

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.