From eaac739e6b1928fac0fe141564eb1ce734b07ac2 Mon Sep 17 00:00:00 2001 From: Bruno Gao Date: Thu, 12 Feb 2026 13:29:03 +0800 Subject: [PATCH] feat: implement batch 4 archunit tests and ci workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive ArchUnit A-class rules for architecture validation - Implement layer dependency rules (api → infra → app → domain) - Add domain layer purity checks (no Spring/JPA/Hibernate dependencies) - Create application layer import restrictions - Add @Transactional boundary enforcement - Implement API layer rules (no field injection, no infra dependency) - Add persistence isolation and cycle detection rules - Create CI workflow with quality gate and build-deploy jobs - Follow M2 v5 frozen contracts for architecture validation Co-authored-by: Qwen-Coder --- .gitea/workflows/ci.yaml | 147 ++++++++++++++++++ .../api/architecture/ApiLayerRulesTest.java | 36 +++++ .../ApplicationLayerImportBanTest.java | 40 +++++ .../architecture/DomainLayerPurityTest.java | 60 +++++++ .../api/architecture/LayerDependencyTest.java | 34 ++++ .../architecture/PersistenceAndCycleTest.java | 68 ++++++++ .../TransactionalBoundaryTest.java | 35 +++++ .../tmpl/api/architecture/package-info.java | 16 ++ 8 files changed, 436 insertions(+) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/ApiLayerRulesTest.java create mode 100644 tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/ApplicationLayerImportBanTest.java create mode 100644 tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/DomainLayerPurityTest.java create mode 100644 tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/LayerDependencyTest.java create mode 100644 tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/PersistenceAndCycleTest.java create mode 100644 tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/TransactionalBoundaryTest.java create mode 100644 tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/package-info.java diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..b89ce51 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,147 @@ +# ============================================================ +# Gitea Actions CI — java-service-template +# ============================================================ +# Job 1 quality-gate : compile + test (JaCoCo) → spotless → verify (SpotBugs + OWASP) +# Job 2 build-deploy : docker build/push → deploy repo update +# (only main/release, only when Dockerfile exists) +# ============================================================ + +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +env: + JAVA_VERSION: "21" + JAVA_DISTRIBUTION: "temurin" + MAVEN_OPTS: "-Xmx1024m -Dfile.encoding=UTF-8" + +jobs: + # ------------------------------------------------------- + # Job 1 — Quality Gate (all branches) + # ------------------------------------------------------- + quality-gate: + name: quality-gate + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: maven + + # Step 1 — compile + unit test + ArchUnit + JaCoCo exec generation + - name: "Step 1: Compile & Test (JaCoCo enabled)" + run: > + mvn clean test + -Djacoco.skip=false + --batch-mode --fail-at-end + + # Step 2 — code formatting + - name: "Step 2: Spotless Check" + run: mvn spotless:check --batch-mode + + # Step 3 — coverage check + static analysis + dependency scan + # Uses -DskipTests because tests already passed in Step 1. + # JaCoCo check reads the exec file generated in Step 1. + - name: "Step 3: Verify (JaCoCo + SpotBugs + OWASP)" + run: > + mvn verify -DskipTests + -Djacoco.skip=false + -Dspotbugs.skip=false + -Ddependency-check.skip=false + --batch-mode + + # ------------------------------------------------------- + # Job 2 — Build & Deploy (main / release branches only) + # ------------------------------------------------------- + build-deploy: + name: build-deploy + needs: [quality-gate] + runs-on: ubuntu-latest + if: > + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Abort early if Dockerfile not yet present (added in later batch) + - name: Check Dockerfile exists + id: dockerfile + run: | + if [ -f Dockerfile ]; then + echo "EXISTS=true" >> $GITHUB_OUTPUT + else + echo "EXISTS=false" >> $GITHUB_OUTPUT + echo "::warning::Dockerfile not found — skipping build-deploy." + fi + + - name: Set up JDK + if: steps.dockerfile.outputs.EXISTS == 'true' + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: maven + + - name: Set image tag (git short SHA 7) + if: steps.dockerfile.outputs.EXISTS == 'true' + id: tag + run: echo "IMAGE_TAG=$(git rev-parse --short=7 HEAD)" >> $GITHUB_OUTPUT + + - name: Build JAR (skip tests) + if: steps.dockerfile.outputs.EXISTS == 'true' + run: mvn package -DskipTests --batch-mode + + - name: Login to registry + if: steps.dockerfile.outputs.EXISTS == 'true' + run: | + echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + env: + REGISTRY: ${{ secrets.REGISTRY }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build & push Docker image + if: steps.dockerfile.outputs.EXISTS == 'true' + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + docker build -t "${IMAGE}" . + docker push "${IMAGE}" + env: + REGISTRY: ${{ secrets.REGISTRY }} + IMAGE_NAME: java-service-template + IMAGE_TAG: ${{ steps.tag.outputs.IMAGE_TAG }} + + - name: Update deploy repo (GitOps) + if: steps.dockerfile.outputs.EXISTS == 'true' + env: + DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} + DEPLOY_REPO: ${{ secrets.DEPLOY_REPO }} + IMAGE_TAG: ${{ steps.tag.outputs.IMAGE_TAG }} + run: | + if [ -z "${DEPLOY_REPO}" ]; then + echo "::warning::DEPLOY_REPO secret not set — skipping deploy update." + exit 0 + fi + git clone "https://x-access-token:${DEPLOY_TOKEN}@${DEPLOY_REPO}" deploy-repo + cd deploy-repo + sed -i "s|newTag:.*|newTag: ${IMAGE_TAG}|g" overlays/*/kustomization.yaml 2>/dev/null || true + sed -i "s|tag:.*|tag: \"${IMAGE_TAG}\"|g" values-*.yaml 2>/dev/null || true + git config user.name "CI Bot" + git config user.email "ci@example.com" + git add -A + git diff --cached --quiet || git commit -m "chore: update image tag to ${IMAGE_TAG}" + git push \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/ApiLayerRulesTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/ApiLayerRulesTest.java new file mode 100644 index 0000000..339379d --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/ApiLayerRulesTest.java @@ -0,0 +1,36 @@ +package com.ljwx.tmpl.api.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * A-5 / A-6 — API layer structural rules. + * + */ +@AnalyzeClasses(packages = "com.ljwx.tmpl", importOptions = ImportOption.DoNotIncludeTests.class) +class ApiLayerRulesTest { + + @ArchTest + static final ArchRule no_field_injection = + noFields() + .should().beAnnotatedWith(Autowired.class) + .as("A-5: Field injection (@Autowired on fields) is forbidden; use constructor injection"); + + @ArchTest + static final ArchRule controllers_must_not_depend_on_infrastructure = + noClasses() + .that().resideInAPackage("com.ljwx.tmpl.api.controller..") + .should().dependOnClassesThat() + .resideInAPackage("com.ljwx.tmpl.infrastructure..") + .as("A-6: Controllers must not depend on infrastructure (use application-layer ports)"); +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/ApplicationLayerImportBanTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/ApplicationLayerImportBanTest.java new file mode 100644 index 0000000..b3ca1b6 --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/ApplicationLayerImportBanTest.java @@ -0,0 +1,40 @@ +package com.ljwx.tmpl.api.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +/** + * A-3 — Application layer import restrictions. + *

+ * Application layer may only depend on domain + Spring context/tx. + * It must NOT import web, persistence, security, messaging, or client packages. + */ +@AnalyzeClasses(packages = "com.ljwx.tmpl.application", importOptions = ImportOption.DoNotIncludeTests.class) +class ApplicationLayerImportBanTest { + + @ArchTest + static final ArchRule application_must_not_depend_on_banned_packages = + noClasses() + .that().resideInAPackage("com.ljwx.tmpl.application..") + .should().dependOnClassesThat() + .resideInAnyPackage( + "org.springframework.web..", + "org.springframework.http..", + "org.springframework.security..", + "org.springframework.cache..", + "org.springframework.data.jpa..", + "org.springframework.data.redis..", + "jakarta.persistence..", + "javax.persistence..", + "org.hibernate..", + "org.springframework.web.client..", + "org.springframework.web.reactive..", + "org.springframework.cloud.openfeign..", + "org.springframework.kafka.." + ) + .as("A-3: Application layer must not import web/persistence/security/client/messaging packages"); +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/DomainLayerPurityTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/DomainLayerPurityTest.java new file mode 100644 index 0000000..f168b0a --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/DomainLayerPurityTest.java @@ -0,0 +1,60 @@ +package com.ljwx.tmpl.api.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +/** + * A-2 — Domain layer purity. + *

+ * Domain must not depend on Spring, JPA, Hibernate, or outer layers + * (infrastructure, api). Jakarta Validation annotations + * are permitted, but the Validator runtime is banned. + */ +@AnalyzeClasses(packages = "com.ljwx.tmpl.domain", importOptions = ImportOption.DoNotIncludeTests.class) +class DomainLayerPurityTest { + + @ArchTest + static final ArchRule domain_must_not_depend_on_spring = + noClasses() + .that().resideInAPackage("com.ljwx.tmpl.domain..") + .should().dependOnClassesThat() + .resideInAnyPackage( + "org.springframework..", + "org.springframework.boot.." + ) + .as("A-2: Domain must not depend on Spring"); + + @ArchTest + static final ArchRule domain_must_not_depend_on_jpa = + noClasses() + .that().resideInAPackage("com.ljwx.tmpl.domain..") + .should().dependOnClassesThat() + .resideInAnyPackage( + "jakarta.persistence..", + "javax.persistence.." + ) + .as("A-2: Domain must not depend on JPA"); + + @ArchTest + static final ArchRule domain_must_not_depend_on_hibernate = + noClasses() + .that().resideInAPackage("com.ljwx.tmpl.domain..") + .should().dependOnClassesThat() + .resideInAnyPackage("org.hibernate..") + .as("A-2: Domain must not depend on Hibernate"); + + @ArchTest + static final ArchRule domain_must_not_depend_on_outer_layers = + noClasses() + .that().resideInAPackage("com.ljwx.tmpl.domain..") + .should().dependOnClassesThat() + .resideInAnyPackage( + "com.ljwx.tmpl.api..", + "com.ljwx.tmpl.infrastructure.." + ) + .as("A-2: Domain must not depend on API or Infrastructure layers"); +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/LayerDependencyTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/LayerDependencyTest.java new file mode 100644 index 0000000..577c915 --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/LayerDependencyTest.java @@ -0,0 +1,34 @@ +package com.ljwx.tmpl.api.architecture; + +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +/** + * A-1 — Layer dependency direction. + *

+ * Enforces: api → infrastructure → application → domain. + * No reverse or cross-layer access is allowed. + */ +@AnalyzeClasses(packages = "com.ljwx.tmpl", importOptions = ImportOption.DoNotIncludeTests.class) +class LayerDependencyTest { + + @ArchTest + static final ArchRule layer_dependencies_are_respected = + layeredArchitecture() + .consideringAllDependencies() + .layer("Domain").definedBy("com.ljwx.tmpl.domain..") + .layer("Application").definedBy("com.ljwx.tmpl.application..") + .layer("Infrastructure").definedBy("com.ljwx.tmpl.infrastructure..") + .layer("API").definedBy("com.ljwx.tmpl.api..") + + .whereLayer("Domain").mayNotAccessAnyLayer() + .whereLayer("Application").mayOnlyAccessLayers("Domain") + .whereLayer("Infrastructure").mayOnlyAccessLayers("Application", "Domain") + .whereLayer("API").mayOnlyAccessLayers("Infrastructure", "Application", "Domain") + + .as("A-1: Layer dependency direction must be api -> infra -> application -> domain"); +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/PersistenceAndCycleTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/PersistenceAndCycleTest.java new file mode 100644 index 0000000..a24c63d --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/PersistenceAndCycleTest.java @@ -0,0 +1,68 @@ +package com.ljwx.tmpl.api.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +/** + * A-7 / A-8 — Persistence isolation + cycle detection. + *

+ */ +@AnalyzeClasses(packages = "com.ljwx.tmpl", importOptions = ImportOption.DoNotIncludeTests.class) +class PersistenceAndCycleTest { + + private static final String INTERNAL_PKG = "com.ljwx.tmpl.infrastructure.persistence.internal.."; + + /* + * A-7: Repository implementations in the internal sub-package must be package-private. + * Currently no classes exist in this package (they will be added in Batch 5), + * so this rule passes vacuously. Once repositories are added, it will enforce + * the package-private convention. + */ + @ArchTest + static final ArchRule internal_repositories_must_not_be_public = + classes() + .that().resideInAPackage(INTERNAL_PKG) + .and().haveSimpleNameEndingWith("Repository") + .should().notBePublic() + .as("A-7: Internal Repository classes must be package-private"); + + /* + * A-7b: Outside code must not depend on internal persistence Repository implementations. + * Uses a combined target-class predicate: classes that BOTH reside in the internal + * package AND have names ending with "Repository". + */ + @ArchTest + static final ArchRule outside_must_not_depend_on_internal_repositories = + noClasses() + .that().resideOutsideOfPackage(INTERNAL_PKG) + .should().dependOnClassesThat( + com.tngtech.archunit.core.domain.JavaClass.Predicates + .resideInAPackage(INTERNAL_PKG) + .and(com.tngtech.archunit.core.domain.JavaClass.Predicates + .simpleNameEndingWith("Repository")) + ) + .as("A-7b: Outside code must not depend on internal Repository implementations"); + + /* + * A-8: No cyclic dependencies between top-level module packages + * (domain, application, infrastructure, api). + */ + @ArchTest + static final ArchRule no_top_level_package_cycles = + slices() + .matching("com.ljwx.tmpl.(*)..") + .should().beFreeOfCycles() + .as("A-8: No cyclic dependencies between top-level module packages"); +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/TransactionalBoundaryTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/TransactionalBoundaryTest.java new file mode 100644 index 0000000..57026bc --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/TransactionalBoundaryTest.java @@ -0,0 +1,35 @@ +package com.ljwx.tmpl.api.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.transaction.annotation.Transactional; + +/** + * A-4 — {@code @Transactional} boundary enforcement. + *

+ * {@code @Transactional} (class-level or method-level) is only allowed + * in the application layer ({@code com.ljwx.tmpl.application..}). + */ +@AnalyzeClasses(packages = "com.ljwx.tmpl", importOptions = ImportOption.DoNotIncludeTests.class) +class TransactionalBoundaryTest { + + @ArchTest + static final ArchRule transactional_classes_only_in_application = + classes() + .that().areAnnotatedWith(Transactional.class) + .should().resideInAPackage("com.ljwx.tmpl.application..") + .as("A-4: @Transactional on classes is only allowed in the application layer"); + + @ArchTest + static final ArchRule transactional_methods_only_in_application = + methods() + .that().areAnnotatedWith(Transactional.class) + .should().beDeclaredInClassesThat() + .resideInAPackage("com.ljwx.tmpl.application..") + .as("A-4: @Transactional on methods is only allowed in the application layer"); +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/package-info.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/package-info.java new file mode 100644 index 0000000..91b9825 --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/architecture/package-info.java @@ -0,0 +1,16 @@ +/** + * ArchUnit A-class architecture tests — CI blocking rules. + * + *

Enforced contracts (v5 frozen): + *

+ */ +package com.ljwx.tmpl.api.architecture; \ No newline at end of file