From de3686ade423e817812650fefc09cbec564c36ac Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Wed, 7 Oct 2026 20:57:48 +0800 Subject: [PATCH 1/4] ci: unify verification and verified artifact publication --- .github/CODEOWNERS | 8 +- .github/actions/setup-jdk-21/action.yml | 2 +- .github/dependabot.yml | 17 ++ .github/workflows/_bench.yml | 41 ++- .github/workflows/_build.yml | 70 ++--- .github/workflows/_docs.yml | 2 +- .github/workflows/_lint.yml | 2 +- .github/workflows/_quality.yml | 8 +- .github/workflows/_verify.yml | 130 ++++++++++ .github/workflows/ci.yml | 92 ++----- .github/workflows/maintenance.yml | 34 +++ .github/workflows/pr.yml | 131 ++-------- .github/workflows/release.yml | 229 +++++++++++------ .gitignore | 2 + CHANGELOG.md | 10 + CONTRIBUTING.md | 1 + docs/DEVELOPMENT.md | 54 +++- docs/OPERATIONS.md | 62 ++++- pom.xml | 59 +++-- resicache-bench/pom.xml | 3 +- scripts/ci/central.py | 183 +++++++++++++ scripts/ci/check-action-pins.py | 17 ++ scripts/ci/check-external-consumer.sh | 189 ++++---------- scripts/ci/check-recovery.py | 20 ++ scripts/ci/check-workflows.sh | 12 + scripts/ci/consumer/pom.xml | 35 +++ .../com/example/consumer/BootConsumer.java | 63 +++++ .../consumer/ExternalConsumerDemo.java | 100 +++++++ scripts/ci/dependencies.py | 119 +++++++++ scripts/ci/install-tools.py | 27 ++ scripts/ci/pipeline.py | 193 ++++++++++++++ scripts/ci/require-all-green.sh | 66 +---- scripts/ci/tests/test_pipeline.py | 243 ++++++++++++++++++ .../cache/RedisTopologyIntegrationTest.java | 190 ++++++++++++++ 34 files changed, 1812 insertions(+), 602 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/_verify.yml create mode 100644 .github/workflows/maintenance.yml create mode 100644 scripts/ci/central.py create mode 100644 scripts/ci/check-action-pins.py create mode 100644 scripts/ci/check-recovery.py create mode 100644 scripts/ci/check-workflows.sh create mode 100644 scripts/ci/consumer/pom.xml create mode 100644 scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java create mode 100644 scripts/ci/consumer/src/main/java/com/example/consumer/ExternalConsumerDemo.java create mode 100644 scripts/ci/dependencies.py create mode 100644 scripts/ci/install-tools.py create mode 100644 scripts/ci/pipeline.py create mode 100644 scripts/ci/tests/test_pipeline.py create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7a8d5386..4c507943 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,9 +8,15 @@ # Critical resilience machinery — ordering, control flow, protection logic /src/main/java/io/github/davidhlp/spring/cache/redis/chain/ @davidhlp -/src/main/java/io/github/davidhlp/spring/cache/redis/protection/ @davidhlp +/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ @davidhlp # Auto-configuration wiring + security-sensitive serialization /src/main/java/io/github/davidhlp/spring/cache/redis/config/ @davidhlp /src/main/java/io/github/davidhlp/spring/cache/redis/serialization/ @davidhlp /src/main/resources/META-INF/spring/ @davidhlp + +# CI, release and build contracts +/.github/ @davidhlp +/scripts/ci/ @davidhlp +/pom.xml @davidhlp +/.mvn/ @davidhlp diff --git a/.github/actions/setup-jdk-21/action.yml b/.github/actions/setup-jdk-21/action.yml index ea1f3ba3..f5b49e65 100644 --- a/.github/actions/setup-jdk-21/action.yml +++ b/.github/actions/setup-jdk-21/action.yml @@ -35,7 +35,7 @@ runs: using: composite steps: - name: Setup JDK ${{ inputs.java-version }} - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: java-version: ${{ inputs.java-version }} distribution: ${{ inputs.distribution }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..7ea1ee9c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 3 + - package-ecosystem: maven + directories: + - / + - /resicache-bench + schedule: + interval: weekly + open-pull-requests-limit: 5 + ignore: + - dependency-name: org.springframework.boot:* + update-types: [version-update:semver-major] diff --git a/.github/workflows/_bench.yml b/.github/workflows/_bench.yml index bc9169dc..23ea7318 100644 --- a/.github/workflows/_bench.yml +++ b/.github/workflows/_bench.yml @@ -1,43 +1,38 @@ -name: "Reusable · Bench" - +name: Reusable Bench on: workflow_call: - permissions: contents: read - jobs: bench-module: - name: JMH Bench Module runs-on: ubuntu-latest timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up JDK 21 - uses: ./.github/actions/setup-jdk-21 - - - name: Install Core to Local Repo - run: ./mvnw install -DskipTests -B - - - name: Build resicache-bench Fat-JAR - run: mvn -f resicache-bench/pom.xml clean package -DskipTests -B - - - name: Smoke-test v2 serializer and storage adaptation + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: ./.github/actions/setup-jdk-21 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: core-candidate + path: candidate + - name: Install verified core + run: | + python3 scripts/ci/pipeline.py verify-candidate candidate + version=$(python3 -c 'import json; print(json.load(open("candidate/manifest.json"))["version"])') + ./mvnw org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file -B \ + -Dfile="candidate/ResiCache-$version.jar" -DpomFile="candidate/ResiCache-$version.pom" + - run: ./mvnw -f resicache-bench/pom.xml clean package -DskipTests -B + - name: Storage protocol smoke (no performance SLO) run: >- java -jar resicache-bench/target/resicache-bench.jar 'CacheSerializationBenchmark.storageRoundTrip' -p payloadBytes=64 -wi 0 -i 1 -r 100ms -f 1 -foe true -jvmArgs '-Xms128m -Xmx256m' -rf json -rff resicache-bench/target/jmh-smoke.json - - - name: Upload Benchmark Artifact - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: - name: resicache-bench-fatjar + name: benchmark-smoke path: | resicache-bench/target/resicache-bench.jar resicache-bench/target/jmh-smoke.json + if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 068218cb..d4823ec5 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -1,60 +1,36 @@ -name: "Reusable · Build & Test" - +name: Reusable Build on: workflow_call: - permissions: contents: read - jobs: build-test: - name: Build & Verify (Java 21) + name: Build and Verify (Java 21) runs-on: ubuntu-latest - timeout-minutes: 20 - + timeout-minutes: 30 steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up JDK 21 - uses: ./.github/actions/setup-jdk-21 - - name: Validate integration-test naming - run: bash ./scripts/ci/check-test-names.sh - - - name: Verify Core (Unit & Integration Tests, JaCoCo Gate) - run: ./mvnw clean verify -B + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: ./.github/actions/setup-jdk-21 + - name: Require working Docker + run: docker info + - run: bash scripts/ci/check-test-names.sh + - name: Full tests, Javadoc and coverage + run: ./mvnw clean verify -Dtestcontainers.ryuk.disabled=false -B env: MAVEN_OPTS: '-Xmx1024m -XX:+UseParallelGC' - - - name: Upload Test Results - if: always() - uses: actions/upload-artifact@v7 + - run: python3 scripts/ci/pipeline.py reports + - run: python3 scripts/ci/pipeline.py candidate target/ci-candidate + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: - name: surefire-test-results - path: target/surefire-reports/ - retention-days: 7 - - - name: Upload JaCoCo Coverage Report + name: core-candidate + path: target/ci-candidate/ + if-no-files-found: error + retention-days: 30 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 if: always() - uses: actions/upload-artifact@v7 with: - name: jacoco-coverage-report - path: target/site/jacoco/ - retention-days: 7 - - unit-test: - name: Unit Tests (no Docker) - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up JDK 21 - uses: ./.github/actions/setup-jdk-21 - - - name: Run unit tests without Docker - run: ./mvnw -Punit test -B - env: - DOCKER_HOST: unix:///nonexistent.sock + name: full-test-results + path: | + target/surefire-reports/ + target/site/jacoco/ + retention-days: 14 diff --git a/.github/workflows/_docs.yml b/.github/workflows/_docs.yml index 706b68a5..7bcd5a31 100644 --- a/.github/workflows/_docs.yml +++ b/.github/workflows/_docs.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Guard against removed-feature regressions in docs run: | diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 676f86ab..cb5524e6 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Set up JDK 21 uses: ./.github/actions/setup-jdk-21 diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index 5aeb9d81..78be999b 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Set up JDK 21 uses: ./.github/actions/setup-jdk-21 @@ -56,11 +56,11 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Qodana Scan if: ${{ inputs.qodana-enabled && env.QODANA_TOKEN != '' }} - uses: JetBrains/qodana-action@v2026.2.1 + uses: JetBrains/qodana-action@10be11607eb323a180e2b76b26c9c5cdceac3e77 # v2026.2.1 with: cache-dir: .qodana/cache use-caches: true @@ -70,7 +70,7 @@ jobs: - name: Upload Qodana Report if: ${{ always() && inputs.qodana-enabled && env.QODANA_TOKEN != '' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: qodana-report path: ./qodana/report/ diff --git a/.github/workflows/_verify.yml b/.github/workflows/_verify.yml new file mode 100644 index 00000000..d945749e --- /dev/null +++ b/.github/workflows/_verify.yml @@ -0,0 +1,130 @@ +name: Reusable Verification +on: + workflow_call: + inputs: + allow-docs-only: + type: boolean + default: false +permissions: + contents: read +jobs: + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + docs_only: ${{ steps.classify.outputs.docs_only }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + - id: classify + env: + ALLOW_DOCS: ${{ inputs.allow-docs-only }} + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + if [[ "$ALLOW_DOCS" == true && "$EVENT" == pull_request ]]; then + python3 scripts/ci/pipeline.py classify "$BASE" "$HEAD" >> "$GITHUB_OUTPUT" + else + echo 'docs_only=false' >> "$GITHUB_OUTPUT" + fi + workflow: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - run: bash scripts/ci/check-workflows.sh + docs: + uses: ./.github/workflows/_docs.yml + lint: + needs: changes + if: needs.changes.outputs.docs_only == 'false' + uses: ./.github/workflows/_lint.yml + unit: + needs: changes + if: needs.changes.outputs.docs_only == 'false' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: ./.github/actions/setup-jdk-21 + - run: ./mvnw -Punit clean test -B + env: + DOCKER_HOST: unix:///nonexistent.sock + - run: python3 scripts/ci/pipeline.py reports --unit + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: always() + with: + name: unit-test-results + path: target/surefire-reports/ + retention-days: 7 + build: + needs: changes + if: needs.changes.outputs.docs_only == 'false' + uses: ./.github/workflows/_build.yml + consumer: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: ./.github/actions/setup-jdk-21 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: core-candidate + path: candidate + - run: bash scripts/ci/check-external-consumer.sh candidate + bench: + needs: build + uses: ./.github/workflows/_bench.yml + security: + needs: [changes, build] + if: needs.changes.outputs.docs_only == 'false' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup-jdk-21 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: core-candidate + path: candidate + - name: Dependency Review + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high + comment-summary-in-pr: never + - name: Resolved core and benchmark dependency evidence + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + EVENT: ${{ github.event_name }} + run: python3 scripts/ci/dependencies.py --base "$BASE" --event "$EVENT" --candidate candidate --output target/dependencies + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: always() + with: + name: dependency-evidence + path: target/dependencies/ + retention-days: 14 + verification-ok: + name: Verification gate + needs: [changes, workflow, docs, lint, unit, build, consumer, bench, security] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - env: + RESULTS: ${{ toJSON(needs) }} + EXPECTED_JOBS: changes workflow docs lint unit build consumer bench security + DOCS_ONLY: ${{ needs.changes.outputs.docs_only }} + run: | + if [[ "$DOCS_ONLY" == true ]]; then + export ALLOWED_SKIPS='lint unit build consumer bench security' + elif [[ "$DOCS_ONLY" != false ]]; then + echo 'Missing change classification' >&2; exit 1 + fi + bash scripts/ci/require-all-green.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 914136a0..2665d4ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,90 +1,28 @@ name: CI Pipeline - on: push: - branches: [main, master] - + branches: [main] + workflow_dispatch: +permissions: + contents: read concurrency: group: ci-${{ github.ref }} cancel-in-progress: true - -permissions: - contents: read - jobs: - # ========================================================================= - # Stage 1: Fast Feedback (Parallel) - # ========================================================================= - lint: - name: "1.1 Lint" - uses: ./.github/workflows/_lint.yml - - docs: - name: "1.2 Docs" - uses: ./.github/workflows/_docs.yml - - quality: - name: "1.3 Quality" - uses: ./.github/workflows/_quality.yml + verify: + name: Verification + uses: ./.github/workflows/_verify.yml with: - qodana-enabled: ${{ vars.QODANA_ENABLED == 'true' }} - secrets: - QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} - - # ========================================================================= - # Stage 2: Core Build & Integration Tests - # ========================================================================= - build: - name: "2.1 Build" - needs: [lint, docs] - uses: ./.github/workflows/_build.yml - - # ========================================================================= - # Stage 3: Benchmark & Package Artifacts - # ========================================================================= - bench: - name: "3.1 Bench" - needs: [build] - uses: ./.github/workflows/_bench.yml - - package: - name: "3.2 Package" - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [build] - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up JDK 21 - uses: ./.github/actions/setup-jdk-21 - - - name: Package JAR - run: ./mvnw clean package -DskipTests -B - - - name: Upload ResiCache Artifact - uses: actions/upload-artifact@v7 - with: - name: resicache-core-jar - path: target/ResiCache-*.jar - retention-days: 7 - - # ========================================================================= - # Stage 4: Canonical Status Check Gate (Single Branch Protection Anchor) - # ========================================================================= + allow-docs-only: false ci-ok: - name: "ci-ok" + name: ci-ok + needs: verify + if: always() runs-on: ubuntu-latest timeout-minutes: 5 - needs: [lint, docs, quality, build, bench, package] - if: always() - steps: - - name: Checkout Scripts - uses: actions/checkout@v5 - - - name: Validate All Pipeline Results - env: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - env: RESULTS: ${{ toJSON(needs) }} - run: bash ./scripts/ci/require-all-green.sh + EXPECTED_JOBS: verify + run: bash scripts/ci/require-all-green.sh diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml new file mode 100644 index 00000000..f76e6623 --- /dev/null +++ b/.github/workflows/maintenance.yml @@ -0,0 +1,34 @@ +name: Weekly Verification and Security +on: + schedule: + - cron: '23 9 * * 1' + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + name: Verification + uses: ./.github/workflows/_verify.yml + codeql: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: ./.github/actions/setup-jdk-21 + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 + with: + languages: java-kotlin + build-mode: manual + - run: ./mvnw -DskipTests package -B + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 + quality: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/_quality.yml + with: + qodana-enabled: ${{ vars.QODANA_ENABLED == 'true' }} + secrets: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 07f451b8..b8b31886 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,125 +1,30 @@ name: PR Pipeline - on: pull_request: - types: [opened, synchronize, reopened] - branches: [main, master] - -concurrency: - group: pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - + types: [opened, synchronize, reopened, ready_for_review] + branches: [main] + merge_group: + types: [checks_requested] permissions: contents: read - +concurrency: + group: pr-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: - # ========================================================================= - # Change Detection - # ========================================================================= - changes: - name: "Detect Changes" - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - product: ${{ steps.filter.outputs.product }} - - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Evaluate Changed Files - id: filter - run: | - target="origin/${{ github.base_ref }}" - echo "Comparing HEAD against $target" - changed=$(git diff --name-only "$target"...HEAD || true) - echo "Changed files:" - echo "$changed" - - if echo "$changed" | grep -qE '^(src/|resicache-bench/|pom\.xml|\.github/|mvnw)'; then - echo "Product code or build configuration changed." - echo "product=true" >> "$GITHUB_OUTPUT" - else - echo "Only non-product files (docs/markdown) changed." - echo "product=false" >> "$GITHUB_OUTPUT" - fi - - # ========================================================================= - # Stage 1: Fast Feedback (Lint, Docs, Quality) - # ========================================================================= - lint: - name: "1.1 Lint" - uses: ./.github/workflows/_lint.yml - - docs: - name: "1.2 Docs" - uses: ./.github/workflows/_docs.yml - - quality: - name: "1.3 Quality" - uses: ./.github/workflows/_quality.yml + verify: + name: Verification + uses: ./.github/workflows/_verify.yml with: - qodana-enabled: ${{ vars.QODANA_ENABLED == 'true' }} - secrets: - QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} - - # ========================================================================= - # Stage 2: Core Build & Integration Tests - # ========================================================================= - build: - name: "2.1 Build" - needs: [lint, docs] - uses: ./.github/workflows/_build.yml - - # ========================================================================= - # Stage 3: Benchmark & Packaging - # ========================================================================= - bench: - name: "3.1 Bench" - needs: [build] - uses: ./.github/workflows/_bench.yml - - package: - name: "3.2 Package" - runs-on: ubuntu-latest - timeout-minutes: 10 - needs: [changes, build] - if: needs.changes.outputs.product == 'true' - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up JDK 21 - uses: ./.github/actions/setup-jdk-21 - - - name: Package JAR - run: ./mvnw clean package -DskipTests -B - - - name: Upload ResiCache Artifact - uses: actions/upload-artifact@v7 - with: - name: pr-resicache-jar - path: target/ResiCache-*.jar - retention-days: 5 - - # ========================================================================= - # Stage 4: Canonical Status Check Gate (Single Branch Protection Anchor) - # ========================================================================= + allow-docs-only: true ci-ok: - name: "ci-ok" + name: ci-ok + needs: verify + if: always() runs-on: ubuntu-latest timeout-minutes: 5 - needs: [changes, lint, docs, quality, build, bench, package] - if: always() - steps: - - name: Checkout Scripts - uses: actions/checkout@v5 - - - name: Validate All Pipeline Results - env: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - env: RESULTS: ${{ toJSON(needs) }} - run: bash ./scripts/ci/require-all-green.sh + EXPECTED_JOBS: verify + run: bash scripts/ci/require-all-green.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f402eeb0..b404762d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,105 +1,170 @@ name: Release Pipeline - on: push: - tags: - - 'v*' - + tags: ['v*'] + workflow_dispatch: + inputs: + candidate-run-id: + description: Original tag run containing the verified candidate and publication record + type: string + required: true + deployment-id: + description: Saved Central deployment ID; recovery never uploads again + type: string + required: true permissions: contents: read - +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - # ========================================================================= - # Preflight: SemVer Tag Validation - # ========================================================================= preflight: - name: "Preflight SemVer Check" runs-on: ubuntu-latest timeout-minutes: 5 outputs: - version: ${{ steps.version.outputs.VERSION }} - + version: ${{ steps.version.outputs.version }} steps: - - name: Validate Tag Format - id: version + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + - id: version + env: + EVENT: ${{ github.event_name }} + REF_TYPE: ${{ github.ref_type }} + TAG: ${{ github.ref_name }} + RUN_ID: ${{ inputs.candidate-run-id }} + DEPLOYMENT_ID: ${{ inputs.deployment-id }} run: | - tag="${GITHUB_REF#refs/tags/}" - echo "Validating release tag: $tag" - if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then - echo "::error::Tag '$tag' does not conform to SemVer (vX.Y.Z[-prerelease])." - exit 1 + [[ "$REF_TYPE" == tag ]] || { echo 'Run release recovery on the original tag ref' >&2; exit 1; } + if [[ "$EVENT" == workflow_dispatch ]]; then + [[ "$RUN_ID" =~ ^[0-9]+$ && "$DEPLOYMENT_ID" =~ ^[0-9a-fA-F-]{36}$ ]] + version=$(python3 scripts/ci/pipeline.py preflight "$TAG" --recovery) + else + version=$(python3 scripts/ci/pipeline.py preflight "$TAG") fi - raw_version="${tag#v}" - echo "Parsed version: $raw_version" - echo "VERSION=$raw_version" >> "$GITHUB_OUTPUT" - - # ========================================================================= - # Quality & Build Verification (Reusable Workflows) - # ========================================================================= - lint: - name: "1.1 Lint" - needs: [preflight] - uses: ./.github/workflows/_lint.yml - - docs: - name: "1.2 Docs" - needs: [preflight] - uses: ./.github/workflows/_docs.yml - - build: - name: "2.1 Build" - needs: [lint, docs] - uses: ./.github/workflows/_build.yml - - # ========================================================================= - # Deploy & Release - # ========================================================================= - deploy: - name: "Deploy to Maven Central" + echo "version=$version" >> "$GITHUB_OUTPUT" + verify: + name: Verification + needs: preflight + if: github.event_name == 'push' + uses: ./.github/workflows/_verify.yml + publish: + needs: [preflight, verify] + if: >- + always() && needs.preflight.result == 'success' && + (needs.verify.result == 'success' || + (github.event_name == 'workflow_dispatch' && needs.verify.result == 'skipped')) runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [preflight, lint, docs, build] + timeout-minutes: 45 environment: name: maven-central - url: https://s01.oss.sonatype.org/ + url: https://central.sonatype.com/publishing permissions: - contents: write - + contents: read + actions: read steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up JDK 21 with Central Deploy Credentials - uses: actions/setup-java@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Download candidate + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: core-candidate + path: candidate + run-id: ${{ inputs.candidate-run-id || github.run_id }} + github-token: ${{ github.token }} + - name: Verify candidate identity + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: python3 scripts/ci/pipeline.py verify-candidate candidate --sha "$GITHUB_SHA" --version "$RELEASE_VERSION" + - name: Download original publication for recovery + if: github.event_name == 'workflow_dispatch' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: - java-version: '21' - distribution: temurin - cache: maven - server-id: central - server-username: OSSRH_USERNAME - server-password: OSSRH_PASSWORD - gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} - gpg-passphrase: GPG_PASSPHRASE - - - name: Update POM Version - run: ./mvnw versions:set -DnewVersion=${{ needs.preflight.outputs.version }} -B - - - name: Deploy to Maven Central - run: ./mvnw deploy -B -DskipTests=false + name: release-publication + path: publication + run-id: ${{ inputs.candidate-run-id }} + github-token: ${{ github.token }} + - name: Verify original run + if: github.event_name == 'workflow_dispatch' env: - MAVEN_OPTS: '-Xmx1024m -XX:+UseParallelGC' - OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} - OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + GH_TOKEN: ${{ github.token }} + CANDIDATE_RUN_ID: ${{ inputs.candidate-run-id }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: python3 scripts/ci/check-recovery.py + - name: Sign exact verified bytes and create Central bundle + if: github.event_name == 'push' + run: python3 scripts/ci/central.py bundle + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + GPG_FINGERPRINT: ${{ vars.GPG_FINGERPRINT }} + - name: Upload once + if: github.event_name == 'push' + run: python3 scripts/ci/central.py upload + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + - name: Confirm Central publication + env: + EVENT: ${{ github.event_name }} + DEPLOYMENT_ID: ${{ inputs.deployment-id }} + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + run: | + if [[ "$EVENT" == workflow_dispatch ]]; then + python3 scripts/ci/central.py recover --deployment-id "$DEPLOYMENT_ID" + else + python3 scripts/ci/central.py poll + fi + python3 scripts/ci/central.py verify-published + python3 - <<'PY' + import hashlib + from pathlib import Path + root = Path('publication') + files = sorted(p for p in root.iterdir() if p.name != 'SHA256SUMS') + (root / 'SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest() + ' ' + p.name + '\n' for p in files)) + PY + - name: Save publication record even on failure + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: release-publication + path: publication/ + retention-days: 90 + github-release: + needs: [preflight, publish] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: - body: | - ## ResiCache ${{ needs.preflight.outputs.version }} - - See [Commits](${{ github.server_url }}/${{ github.repository }}/commits/${{ github.ref_name }}) for detailed changes. - draft: false - prerelease: ${{ contains(needs.preflight.outputs.version, 'alpha') || contains(needs.preflight.outputs.version, 'beta') || contains(needs.preflight.outputs.version, 'rc') }} + name: release-publication + path: publication + - name: Create or finish GitHub Release after PUBLISHED env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + python3 - <<'PY' + import json, os + from pathlib import Path + status = json.loads(Path('publication/central-status.json').read_text()) + manifest = json.loads(Path('publication/manifest.json').read_text()) + assert status['deploymentState'] == 'PUBLISHED' + assert manifest['sha'] == os.environ['GITHUB_SHA'] + assert manifest['version'] == os.environ['RELEASE_VERSION'] + changelog = Path('CHANGELOG.md').read_text() + notes = changelog.split('## [' + manifest['version'] + ']', 1)[1].split('\n## [', 1)[0] + Path('release-notes.md').write_text('Version: ' + manifest['version'] + '\nCommit: ' + manifest['sha'] + '\nCentral deployment: ' + status['deploymentId'] + '\n\n' + notes) + PY + prerelease=() + if [[ "$RELEASE_VERSION" == *-* ]]; then prerelease=(--prerelease); fi + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" publication/* --clobber --repo "$GITHUB_REPOSITORY" + else + gh release create "$GITHUB_REF_NAME" publication/* --verify-tag \ + --title "ResiCache $RELEASE_VERSION" --notes-file release-notes.md \ + "${prerelease[@]}" --repo "$GITHUB_REPOSITORY" + fi diff --git a/.gitignore b/.gitignore index fde9c84c..ddc4f847 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ target/ build/ dependency-reduced-pom.xml *.class +__pycache__/ +*.pyc ### Maven wrapper jar(二进制, 用 .mvn/wrapper 配置即可) ### .mvn/wrapper/maven-wrapper.jar diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bf60f7..97cd937e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,16 @@ contract. ## [Unreleased] — current development +### CI/CD + +- Share verification across PR/main/merge-group/manual/release workflows; fail + closed on malformed job results or missing/skipped integration evidence. +- Reuse verified artifacts for packaged Boot consumers and benchmark smoke; + add real Sentinel and TLS client smoke tests without changing runtime APIs. +- Add pinned workflow tooling, dependency differential checks and weekly security + reports; prepare tag/POM/Changelog-validated Central publishing and recovery + using the exact signed candidate bytes. + The project is on a **single build line**: Spring Boot 4.0 / SDR 4.0 / Spring 7 / Java 21 / Redisson 3.50.0. Dual-branch (`master` / `boot4`) is abandoned; the current build line is `main` and Boot diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b485d45c..be3aafe3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,7 @@ At minimum, run the checks relevant to the change: ./mvnw checkstyle:check -B bash scripts/ci/check-test-names.sh bash scripts/ci/check-docs-contracts.sh +bash scripts/ci/check-workflows.sh ``` The unit profile is a no-Docker path and excludes `**/*IntegrationTest*.java`; diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index acfac4b4..d9876702 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -8,7 +8,9 @@ which command proves which boundary. - JDK 21, matching `pom.xml` and the Maven Enforcer range. - Maven 3.x or the bundled `./mvnw` wrapper. -- Docker for Redis/Testcontainers integration and cluster tests. +- Docker for Redis/Testcontainers integration, Cluster, Sentinel and TLS tests. +- OpenSSL and JDK keytool for temporary TLS certificates; Python 3.12+ and GPG + for CI contract checks. Linux x86-64 is the CI runner platform. - A working local Maven cache; no committed credentials are required. The root build is the core library. `resicache-bench/` is a separate JMH module @@ -26,7 +28,9 @@ by the change type. | `./mvnw checkstyle:check -B` | explicit Checkstyle gate; it is separate from `verify` | | `bash scripts/ci/check-test-names.sh` | all executable container tests use `*IntegrationTest.java` | | `bash scripts/ci/check-docs-contracts.sh` | stale contract strings, removed Javadoc references, and required docs guards | -| `bash scripts/ci/check-external-consumer.sh` | packaged-JAR public contract; no Spring context or Redis claim | +| `bash scripts/ci/check-external-consumer.sh` | isolated packaged-JAR consumer, Boot discovery, Redis read/write, optional Redisson and observability paths | +| `bash scripts/ci/check-workflows.sh` | actionlint, ShellCheck, SHA pins and CI/release contract regression tests | +| `python3 scripts/ci/pipeline.py reports` | full test execution evidence; rejects skipped/missing integration tests | | `./mvnw clean package -DskipTests -B` | packaged artifact without test execution | | `./mvnw javadoc:javadoc -B` | Javadoc source consistency when public API docs change | @@ -47,9 +51,17 @@ failures separately from test failures. - Classes containing container markers must use the `*IntegrationTest.java` suffix, with only the explicit helper allowlist exempted by the naming script. - **Public-surface tests** compare the compiled package against the allowlists. -- **External-consumer tests** compile against the packaged JAR and declared - compile dependencies only. They prove importability and value-path protocol, - not a real Redis deployment. +- **External-consumer tests** resolve the packaged POM in a separate Maven + project, excluding project test/provided dependencies and optional dependencies + unless explicitly selected. They run the public value protocol plus a real + Boot application against Redis in minimal, Redisson and observability modes. + Use `bash scripts/ci/check-external-consumer.sh ` to reuse + an existing verified candidate; without a candidate, the script packages it. + `JAVA_HOME` supplies JDK 21 (`RESICACHE_JDK21` is an optional local override). + `CONSUMER_REDIS_PORT` can point to an existing localhost Redis instead of Docker. +- **Topology smoke tests** prove Sentinel master discovery and data/lock access, + and TLS trusted/untrusted certificate behavior in both clients. They do not + establish Sentinel failover availability or latency SLOs. Tests mirror the source package under `src/test/java`. Integration fixtures and application test resources are part of the test contract; do not change a test @@ -78,10 +90,28 @@ follow [`CONTRIBUTING.md`](../CONTRIBUTING.md)'s PR checklist. ## CI shape -Pushes to `main`/`master` and pull requests run lint, docs consistency, quality, -core build, benchmark, and packaging jobs. The benchmark job builds the JMH -fat JAR and runs a bounded v2 storage round-trip smoke test; it does not enforce -throughput or latency SLOs. The docs job is a required input to -the build job. Product packaging is conditional in the PR pipeline, but the -core build and docs gates still run for documentation changes. Release tags -use the separate release workflow described in [`OPERATIONS.md`](OPERATIONS.md). +PR, main push, merge-group, manual verification, weekly verification and release +share `_verify.yml`. Only a PR whose changed paths are all explicitly recognized +documentation can skip lint, unit, full build, consumer, benchmark and dependency +jobs. Wrapper configuration, CI scripts, unknown paths and classification errors +never downgrade verification. Docs and workflow checks always run. + +Fast checks and full verification run concurrently. The full build uses Ryuk, +executes every integration test, enforces 70% line / 40% branch coverage and +creates one `core-candidate` artifact (JAR, sources, Javadoc, POM, checksums and +commit/run manifest). Consumers and benchmarks download this artifact instead of +rebuilding the core. The JMH storage round-trip is a bounded protocol smoke, +not a performance SLO. `ci-ok` is the stable PR/main protection check; it fails +on missing, malformed, failed, cancelled or unexpectedly skipped jobs. + +Dependency Review rejects newly introduced High/Critical vulnerabilities on +PRs. Maven-resolved core and benchmark dependency trees are also scanned against +OSV and compared with the PR merge base, so transitive-dependency evidence does +not depend on privileged snapshot submission from forks. New advisories with +unknown severity require review. Resolver/scanner failures fail the check; +existing advisories are reported without silently treating them as fixed. +Weekly verification, CodeQL and optional Qodana keep existing risk visible. +Dependabot opens bounded weekly Maven/Actions update PRs; Actions are SHA-pinned. +Reports and candidate artifacts identify their commit and run; reports are not +compatibility guarantees. Release credentials are used only after verification, +as described in [`OPERATIONS.md`](OPERATIONS.md). diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 2c2ab8c0..b0942d7e 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -94,17 +94,57 @@ rollback strategy and is not required by the documented migration flow. ## Release and publication boundary -The root POM is versioned independently from the benchmark POM. A `vX.Y.Z` tag -enters the release workflow, which validates SemVer, runs lint/docs/build gates, -updates the POM version in the workflow workspace, deploys with repository -credentials, and creates a GitHub release. Release credentials are configured -out of band; contributors must not add secrets to `release.yml`. - -The current Boot 4 / Java 21 line is source-first and has no matching Maven -Central artifact in the repository's compatibility evidence. A local -`./mvnw install` is a consumer-development step, not a publication or release -claim. Same-line publication/signing and adopter evidence remain deferred in -the local task ledger. +The root POM and benchmark artifact are independently versioned; the benchmark's +`resicache.version` tracks the core. Update the core version, benchmark reference +and versioned Changelog entry through a PR before pushing `vX.Y.Z[-prerelease]`. +Tag and POM must match, the tagged commit must belong to main history, and Maven +Central coordinates must be unused. Numeric version/prerelease identifiers cannot +have leading zeroes; build metadata (`+...`) is not part of this project's tag +format. Any prerelease suffix produces a prerelease GitHub Release. The current +`0.0.2` coordinates already identify the old Boot 3 / Java 17 artifact and cannot +be reused for this build line. + +The release workflow runs the shared full verification, packaged consumers and +benchmark smoke, then signs the exact verified candidate bytes in an isolated +GPG home. It uploads a Maven-layout bundle through the Central Portal Publisher +API with automatic publication and polls for at most 30 minutes. Only PUBLISHED +permits GitHub Release creation; validation, timeout or unknown states fail. +Public repository bytes must also match the candidate checksums before Release +creation. The Release includes artifacts, signatures, checksums, the commit/run manifest +and Central publication record. An upload timeout is not automatically retried: +the server may already have accepted it. + +The `maven-central` environment is restricted to version tags. Configure +`CENTRAL_USERNAME` / `CENTRAL_PASSWORD` using a Portal user token, +`GPG_PRIVATE_KEY` / `GPG_PASSPHRASE` as secrets and `GPG_FINGERPRINT` as an +environment variable. Credentials and signing-key material never enter candidate +or publication artifacts. Old OSSRH credentials must not be assumed valid. +Local Maven publication uses the explicit `release` profile: signing binds to +verify, and the Central plugin automatically publishes and waits for completion. +Ordinary builds do not load release/signing plugins. + +To recover a failed publication/Release run, dispatch `release.yml` **on the +original tag ref**, with `candidate-run-id` pointing to the original tag run +and its saved `deployment-id`. The original Verification gate must have passed; +the candidate commit, version, run and publication record must match. Recovery +never uploads again: it polls the original deployment and creates/completes the +GitHub Release only after PUBLISHED. Download the `release-publication` artifact +for `publication.json`; it is retained even on failure for 90 days, while the +raw candidate is retained for 30 days. Preserve both externally if longer +recovery is needed. If an upload response was lost before an ID was recorded, +locate the uniquely named deployment in the Portal; do not blindly rerun upload. + +Main requires PRs and the GitHub Actions `ci-ok` check against an up-to-date +branch, and disallows force pushes/deletion. The single-maintainer configuration +does not require another person's approval. Secret scanning and push protection +remain enabled; Dependabot alerts and security updates complement CI scans. +Workflow files describe intended checks; remote branch/environment settings +must also be verified when configuring the repository. + +The Boot 4 / Java 21 line remains source-first until a matching Central artifact +is actually published and verified. A local install, candidate bundle or green +CI run is not a publication claim. Public publication is triggered separately +by a maintainer's new version tag after configuring namespace/signing access. ## Backup, restore, and hosted-service limits diff --git a/pom.xml b/pom.xml index 69005292..2bc0ab3f 100644 --- a/pom.xml +++ b/pom.xml @@ -239,16 +239,6 @@ - - org.sonatype.central - central-publishing-maven-plugin - 0.8.0 - true - - central - - - org.apache.maven.plugins maven-source-plugin @@ -282,21 +272,6 @@ - - org.apache.maven.plugins - maven-gpg-plugin - 3.1.0 - - - sign-artifacts - deploy - - sign - - - - - org.jacoco jacoco-maven-plugin @@ -367,6 +342,40 @@ + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.1.0 + + + sign-artifacts + verify + + sign + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 + true + + central + true + published + + + + + + unit diff --git a/resicache-bench/pom.xml b/resicache-bench/pom.xml index 12a45b19..9fd50ef2 100644 --- a/resicache-bench/pom.xml +++ b/resicache-bench/pom.xml @@ -23,6 +23,7 @@ ${java.version} ${java.version} UTF-8 + 0.0.2 1.37 4.0.0 3.50.0 @@ -37,7 +38,7 @@ io.github.davidhlp ResiCache - 0.0.2 + ${resicache.version} diff --git a/scripts/ci/central.py b/scripts/ci/central.py new file mode 100644 index 00000000..8593cf95 --- /dev/null +++ b/scripts/ci/central.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Publish an already verified candidate; uploads are never blindly retried.""" +import argparse +import base64 +import hashlib +import json +import os +import re +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile + +from pipeline import require, verify_candidate + +BASE = "https://central.sonatype.com" + + +def token(): + return base64.b64encode((os.environ["CENTRAL_USERNAME"] + ":" + os.environ["CENTRAL_PASSWORD"]).encode()).decode() + + +def request(path, body=b"", content_type="application/json", base=BASE): + req = urllib.request.Request(base + path, data=body, headers={ + "Authorization": "Bearer " + token(), "Content-Type": content_type}, method="POST") + with urllib.request.urlopen(req, timeout=60) as response: + return response.read() + + +def bundle(candidate, output, key_fingerprint): + manifest = verify_candidate(candidate) + require(not output.exists(), "Publication directory already exists") + output.mkdir(parents=True) + # GPG homes are ephemeral and never included in artifacts. + with tempfile.TemporaryDirectory() as gnupg: + env = os.environ.copy() + env["GNUPGHOME"] = gnupg + subprocess.run(["gpg", "--batch", "--import"], input=env["GPG_PRIVATE_KEY"].encode(), + env=env, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + keys = subprocess.check_output(["gpg", "--batch", "--with-colons", "--list-secret-keys"], env=env, text=True) + require(key_fingerprint in [line.split(":")[9] for line in keys.splitlines() if line.startswith("fpr:")], "Signing fingerprint mismatch") + for name in manifest["files"]: + shutil.copyfile(candidate / name, output / name) + subprocess.run(["gpg", "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "0", + "--local-user", key_fingerprint, "--armor", "--detach-sign", str(output / name)], + input=env["GPG_PASSPHRASE"].encode(), env=env, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["gpg", "--batch", "--verify", str(output / (name + ".asc")), str(output / name)], + env=env, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["gpgconf", "--kill", "gpg-agent"], env=env, check=True) + for path in list(output.iterdir()): + for algorithm in ("md5", "sha1", "sha256", "sha512"): + (output / (path.name + "." + algorithm)).write_text(hashlib.new(algorithm, path.read_bytes()).hexdigest()) + prefix = f"{manifest['group'].replace('.', '/')}/{manifest['artifact']}/{manifest['version']}/" + with zipfile.ZipFile(output / "central-bundle.zip", "w", zipfile.ZIP_DEFLATED) as archive: + for path in sorted(output.iterdir()): + if path.suffix != ".zip": + archive.write(path, prefix + path.name) + shutil.copyfile(candidate / "manifest.json", output / "manifest.json") + record = {"sha": manifest["sha"], "version": manifest["version"], "run_id": manifest["run_id"], + "manifest_sha256": hashlib.sha256((candidate / "manifest.json").read_bytes()).hexdigest(), + "signing_fingerprint": key_fingerprint, + "deployment_name": f"ResiCache-{manifest['version']}-{manifest['sha']}", + "purl": f"pkg:maven/{manifest['group']}/{manifest['artifact']}@{manifest['version']}"} + (output / "publication.json").write_text(json.dumps(record, indent=2) + "\n") + return record + + +def upload(output, base=BASE): + record_path = output / "publication.json" + record = json.loads(record_path.read_text()) + require(not record.get("deployment_id"), "This candidate has already been uploaded") + boundary = "ResiCacheCentralBundle" + data = (f'--{boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="central-bundle.zip"\r\n' + 'Content-Type: application/octet-stream\r\n\r\n').encode() + data += (output / "central-bundle.zip").read_bytes() + f"\r\n--{boundary}--\r\n".encode() + # A timeout may follow a successful remote upload: do not repeat this POST. + deployment_id = request("/api/v1/publisher/upload?" + urllib.parse.urlencode({ + "name": record["deployment_name"], "publishingType": "AUTOMATIC"}), data, + f"multipart/form-data; boundary={boundary}", base).decode().strip() + require(re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", deployment_id), "Invalid deployment ID") + record["deployment_id"] = deployment_id + record_path.write_text(json.dumps(record, indent=2) + "\n") + return record + + +def poll(record, base=BASE, timeout=1800, interval=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + # Status is read-only despite using POST. Transient errors are bounded by the deadline. + try: + status = json.loads(request("/api/v1/publisher/status?" + urllib.parse.urlencode({"id": record["deployment_id"]}), base=base)) + except urllib.error.HTTPError as error: + require(error.code == 429 or error.code >= 500, f"Central status request failed: {error.code}") + time.sleep(interval) + continue + except (urllib.error.URLError, TimeoutError): + time.sleep(interval) + continue + require(status.get("deploymentId") == record["deployment_id"], "Deployment identity mismatch") + require(status.get("deploymentName") == record["deployment_name"], "Deployment name mismatch") + state = status.get("deploymentState") + print("Central state:", state, flush=True) + if state == "PUBLISHED": + require(record["purl"] in status.get("purls", []), "Published coordinates mismatch") + return status + require(state in ("PENDING", "VALIDATING", "VALIDATED", "PUBLISHING"), f"Central did not publish: {state}") + time.sleep(interval) + raise ValueError("Central publication timed out; resume using the saved deployment ID") + + +def recovery(candidate, output, deployment_id): + manifest = verify_candidate(candidate) + record = json.loads((output / "publication.json").read_text()) + require(record.get("deployment_id", deployment_id) == deployment_id, "Recovery deployment ID mismatch") + require(re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", deployment_id), "Invalid recovery deployment ID") + record["deployment_id"] = deployment_id + require(record["sha"] == manifest["sha"] and record["version"] == manifest["version"] + and record["run_id"] == manifest["run_id"], "Recovery candidate mismatch") + require(record["manifest_sha256"] == hashlib.sha256((candidate / "manifest.json").read_bytes()).hexdigest(), "Recovery manifest mismatch") + require(record["purl"] == f"pkg:maven/{manifest['group']}/{manifest['artifact']}@{manifest['version']}", "Recovery coordinates mismatch") + require(record["deployment_name"] == f"ResiCache-{manifest['version']}-{manifest['sha']}", "Recovery deployment name mismatch") + for name, expected in manifest["files"].items(): + require(hashlib.sha256((output / name).read_bytes()).hexdigest() == expected, "Recovery artifact mismatch") + require((output / (name + ".asc")).is_file(), "Missing recovered signature") + signature = output / (name + ".asc") + require(hashlib.sha256(signature.read_bytes()).hexdigest() == + (output / (name + ".asc.sha256")).read_text(), "Recovered signature checksum mismatch") + return record + + +def verify_published(candidate, base="https://repo.maven.apache.org/maven2", timeout=300): + manifest = verify_candidate(candidate) + prefix = f"{manifest['group'].replace('.', '/')}/{manifest['artifact']}/{manifest['version']}" + deadline = time.monotonic() + timeout + for name, expected in manifest["files"].items(): + while True: + try: + with urllib.request.urlopen(f"{base}/{prefix}/{name}", timeout=30) as response: + value = hashlib.sha256(response.read()).hexdigest() + require(value == expected, f"Published bytes differ from verified candidate: {name}") + break + except urllib.error.HTTPError as error: + require(error.code in (404, 429) or error.code >= 500, f"Published artifact check failed: {error.code}") + require(time.monotonic() < deadline, "Published artifacts not yet resolvable; resume later") + time.sleep(10) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("bundle", "upload", "poll", "recover", "verify-published")) + parser.add_argument("--candidate", type=Path, default=Path("candidate")) + parser.add_argument("--output", type=Path, default=Path("publication")) + parser.add_argument("--deployment-id") + args = parser.parse_args() + if args.command == "bundle": + bundle(args.candidate, args.output, os.environ["GPG_FINGERPRINT"]) + elif args.command == "upload": + record = upload(args.output) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write("deployment_id=" + record["deployment_id"] + "\n") + elif args.command == "verify-published": + verify_published(args.candidate) + else: + record = recovery(args.candidate, args.output, args.deployment_id) if args.command == "recover" else json.loads((args.output / "publication.json").read_text()) + status = poll(record) + (args.output / "publication.json").write_text(json.dumps(record, indent=2) + "\n") + (args.output / "central-status.json").write_text(json.dumps(status, indent=2) + "\n") + + +if __name__ == "__main__": + try: + main() + except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error: + # Do not print HTTP bodies, signing subprocess diagnostics or credentials. + print(f"::error::Publication failed ({type(error).__name__}): {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/ci/check-action-pins.py b/scripts/ci/check-action-pins.py new file mode 100644 index 00000000..cb913ad9 --- /dev/null +++ b/scripts/ci/check-action-pins.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + +for path in list(Path('.github/workflows').glob('*.yml')) + list(Path('.github/actions').rglob('action.yml')): + for reference in re.findall(r'uses:\s*[\x22\x27]?([^\s\x22\x27]+)', path.read_text()): + if not reference.startswith('./') and not re.fullmatch(r'[^@]+@[0-9a-f]{40}', reference): + raise ValueError(f'{path}: action is not SHA-pinned: {reference}') +print('All external Actions are SHA-pinned') + +verification = Path('.github/workflows/_verify.yml').read_text().split('jobs:\n', 1)[1] +jobs = set(re.findall(r'^ ([\w-]+):$', verification, re.M)) - {'verification-ok'} +gate = verification.split(' verification-ok:', 1)[1] +needs = set(re.search(r'needs: \[([^]]+)\]', gate)[1].replace(',', ' ').split()) +expected = set(re.search(r'EXPECTED_JOBS: ([^\n]+)', gate)[1].split()) +if jobs != needs or jobs != expected: + raise ValueError('Every verification job must be included in the blocking gate') diff --git a/scripts/ci/check-external-consumer.sh b/scripts/ci/check-external-consumer.sh index 5eba4445..a8fedea5 100755 --- a/scripts/ci/check-external-consumer.sh +++ b/scripts/ci/check-external-consumer.sh @@ -1,141 +1,54 @@ #!/usr/bin/env bash -# RM-010 external consumer gate — prove the public contract from the PACKAGED JAR. -# -# 1. ./mvnw -DskipTests package -B (rebuilds target/ResiCache-.jar) -# 2. Compiles a minimal consumer in an ISOLATED temp directory using ONLY the -# packaged JAR + declared compile-scope dependencies (no target/classes, -# no test-classes, no source paths, no same-package access). -# 3. Runs the consumer's pure-value protocol demo (no Spring context, no Redis — -# compile-plus-value-path proof only; real Redis behavior is NOT claimed here). +# Resolve the packaged POM as an ordinary consumer; no project/test classpath. set -euo pipefail -JH="${RESICACHE_JDK21:-$HOME/.local/share/mise/installs/java/temurin-21.0.12+101.0.LTS}" -export JAVA_HOME="$JH" -MVNW="./mvnw" - -echo "== 1) packaging ==" -"$MVNW" -q -DskipTests package -B -JAR=$(ls target/ResiCache-*.jar | grep -v -- '-sources' | grep -v -- '-javadoc' | head -1) -echo "JAR: $JAR" -echo "JAR sha256: $(sha256sum "$JAR")" - -echo "== 2) declared compile classpath (dependency:build-classpath) ==" -CP_FILE=$(mktemp) -"$MVNW" -q dependency:build-classpath -Dmdep.outputFile="$CP_FILE" -B -DECLARED_CP=$(cat "$CP_FILE") - -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT -mkdir -p "$TMP/src/com/example/consumer" - -cat > "$TMP/src/com/example/consumer/ExternalConsumerDemo.java" <<'EOF' -package com.example.consumer; - -import io.github.davidhlp.spring.cache.redis.annotation.RedisCacheable; -import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; -import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; -import io.github.davidhlp.spring.cache.redis.chain.ChainContinuation; -import io.github.davidhlp.spring.cache.redis.chain.CacheResult; -import io.github.davidhlp.spring.cache.redis.chain.FlowControl; -import io.github.davidhlp.spring.cache.redis.chain.HandlerOrder; -import io.github.davidhlp.spring.cache.redis.chain.HandlerPriority; -import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; -import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; -import io.github.davidhlp.spring.cache.redis.chain.model.CachePolicyView; -import io.github.davidhlp.spring.cache.redis.chain.observer.ChainObserver; - -/** - * External consumer sample (RM-010) — annotation surface + one supported - * extension seam, using ONLY classified public contract types. - */ -public class ExternalConsumerDemo { - - @RedisCacheable(value = "demo", key = "#id", ttl = 60) - public String load(String id) { - return "value-" + id; - } - - /** Supported extension: a custom CacheHandler. */ - @HandlerPriority(HandlerOrder.TTL) - static class DemoHandler implements CacheHandler { - @Override - public HandlerResult handle(CacheContext context) { - return HandlerResult.continueWith(CacheResult.miss()); - } - } - - /** - * Supported extension: nested advancement. The engine hands this handler a - * ChainContinuation bound to its position, so it can run the remainder of the - * chain inside its own critical section (this is what sync=true does inside - * the distributed lock) and then end the chain. - */ - static class SyncLikeHandler implements CacheHandler { - - @Override - public HandlerResult handle(CacheContext context) { - throw new AssertionError("engine must call the 2-arg form"); - } - - @Override - public HandlerResult handle(CacheContext context, ChainContinuation next) { - return HandlerResult.terminate(next.advance()); - } - } - - /** Supported extension: a custom ChainObserver with scope token. */ - static class DemoObserver implements ChainObserver { - @Override - public Object onChainStart(CacheContext context) { - return "demo-token"; - } - - @Override - public void onChainEnd(CacheContext context, Object scopeToken, CacheResult result) { - // token is the same reference returned by this observer's onChainStart - } - } - - public static void main(String[] args) { - // Value-path protocol demo (no Spring, no Redis): - CacheResult hit = CacheResult.success("bytes".getBytes()); - if (!hit.isSuccess() || hit.outcome() != CacheResult.Outcome.SUCCESS) { - throw new AssertionError("success contract broken"); - } - CacheResult failure = CacheResult.failure( - CacheOperation.PUT, CacheResult.FailureKind.REDIS, new IllegalStateException("x")); - if (failure.isSuccess() || failure.operation() != CacheOperation.PUT) { - throw new AssertionError("failure contract broken"); - } - HandlerResult r = HandlerResult.terminate(hit); - if (r.decision() != FlowControl.TERMINATE || !r.shouldTerminate()) { - throw new AssertionError("flow control contract broken"); - } - DemoHandler handler = new DemoHandler(); - DemoObserver observer = new DemoObserver(); - if (observer.onChainStart(null) != "demo-token") { - throw new AssertionError("observer token contract broken"); - } - if (handler.handle(null).decision() != FlowControl.CONTINUE) { - throw new AssertionError("handler contract broken"); - } - ChainContinuation next = () -> CacheResult.success(); - if (new SyncLikeHandler().handle(null, next).decision() != FlowControl.TERMINATE) { - throw new AssertionError("nested advancement contract broken"); - } - if (CachePolicyView.NONE.useBloomFilter()) { - throw new AssertionError("policy view default contract broken"); - } - System.out.println("EXTERNAL_CONSUMER_OK"); - } +root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$root" +if [[ -n "${RESICACHE_JDK21:-}" ]]; then + export JAVA_HOME="$RESICACHE_JDK21" +fi +if [[ ! -d "${1:-target/ci-candidate}" ]]; then + ./mvnw clean package -DskipTests -B + python3 scripts/ci/pipeline.py candidate target/ci-candidate +fi +candidate="$(realpath "${1:-target/ci-candidate}")" +python3 scripts/ci/pipeline.py verify-candidate "$candidate" +boot_version="$(python3 -c 'import xml.etree.ElementTree as E; print(E.parse("pom.xml").findtext("{*}parent/{*}version"))')" +redisson_version="$(python3 -c 'import xml.etree.ElementTree as E; print(E.parse("pom.xml").findtext("{*}properties/{*}redisson.version"))')" +version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$candidate/manifest.json")" +task_dir="$(mktemp -d)" +container_id="" +cleanup() { + if [[ -n "$container_id" ]]; then docker rm -f "$container_id" >/dev/null; fi + rm -rf "$task_dir" } -EOF - - -echo "== 3) compiling consumer against packaged JAR only ==" -mkdir -p "$TMP/classes" -"$JH/bin/javac" -cp "$JAR:$DECLARED_CP" -d "$TMP/classes" "$TMP/src/com/example/consumer/ExternalConsumerDemo.java" -echo "consumer imports (compile resolved against JAR + declared deps only): CacheHandler, ChainContinuation, ChainObserver, CacheResult(+Outcome/FailureKind), HandlerResult, FlowControl, HandlerOrder, HandlerPriority, CacheContext, CachePolicyView, @RedisCacheable" - -echo "== 4) running consumer ==" -"$JH/bin/java" -cp "$TMP/classes:$JAR:$DECLARED_CP" com.example.consumer.ExternalConsumerDemo -echo "RM-010 PASS: external consumer compiled from packaged JAR and ran" +trap cleanup EXIT +cp -R scripts/ci/consumer/. "$task_dir/" +python3 - "$task_dir/pom.xml" "$boot_version" <<'PYGEN' +from pathlib import Path +import sys +path = Path(sys.argv[1]) +path.write_text(path.read_text().replace('@BOOT_VERSION@', sys.argv[2])) +PYGEN +./mvnw org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \ + -Dfile="$candidate/ResiCache-$version.jar" -DpomFile="$candidate/ResiCache-$version.pom" -B +if [[ "${CONSUMER_REDIS_PORT:-}" == "" ]]; then + docker info >/dev/null + container_id="$(docker run -d --rm -p 127.0.0.1::6379 redis:7-alpine)" + redis_port="$(docker port "$container_id" 6379/tcp | sed 's/.*://')" +else + redis_port="$CONSUMER_REDIS_PORT" +fi +for profile in minimal redisson observability; do + ./mvnw -f "$task_dir/pom.xml" -P"$profile" -Dresicache.version="$version" -Dredisson.version="$redisson_version" \ + clean package dependency:build-classpath -Dmdep.includeScope=runtime \ + -Dmdep.outputFile="$task_dir/classpath" -B + classpath="$(cat "$task_dir/classpath")" + if [[ "$classpath" =~ junit|mockito|testcontainers|lombok ]]; then + echo 'Consumer classpath contains test/provided dependencies' >&2; exit 1 + fi + if [[ "$profile" == minimal && "$classpath" =~ redisson|actuator|micrometer-core ]]; then + echo 'Minimal consumer contains optional dependencies' >&2; exit 1 + fi + "$JAVA_HOME/bin/java" -cp "$task_dir/target/classes:$classpath" com.example.consumer.ExternalConsumerDemo + "$JAVA_HOME/bin/java" -cp "$task_dir/target/classes:$classpath" com.example.consumer.BootConsumer "$profile" "$redis_port" +done diff --git a/scripts/ci/check-recovery.py b/scripts/ci/check-recovery.py new file mode 100644 index 00000000..fab7d842 --- /dev/null +++ b/scripts/ci/check-recovery.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Bind recovery artifacts to a previously verified tag run in this repository.""" +import json +import os +from pathlib import Path +import subprocess + +from pipeline import require, verify_candidate + +run_id = os.environ["CANDIDATE_RUN_ID"] +require(run_id.isdigit(), "Candidate run ID must be numeric") +repository = os.environ["GITHUB_REPOSITORY"] +run = json.loads(subprocess.check_output(["gh", "api", f"repos/{repository}/actions/runs/{run_id}"])) +require(run["event"] == "push" and run["head_sha"] == os.environ["GITHUB_SHA"], "Recovery run is not the original tag commit") +require(run["path"] == ".github/workflows/release.yml", "Recovery run is not a release workflow") +jobs = json.loads(subprocess.check_output(["gh", "api", "--paginate", "--slurp", f"repos/{repository}/actions/runs/{run_id}/jobs?per_page=100"])) +gates = [job for page in jobs for job in page["jobs"] if job["name"].endswith("/ Verification gate")] +require(len(gates) == 1 and gates[0]["conclusion"] == "success", "Original full verification did not pass") +manifest = verify_candidate(Path("candidate"), os.environ["GITHUB_SHA"], os.environ["RELEASE_VERSION"]) +require(str(manifest["run_id"]) == run_id, "Candidate artifact belongs to another run") diff --git a/scripts/ci/check-workflows.sh b/scripts/ci/check-workflows.sh new file mode 100644 index 00000000..cca1f438 --- /dev/null +++ b/scripts/ci/check-workflows.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$root" +tools_dir="${CI_TOOLS_DIR:-$root/target/ci-tools}" +mkdir -p "$tools_dir" +python3 scripts/ci/install-tools.py "$tools_dir" +export PATH="$tools_dir:$PATH" +actionlint -color +shellcheck scripts/ci/*.sh +python3 -m unittest discover -s scripts/ci/tests -v +python3 scripts/ci/check-action-pins.py diff --git a/scripts/ci/consumer/pom.xml b/scripts/ci/consumer/pom.xml new file mode 100644 index 00000000..5ef75dd4 --- /dev/null +++ b/scripts/ci/consumer/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + @BOOT_VERSION@ + + + com.example + resicache-consumer + 1.0 + 21 + + + io.github.davidhlpResiCache + ${resicache.version} + + + + minimal + + redisson + + org.redissonredisson${redisson.version} + + + + observability + + org.springframework.bootspring-boot-starter-actuator + + + + diff --git a/scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java b/scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java new file mode 100644 index 00000000..e29bba16 --- /dev/null +++ b/scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java @@ -0,0 +1,63 @@ +package com.example.consumer; + +import io.github.davidhlp.spring.cache.redis.annotation.RedisCacheable; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Ordinary Boot application using only the installed JAR and its POM. */ +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@EnableCaching +public class BootConsumer { + @Bean + public Service service() { + return new Service(); + } + + public static class Service { + static final java.util.concurrent.atomic.AtomicInteger CALLS = new java.util.concurrent.atomic.AtomicInteger(); + + @RedisCacheable(value = "external-sync", key = "#id", sync = true) + public String load(String id) { + CALLS.incrementAndGet(); + return "value-" + id; + } + } + + public static void main(String[] args) throws Exception { + String profile = args[0]; + try (var app = new SpringApplicationBuilder(BootConsumer.class) + .web(WebApplicationType.NONE) + .run("--spring.data.redis.port=" + args[1], "--resi-cache.redis.port=" + args[1], + "--resi-cache.metrics.enabled=" + profile.equals("observability"), + "--resi-cache.protection.bloom-filter-enabled=false")) { + CacheManager manager = app.getBean(CacheManager.class); + if (!manager.getClass().getName().contains("RedisProCacheManager")) { + throw new AssertionError("Packaged Boot auto-configuration was not discovered"); + } + var cache = manager.getCache("external-" + profile); + cache.put("key", "value"); + if (!"value".equals(cache.get("key", String.class))) { + throw new AssertionError("Packaged cache read/write failed"); + } + if (profile.equals("redisson")) { + Service service = app.getBean(Service.class); + if (!service.load("1").equals(service.load("1")) || Service.CALLS.get() != 1) { + throw new AssertionError("Packaged sync cache path failed"); + } + } + if (profile.equals("observability")) { + if (!app.containsBean("redisCacheHealthIndicator") || app.getBeansOfType( + Class.forName("io.micrometer.core.instrument.MeterRegistry")).isEmpty()) { + throw new AssertionError("Optional observability did not assemble"); + } + } + System.out.println("BOOT_CONSUMER_OK " + profile); + } + } +} diff --git a/scripts/ci/consumer/src/main/java/com/example/consumer/ExternalConsumerDemo.java b/scripts/ci/consumer/src/main/java/com/example/consumer/ExternalConsumerDemo.java new file mode 100644 index 00000000..4289d3cd --- /dev/null +++ b/scripts/ci/consumer/src/main/java/com/example/consumer/ExternalConsumerDemo.java @@ -0,0 +1,100 @@ +package com.example.consumer; + +import io.github.davidhlp.spring.cache.redis.annotation.RedisCacheable; +import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; +import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; +import io.github.davidhlp.spring.cache.redis.chain.ChainContinuation; +import io.github.davidhlp.spring.cache.redis.chain.CacheResult; +import io.github.davidhlp.spring.cache.redis.chain.FlowControl; +import io.github.davidhlp.spring.cache.redis.chain.HandlerOrder; +import io.github.davidhlp.spring.cache.redis.chain.HandlerPriority; +import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; +import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; +import io.github.davidhlp.spring.cache.redis.chain.model.CachePolicyView; +import io.github.davidhlp.spring.cache.redis.chain.observer.ChainObserver; + +/** + * External consumer sample (RM-010) — annotation surface + one supported + * extension seam, using ONLY classified public contract types. + */ +public class ExternalConsumerDemo { + + @RedisCacheable(value = "demo", key = "#id", ttl = 60) + public String load(String id) { + return "value-" + id; + } + + /** Supported extension: a custom CacheHandler. */ + @HandlerPriority(HandlerOrder.TTL) + static class DemoHandler implements CacheHandler { + @Override + public HandlerResult handle(CacheContext context) { + return HandlerResult.continueWith(CacheResult.miss()); + } + } + + /** + * Supported extension: nested advancement. The engine hands this handler a + * ChainContinuation bound to its position, so it can run the remainder of the + * chain inside its own critical section (this is what sync=true does inside + * the distributed lock) and then end the chain. + */ + static class SyncLikeHandler implements CacheHandler { + + @Override + public HandlerResult handle(CacheContext context) { + throw new AssertionError("engine must call the 2-arg form"); + } + + @Override + public HandlerResult handle(CacheContext context, ChainContinuation next) { + return HandlerResult.terminate(next.advance()); + } + } + + /** Supported extension: a custom ChainObserver with scope token. */ + static class DemoObserver implements ChainObserver { + @Override + public Object onChainStart(CacheContext context) { + return "demo-token"; + } + + @Override + public void onChainEnd(CacheContext context, Object scopeToken, CacheResult result) { + // token is the same reference returned by this observer's onChainStart + } + } + + public static void main(String[] args) { + // Value-path protocol demo (no Spring, no Redis): + CacheResult hit = CacheResult.success("bytes".getBytes()); + if (!hit.isSuccess() || hit.outcome() != CacheResult.Outcome.SUCCESS) { + throw new AssertionError("success contract broken"); + } + CacheResult failure = CacheResult.failure( + CacheOperation.PUT, CacheResult.FailureKind.REDIS, new IllegalStateException("x")); + if (failure.isSuccess() || failure.operation() != CacheOperation.PUT) { + throw new AssertionError("failure contract broken"); + } + HandlerResult r = HandlerResult.terminate(hit); + if (r.decision() != FlowControl.TERMINATE || !r.shouldTerminate()) { + throw new AssertionError("flow control contract broken"); + } + DemoHandler handler = new DemoHandler(); + DemoObserver observer = new DemoObserver(); + if (observer.onChainStart(null) != "demo-token") { + throw new AssertionError("observer token contract broken"); + } + if (handler.handle(null).decision() != FlowControl.CONTINUE) { + throw new AssertionError("handler contract broken"); + } + ChainContinuation next = () -> CacheResult.success(); + if (new SyncLikeHandler().handle(null, next).decision() != FlowControl.TERMINATE) { + throw new AssertionError("nested advancement contract broken"); + } + if (CachePolicyView.NONE.useBloomFilter()) { + throw new AssertionError("policy view default contract broken"); + } + System.out.println("EXTERNAL_CONSUMER_OK"); + } +} diff --git a/scripts/ci/dependencies.py b/scripts/ci/dependencies.py new file mode 100644 index 00000000..375741d2 --- /dev/null +++ b/scripts/ci/dependencies.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Resolve both Maven graphs and compare OSV advisories without privileged PR tokens.""" +import argparse +from concurrent.futures import ThreadPoolExecutor +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import urllib.parse +import urllib.request + +from pipeline import coordinates, require, verify_candidate + +ROOT = Path(__file__).resolve().parents[2] + + +def graphs(directory, candidate, output): + # Graph resolution needs the checked-out core POM, never the old Central POM + # with the same coordinates. No code from the dummy installation is executed. + manifest = verify_candidate(candidate) + with (output / "install.log").open("w") as log: + subprocess.run([str(ROOT / "mvnw"), "org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file", "-B", + "-Dfile=" + str(candidate / f"ResiCache-{manifest['version']}.jar"), + "-DpomFile=" + str(directory / "pom.xml")], check=True, stdout=log, stderr=subprocess.STDOUT, timeout=300) + packages = set() + for label, pom in (("core", "pom.xml"), ("bench", "resicache-bench/pom.xml")): + destination = output / (label + ".json") + print(f"Resolving {label} dependency graph", flush=True) + with (output / (label + ".log")).open("w") as log: + subprocess.run([str(ROOT / "mvnw"), "-f", str(directory / pom), "-B", + "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree", + "-DoutputType=json", "-DoutputFile=" + str(destination)], check=True, stdout=log, stderr=subprocess.STDOUT, timeout=300) + graph = json.loads(destination.read_text()) + require(graph.get("children"), f"No resolved {label} dependencies") + + def visit(node): + if (node["groupId"], node["artifactId"]) != ("io.github.davidhlp", "ResiCache"): + packages.add((node["groupId"] + ":" + node["artifactId"], node["version"])) + for child in node.get("children", []): + visit(child) + + for child in graph["children"]: + visit(child) + return packages + + +def scan(packages): + ordered = sorted(packages) + query = {"queries": [{"package": {"ecosystem": "Maven", "name": name}, "version": version} + for name, version in ordered]} + request = urllib.request.Request("https://api.osv.dev/v1/querybatch", json.dumps(query).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=60) as response: + results = json.load(response)["results"] + require(len(results) == len(ordered), "Incomplete OSV batch response") + ids = {item["id"] for result in results for item in result.get("vulns", [])} + + def advisory(identifier): + with urllib.request.urlopen("https://api.osv.dev/v1/vulns/" + urllib.parse.quote(identifier, safe=""), timeout=30) as response: + return identifier, json.load(response) + + with ThreadPoolExecutor(max_workers=8) as executor: + details = dict(executor.map(advisory, sorted(ids))) + return [{"package": name, "version": version, "id": vuln["id"], + "severity": details[vuln["id"]].get("database_specific", {}).get("severity", "UNKNOWN"), + "advisory": details[vuln["id"]]} + for (name, version), result in zip(ordered, results) for vuln in result.get("vulns", [])] + + +def new_risks(head, base): + known = {(item["package"], item["id"]) for item in base} + return [item for item in head if (item["package"], item["id"]) not in known + and item["severity"] in ("HIGH", "CRITICAL", "UNKNOWN")] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default="") + parser.add_argument("--event", required=True) + parser.add_argument("--candidate", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + candidate = args.candidate.resolve() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + head_dir = output / "head" + head_dir.mkdir(exist_ok=True) + head_packages = graphs(ROOT, candidate, head_dir) + print(f"Scanning {len(head_packages)} resolved core/benchmark packages", flush=True) + head = scan(head_packages) + (head_dir / "advisories.json").write_text(json.dumps(head, indent=2) + "\n") + base = [] + if args.event == "pull_request": + require(args.base and set(args.base) != {"0"}, "Missing PR base SHA") + base_sha = subprocess.check_output(["git", "merge-base", args.base, "HEAD"], text=True).strip() + base_dir = output / "base" + base_dir.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory() as scratch: + archive = subprocess.check_output(["git", "archive", base_sha]) + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + tar.extractall(scratch, filter="data") + base = scan(graphs(Path(scratch), candidate, base_dir)) + (base_dir / "advisories.json").write_text(json.dumps(base, indent=2) + "\n") + risks = new_risks(head, base) + summary = {"resolved_packages": len(head_packages), "advisories": len(head), "new_high_or_unknown": len(risks)} + (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + print(json.dumps(summary)) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as report: + report.write(f"### Dependency evidence\n\nResolved {len(head_packages)} core/benchmark packages; " + f"{len(head)} existing advisories; {len(risks)} new high/critical/unknown advisories.\n") + require(args.event != "pull_request" or not risks, "New high/critical dependency risk (unknown severity also requires review)") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/install-tools.py b/scripts/ci/install-tools.py new file mode 100644 index 00000000..93f5cbd9 --- /dev/null +++ b/scripts/ci/install-tools.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Install checksum-pinned official Linux tools, without system configuration.""" +import hashlib +import io +from pathlib import Path +import sys +import tarfile +import urllib.request + +TOOLS = ( + ("actionlint", "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz", + "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8", "actionlint"), + ("shellcheck", "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.gz", + "b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6", "shellcheck-v0.11.0/shellcheck"), +) +output = Path(sys.argv[1]) +output.mkdir(parents=True, exist_ok=True) +for name, url, expected, member in TOOLS: + if (output / name).is_file(): + continue + with urllib.request.urlopen(url, timeout=60) as response: + data = response.read() + if hashlib.sha256(data).hexdigest() != expected: + raise ValueError("Tool archive checksum mismatch: " + name) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive: + (output / name).write_bytes(archive.extractfile(member).read()) + (output / name).chmod(0o755) diff --git a/scripts/ci/pipeline.py b/scripts/ci/pipeline.py new file mode 100644 index 00000000..d7292dfd --- /dev/null +++ b/scripts/ci/pipeline.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Dependency-free CI contracts shared by local checks and Actions.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET + +NS = {"m": "http://maven.apache.org/POM/4.0.0"} +VERSION = r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def coordinates(pom): + root = ET.parse(pom).getroot() + return tuple(root.findtext(f"m:{key}", namespaces=NS) for key in ("groupId", "artifactId", "version")) + + +def release_version(tag): + match = re.fullmatch("v" + VERSION, tag) + require(match is not None, "Expected vX.Y.Z[-prerelease], without leading zeroes") + if match[4]: + require(all(not (part.isdigit() and len(part) > 1 and part.startswith("0")) + for part in match[4].split(".")), "Numeric prerelease identifier has a leading zero") + return tag[1:] + + +def docs_only(paths): + # New/unknown paths always get full verification; deletions are included. + return bool(paths) and all(re.fullmatch( + r"(?:docs/[^\n]+\.md|(?:README(?:\.zh-CN)?|AGENTS|CHANGELOG|COMPATIBILITY|CONTRIBUTING|PERFORMANCE|SECURITY|STABILITY)\.md|LICENSE)", + path) for path in paths) + + +def gate(results, expected, allowed): + require(isinstance(results, dict), "RESULTS must be a JSON object") + require(expected and len(set(expected)) == len(expected), "Expected jobs must be nonempty and unique") + require(set(results) == set(expected), "Missing or unexpected jobs in RESULTS") + require(set(allowed) <= set(expected), "Unknown allowed skip") + for job in expected: + result = results[job] + require(isinstance(result, dict), f"Malformed result for {job}") + state = result.get("result") + require(state == "success" or (state == "skipped" and job in allowed), f"Required job {job}: {state}") + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def candidate(output): + group, artifact, version = coordinates("pom.xml") + output.mkdir(parents=True, exist_ok=False) + for suffix in (".jar", "-sources.jar", "-javadoc.jar"): + source = Path("target") / f"{artifact}-{version}{suffix}" + require(source.is_file(), f"Missing artifact: {source}") + shutil.copyfile(source, output / source.name) + shutil.copyfile("pom.xml", output / f"{artifact}-{version}.pom") + manifest = {"group": group, "artifact": artifact, "version": version, + "sha": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "repository": os.environ.get("GITHUB_REPOSITORY", "DavidHLP/ResiCache"), + "run_id": os.environ.get("GITHUB_RUN_ID", "local"), + "files": {p.name: digest(p) for p in sorted(output.iterdir())}} + (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + hashes = {p.name: digest(p) for p in sorted(output.iterdir())} + (output / "SHA256SUMS").write_text("".join(f"{value} {name}\n" for name, value in hashes.items())) + + +def verify_candidate(directory, sha=None, version=None): + manifest = json.loads((directory / "manifest.json").read_text()) + require(manifest.get("repository", "").lower() == "davidhlp/resicache", "Wrong repository") + require(sha is None or manifest["sha"] == sha, "Candidate commit mismatch") + require(version is None or manifest["version"] == version, "Candidate version mismatch") + group, artifact, ver = manifest["group"], manifest["artifact"], manifest["version"] + require((group, artifact) == ("io.github.davidhlp", "ResiCache"), "Wrong coordinates") + require(re.fullmatch(VERSION, ver) is not None, "Invalid candidate version") + expected = {f"{artifact}-{ver}{suffix}" for suffix in (".jar", "-sources.jar", "-javadoc.jar", ".pom")} + require(set(manifest["files"]) == expected, "Candidate artifact set mismatch") + for name, value in manifest["files"].items(): + require(digest(directory / name) == value, f"Candidate checksum mismatch: {name}") + require(coordinates(directory / f"{artifact}-{ver}.pom") == (group, artifact, ver), "POM coordinates mismatch") + sums = (directory / "SHA256SUMS").read_text().splitlines() + require(len(sums) == 5, "Candidate checksum inventory mismatch") + expected_sums = {f"{digest(directory / name)} {name}" for name in expected | {"manifest.json"}} + require(set(sums) == expected_sums, "Candidate checksum inventory mismatch") + return manifest + + +def report_tests(report_dir, test_root, unit): + reports = list(report_dir.glob("TEST-*.xml")) + require(reports, "No Surefire XML reports") + cases = [] + actual = set() + for report in reports: + suite = ET.parse(report).getroot() + executed = list(suite.iter("testcase")) + cases.extend(executed) + # JUnit @DisplayName can replace testcase.classname (especially @Nested). + # Surefire's suite identity is the compiled class, even when tests=0 on + # the outer suite and its nested cases appear inside the same XML. + if executed: + actual.add(suite.get("name", "").split("$")[0]) + require(cases, "No test cases executed") + require(not any(case.find("failure") is not None or case.find("error") is not None for case in cases), "Failed test cases") + require(not any(case.find("skipped") is not None for case in cases), "Skipped tests are not CI evidence") + if unit: + require(not any("IntegrationTest" in name for name in actual), "Unit profile executed integration tests") + else: + for path in test_root.rglob("*IntegrationTest.java"): + source = path.read_text() + if re.search(r"\babstract\s+class\b", source): + continue + package = re.search(r"\bpackage\s+([\w.]+)\s*;", source)[1] + require(package + "." + path.stem in actual, f"Integration test did not execute: {path}") + print(f"Verified {len(cases)} executed test cases ({'unit' if unit else 'full'})") + + +def preflight(tag, published_url=None, recovery=False): + version = release_version(tag) + group, artifact, pom_version = coordinates("pom.xml") + require(version == pom_version, "Tag/POM version mismatch; update the POM in a PR first") + bench = ET.parse("resicache-bench/pom.xml").getroot() + require(bench.findtext("m:properties/m:resicache.version", namespaces=NS) == version, "Benchmark core version mismatch") + require(re.search(r"^## \[" + re.escape(version) + r"\]", Path("CHANGELOG.md").read_text(), re.M), "Missing versioned Changelog entry") + subprocess.run(["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"], check=True) + require(subprocess.check_output(["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"], text=True).strip() + == subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), "Tag/checkout mismatch") + if not recovery: + url = published_url or f"https://repo.maven.apache.org/maven2/{group.replace('.', '/')}/{artifact}/{version}/{artifact}-{version}.pom" + try: + urllib.request.urlopen(urllib.request.Request(url, method="HEAD"), timeout=30).close() + except urllib.error.HTTPError as error: + require(error.code == 404, f"Central existence check failed: {error.code}") + else: + raise ValueError("Maven Central coordinates already exist") + return version + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("gate") + classify = sub.add_parser("classify") + classify.add_argument("base") + classify.add_argument("head") + create = sub.add_parser("candidate") + create.add_argument("directory", type=Path) + verify = sub.add_parser("verify-candidate") + verify.add_argument("directory", type=Path) + verify.add_argument("--sha") + verify.add_argument("--version") + reports = sub.add_parser("reports") + reports.add_argument("--unit", action="store_true") + reports.add_argument("--directory", type=Path, default=Path("target/surefire-reports")) + reports.add_argument("--sources", type=Path, default=Path("src/test/java")) + pre = sub.add_parser("preflight") + pre.add_argument("tag") + pre.add_argument("--recovery", action="store_true") + args = parser.parse_args() + if args.command == "gate": + gate(json.loads(os.environ.get("RESULTS", "")), os.environ.get("EXPECTED_JOBS", "").split(), + os.environ.get("ALLOWED_SKIPS", "").split()) + print("All required jobs succeeded; conditional skips were explicitly allowed") + elif args.command == "classify": + paths = subprocess.check_output(["git", "diff", "--name-only", "--no-renames", "-z", args.base + "..." + args.head]).decode().split("\0") + print("docs_only=" + str(docs_only([path for path in paths if path])).lower()) + elif args.command == "candidate": + candidate(args.directory) + elif args.command == "verify-candidate": + verify_candidate(args.directory, args.sha, args.version) + elif args.command == "reports": + report_tests(args.directory, args.sources, args.unit) + else: + print(preflight(args.tag, recovery=args.recovery)) + + +if __name__ == "__main__": + try: + main() + except (ValueError, KeyError, OSError, ET.ParseError, subprocess.CalledProcessError) as error: + print(f"::error::{error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/ci/require-all-green.sh b/scripts/ci/require-all-green.sh index 0501094e..fcda913e 100755 --- a/scripts/ci/require-all-green.sh +++ b/scripts/ci/require-all-green.sh @@ -1,67 +1,3 @@ #!/usr/bin/env bash -# ============================================================================== -# require-all-green.sh -# -# Aggregates GitHub Actions job results from the RESULTS environment variable -# (populated by `toJSON(needs)`). Validates that every job either succeeded or -# was legitimately skipped. Rejects failures and cancellations. -# ============================================================================== - set -euo pipefail - -if [[ -z "${RESULTS:-}" ]]; then - echo "::error::RESULTS environment variable is empty or not set." - exit 1 -fi - -echo "Evaluating workflow job execution results..." -echo "--------------------------------------------------" - -if ! command -v jq >/dev/null 2>&1; then - echo "::error::jq is required but not found in PATH." - exit 1 -fi - -failed=0 -total=0 - -# Parse JSON into a list of "job_name:result" lines -while IFS="=" read -r job result; do - total=$((total + 1)) - case "$result" in - "success") - echo " [OK] $job -> $result" - ;; - "skipped") - echo " [SKIPPED] $job -> $result" - ;; - "failure") - echo "::error::Job '$job' failed (result: $result)" - failed=$((failed + 1)) - ;; - "cancelled") - echo "::error::Job '$job' was cancelled (result: $result)" - failed=$((failed + 1)) - ;; - *) - echo "::error::Job '$job' has unexpected state: '$result'" - failed=$((failed + 1)) - ;; - esac -done < <(echo "$RESULTS" | jq -r 'to_entries[] | "\(.key)=\(.value.result // .value)"') - -echo "--------------------------------------------------" -echo "Total evaluated jobs: $total, Failures/Cancellations: $failed" - -if [[ $total -eq 0 ]]; then - echo "::error::No job results found in RESULTS." - exit 1 -fi - -if [[ $failed -gt 0 ]]; then - echo "::error::Pipeline failed! $failed job(s) did not complete cleanly." - exit 1 -fi - -echo "All required pipeline jobs completed successfully (or cleanly skipped)." -exit 0 +python3 "$(dirname "$0")/pipeline.py" gate diff --git a/scripts/ci/tests/test_pipeline.py b/scripts/ci/tests/test_pipeline.py new file mode 100644 index 00000000..22028ddf --- /dev/null +++ b/scripts/ci/tests/test_pipeline.py @@ -0,0 +1,243 @@ +import contextlib +import http.server +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import central +import dependencies +import pipeline + + +@contextlib.contextmanager +def workspace(): + previous = Path.cwd() + with tempfile.TemporaryDirectory() as directory: + os.chdir(directory) + try: + yield Path(directory) + finally: + os.chdir(previous) + + +def pom(version='0.0.8'): + return f'io.github.davidhlpResiCache{version}' + + +def candidate(): + Path('pom.xml').write_text(pom()) + Path('target').mkdir() + for suffix in ('.jar', '-sources.jar', '-javadoc.jar'): + Path('target/ResiCache-0.0.8' + suffix).write_bytes(b'candidate bytes') + with patch('pipeline.subprocess.check_output', return_value='a' * 40 + '\n'), patch.dict(os.environ, {'GITHUB_RUN_ID': '123', 'GITHUB_REPOSITORY': 'DavidHLP/ResiCache'}): + pipeline.candidate(Path('candidate')) + return pipeline.verify_candidate(Path('candidate')) + + +class PipelineTests(unittest.TestCase): + def test_docs_allowlist_and_full_fallback(self): + self.assertTrue(pipeline.docs_only(['README.md', 'docs/OPERATIONS.md'])) + for path in ['scripts/ci/x.sh', '.mvn/wrapper/maven-wrapper.properties', '.github/workflows/ci.yml', 'pom.xml', 'new-file', 'docs/example.java', 'src/test/A.java']: + self.assertFalse(pipeline.docs_only(['README.md', path])) + self.assertFalse(pipeline.docs_only([])) + + def test_gate_requires_exact_inventory_and_explicit_skips(self): + pipeline.gate({'build': {'result': 'success'}}, ['build'], []) + pipeline.gate({'build': {'result': 'skipped'}}, ['build'], ['build']) + for results in [{}, {'extra': {'result': 'success'}}, {'build': {'result': 'skipped'}}, {'build': {'result': 'cancelled'}}, {'build': {'result': 'failure'}}, {'build': {'result': None}}, {'build': 'success'}]: + with self.subTest(results=results), self.assertRaises(ValueError): + pipeline.gate(results, ['build'], []) + + def test_gate_does_not_accept_valid_prefix_of_invalid_json(self): + script = Path(pipeline.__file__).with_name('require-all-green.sh') + for raw in ['{"build":{"result":"success"}} trailing', '{"build":', '', '[]']: + env = os.environ | {'RESULTS': raw, 'EXPECTED_JOBS': 'build'} + self.assertNotEqual(0, subprocess.run(['bash', str(script)], env=env, capture_output=True).returncode) + + def test_release_plugins_do_not_enter_normal_build_and_sign_before_upload(self): + import xml.etree.ElementTree as ET + root = ET.parse(Path(__file__).resolve().parents[3] / 'pom.xml').getroot() + ns = pipeline.NS + normal = {node.findtext('m:artifactId', namespaces=ns) for node in root.findall('m:build/m:plugins/m:plugin', ns)} + self.assertNotIn('maven-gpg-plugin', normal) + self.assertNotIn('central-publishing-maven-plugin', normal) + release = next(node for node in root.findall('m:profiles/m:profile', ns) if node.findtext('m:id', namespaces=ns) == 'release') + plugins = {node.findtext('m:artifactId', namespaces=ns): node for node in release.findall('m:build/m:plugins/m:plugin', ns)} + self.assertEqual('verify', plugins['maven-gpg-plugin'].findtext('m:executions/m:execution/m:phase', namespaces=ns)) + publishing = plugins['central-publishing-maven-plugin'] + self.assertEqual('true', publishing.findtext('m:configuration/m:autoPublish', namespaces=ns)) + self.assertEqual('published', publishing.findtext('m:configuration/m:waitUntil', namespaces=ns)) + + def test_semver(self): + for tag in ['v0.0.8', 'v1.2.3-preview.1', 'v1.2.3-foo-bar']: + self.assertEqual(tag[1:], pipeline.release_version(tag)) + for tag in ['0.0.8', 'v01.2.3', 'v1.2.3-01', 'v1.2.3-', 'v1.2.3+build', 'v1.2.3-a..b']: + with self.subTest(tag=tag), self.assertRaises(ValueError): + pipeline.release_version(tag) + + def test_candidate_binding_and_tamper(self): + with workspace(): + candidate() + pipeline.verify_candidate(Path('candidate'), 'a' * 40, '0.0.8') + with self.assertRaises(ValueError): + pipeline.verify_candidate(Path('candidate'), 'b' * 40) + Path('candidate/ResiCache-0.0.8.jar').write_bytes(b'tampered') + with self.assertRaises(ValueError): + pipeline.verify_candidate(Path('candidate')) + + def test_report_rejects_missing_skipped_failed_and_unit_integration(self): + with workspace(): + Path('reports').mkdir() + Path('src').mkdir() + Path('src/RedisIntegrationTest.java').write_text('package sample; class RedisIntegrationTest {}') + report = Path('reports/TEST-sample.xml') + for case in ['', '', '', '']: + name = 'sample.OtherTest' if 'sample.OtherTest' in case else 'sample.RedisIntegrationTest' + report.write_text('' + case + '') + with self.assertRaises(ValueError): + pipeline.report_tests(Path('reports'), Path('src'), False) + report.write_text('') + pipeline.report_tests(Path('reports'), Path('src'), False) + with self.assertRaises(ValueError): + pipeline.report_tests(Path('reports'), Path('src'), True) + + def test_preflight_coordinates_changelog_and_ancestry(self): + with workspace(): + subprocess.run(['git', 'init', '-b', 'main', '-q'], check=True) + Path('pom.xml').write_text(pom()) + Path('resicache-bench').mkdir() + Path('resicache-bench/pom.xml').write_text('0.0.8') + Path('CHANGELOG.md').write_text('## [0.0.8]\nRelease notes\n') + subprocess.run(['git', 'add', 'pom.xml', 'resicache-bench/pom.xml', 'CHANGELOG.md'], check=True) + subprocess.run(['git', '-c', 'user.name=CI', '-c', 'user.email=ci@tests.invalid', 'commit', '-qm', 'fixture'], check=True) + subprocess.run(['git', 'tag', 'v0.0.8'], check=True) + subprocess.run(['git', 'update-ref', 'refs/remotes/origin/main', 'HEAD'], check=True) + with patch('pipeline.urllib.request.urlopen', side_effect=pipeline.urllib.error.HTTPError('url', 404, 'missing', {}, None)): + self.assertEqual('0.0.8', pipeline.preflight('v0.0.8')) + with patch('pipeline.urllib.request.urlopen'): + with self.assertRaisesRegex(ValueError, 'already exist'): + pipeline.preflight('v0.0.8') + with patch('pipeline.urllib.request.urlopen', side_effect=pipeline.urllib.error.HTTPError('url', 503, 'unavailable', {}, None)): + with self.assertRaises(ValueError): + pipeline.preflight('v0.0.8') + Path('CHANGELOG.md').write_text('## [Unreleased]\n') + with self.assertRaisesRegex(ValueError, 'Changelog'): + pipeline.preflight('v0.0.8', recovery=True) + + def test_new_high_dependency_policy(self): + old = {'package': 'x:y', 'version': '1', 'id': 'GHSA-old', 'severity': 'HIGH'} + new = {'package': 'a:b', 'version': '2', 'id': 'GHSA-new', 'severity': 'CRITICAL'} + low = new | {'id': 'GHSA-low', 'severity': 'LOW'} + unknown = new | {'id': 'OSV-unknown', 'severity': 'UNKNOWN'} + self.assertEqual([new, unknown], dependencies.new_risks([old | {'version': '2'}, new, low, unknown], [old])) + + +class CentralTests(unittest.TestCase): + def record(self): + return {'deployment_id': '12345678-1234-1234-1234-123456789012', 'deployment_name': 'candidate', 'purl': 'pkg:maven/io.github.davidhlp/ResiCache@0.0.8'} + + def test_poll_requires_published_correct_coordinates_and_identity(self): + record = self.record() + valid = {'deploymentId': record['deployment_id'], 'deploymentName': 'candidate', 'purls': [record['purl']]} + responses = [json.dumps(valid | {'deploymentState': state}).encode() for state in ['PENDING', 'VALIDATING', 'PUBLISHING', 'PUBLISHED']] + with patch('central.request', side_effect=responses): + self.assertEqual('PUBLISHED', central.poll(record, timeout=1, interval=0)['deploymentState']) + for status in [valid | {'deploymentState': 'FAILED'}, valid | {'deploymentState': 'unknown'}, valid | {'deploymentState': 'PUBLISHED', 'purls': []}, valid | {'deploymentState': 'PUBLISHED', 'deploymentId': 'other'}]: + with patch('central.request', return_value=json.dumps(status).encode()), self.assertRaises(ValueError): + central.poll(record, timeout=1, interval=0) + with self.assertRaisesRegex(ValueError, 'timed out'): + central.poll(record, timeout=0) + + def test_actual_http_upload_and_status(self): + record = self.record() + requests = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + requests.append((self.path, self.headers.get('Authorization'))) + self.rfile.read(int(self.headers.get('Content-Length', '0'))) + self.send_response(201 if '/upload?' in self.path else 200) + self.end_headers() + if '/upload?' in self.path: + self.wfile.write(record['deployment_id'].encode()) + else: + self.wfile.write(json.dumps({'deploymentId': record['deployment_id'], + 'deploymentName': record['deployment_name'], 'deploymentState': 'PUBLISHED', + 'purls': [record['purl']]}).encode()) + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with workspace(), patch.dict(os.environ, {'CENTRAL_USERNAME': 'fixture', 'CENTRAL_PASSWORD': 'fixture'}): + Path('publication').mkdir() + Path('publication/publication.json').write_text(json.dumps({'deployment_name': 'candidate', 'purl': record['purl']})) + Path('publication/central-bundle.zip').write_bytes(b'zip') + base = 'http://127.0.0.1:' + str(server.server_port) + uploaded = central.upload(Path('publication'), base=base) + self.assertEqual(record['deployment_id'], uploaded['deployment_id']) + self.assertEqual('PUBLISHED', central.poll(uploaded, base=base, timeout=1)['deploymentState']) + self.assertIn('publishingType=AUTOMATIC', requests[0][0]) + self.assertTrue(all(auth.startswith('Bearer ') for _, auth in requests)) + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_published_bytes_must_equal_candidate(self): + with workspace(): + candidate() + class Response: + def __enter__(self): + return self + def __exit__(self, *args): + pass + def read(self): + return b'tampered public artifact' + with patch('central.urllib.request.urlopen', return_value=Response()), self.assertRaisesRegex(ValueError, 'differ'): + central.verify_published(Path('candidate')) + + def test_upload_timeout_is_not_retried(self): + with workspace(): + Path('publication').mkdir() + Path('publication/publication.json').write_text(json.dumps({'deployment_name': 'candidate'})) + Path('publication/central-bundle.zip').write_bytes(b'zip') + with patch('central.request', side_effect=TimeoutError) as request, self.assertRaises(TimeoutError): + central.upload(Path('publication')) + self.assertEqual(1, request.call_count) + + def test_signed_bundle_and_recovery(self): + with workspace() as root: + candidate() + home = root / 'gpg' + home.mkdir(mode=0o700) + env = os.environ | {'GNUPGHOME': str(home)} + subprocess.run(['gpg', '--batch', '--passphrase', '', '--quick-generate-key', 'CI fixture ', 'rsa2048', 'sign', '1d'], env=env, check=True, capture_output=True) + keys = subprocess.check_output(['gpg', '--with-colons', '--list-secret-keys'], env=env, text=True) + fingerprint = next(line.split(':')[9] for line in keys.splitlines() if line.startswith('fpr:')) + private = subprocess.check_output(['gpg', '--batch', '--armor', '--export-secret-keys', fingerprint], env=env).decode() + with patch.dict(os.environ, {'GPG_PRIVATE_KEY': private, 'GPG_PASSPHRASE': ''}): + record = central.bundle(Path('candidate'), Path('publication'), fingerprint) + record['deployment_id'] = self.record()['deployment_id'] + Path('publication/publication.json').write_text(json.dumps(record)) + central.recovery(Path('candidate'), Path('publication'), record['deployment_id']) + with self.assertRaises(ValueError): + central.recovery(Path('candidate'), Path('publication'), 'other') + Path('publication/ResiCache-0.0.8.jar.asc').unlink() + with self.assertRaises(ValueError): + central.recovery(Path('candidate'), Path('publication'), record['deployment_id']) + subprocess.run(['gpgconf', '--kill', 'gpg-agent'], env=env, check=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java new file mode 100644 index 00000000..dd502172 --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java @@ -0,0 +1,190 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SslOptions; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Comparator; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties; +import org.springframework.data.redis.connection.RedisSentinelConfiguration; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Real Sentinel discovery and certificate verification; these are not HA/SLO claims. */ +@Testcontainers(disabledWithoutDocker = false) +class RedisTopologyIntegrationTest { + + private static final Path CERTIFICATES = certificates(); + + @Container + private static final GenericContainer SENTINEL = new GenericContainer<>("redis:7-alpine") + .withCommand("sh", "-c", sentinelCommand()) + .waitingFor(Wait.forLogMessage(".*SENTINEL_READY.*", 1)); + + @Container + private static final GenericContainer TLS = new GenericContainer<>("redis:7-alpine") + .withCopyFileToContainer(MountableFile.forHostPath(CERTIFICATES.resolve("server.crt")), "/tls/server.crt") + .withCopyFileToContainer(MountableFile.forHostPath(CERTIFICATES.resolve("server.key"), 0644), "/tls/server.key") + .withExposedPorts(6379) + .withCommand("redis-server", "--port", "0", "--tls-port", "6379", "--tls-cert-file", "/tls/server.crt", + "--tls-key-file", "/tls/server.key", "--tls-ca-cert-file", "/tls/server.crt", + "--tls-auth-clients", "no", "--save", "") + .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1)); + + @Test + void sentinelDiscoversMasterForSpringReadsAndRedissonLocks() throws Exception { + String ip = SENTINEL.getContainerInfo().getNetworkSettings().getNetworks() + .values().iterator().next().getIpAddress(); + var discovered = SENTINEL.execInContainer("redis-cli", "-p", "26379", "sentinel", "get-master-addr-by-name", "cache"); + assertThat(discovered.getStdout()).contains(ip, "6379"); + RedisSentinelConfiguration redis = new RedisSentinelConfiguration().master("cache") + .sentinel(ip, 26379).sentinel(ip, 26380).sentinel(ip, 26381); + LettuceConnectionFactory factory = new LettuceConnectionFactory(redis); + RedisProCacheProperties properties = properties(); + properties.getRedis().setMode("sentinel"); + properties.getRedis().setSentinelMaster("cache"); + properties.getRedis().setSentinelNodes(java.util.List.of(ip + ":26379", ip + ":26380", ip + ":26381")); + Config config = new RedissonConfiguration().buildConfig(new DataRedisProperties(), properties); + assertReadWriteAndLock(factory, config); + } + + @Test + void trustedTlsCertificateAllowsSpringReadsAndRedissonLocks() throws Exception { + LettuceConnectionFactory factory = tlsFactory(true); + Config config = tlsConfig(); + config.useSingleServer().setSslTruststore(CERTIFICATES.resolve("trust.p12").toUri().toURL()) + .setSslTruststorePassword("test-only"); + assertReadWriteAndLock(factory, config); + } + + @Test + void untrustedTlsCertificateIsRejectedByBothClients() { + LettuceConnectionFactory factory = tlsFactory(false); + try { + factory.afterPropertiesSet(); + factory.start(); + assertThatThrownBy(() -> { + try (var connection = factory.getConnection()) { + connection.ping(); + } + }).hasStackTraceContaining("SSLHandshakeException"); + } finally { + factory.destroy(); + } + assertThatThrownBy(() -> Redisson.create(tlsConfig())).hasStackTraceContaining("SSLHandshakeException"); + } + + private static void assertReadWriteAndLock(LettuceConnectionFactory factory, Config config) { + RedissonClient redisson = null; + try { + factory.afterPropertiesSet(); + factory.start(); + StringRedisTemplate template = new StringRedisTemplate(factory); + template.opsForValue().set("topology", "value", Duration.ofSeconds(30)); + assertThat(template.opsForValue().get("topology")).isEqualTo("value"); + redisson = Redisson.create(config); + var lock = redisson.getLock("topology-lock"); + lock.lock(); + try { + assertThat(lock.isHeldByCurrentThread()).isTrue(); + } finally { + lock.unlock(); + } + } finally { + if (redisson != null) { + redisson.shutdown(); + } + factory.destroy(); + } + } + + private static LettuceConnectionFactory tlsFactory(boolean trusted) { + var ssl = SslOptions.builder(); + if (trusted) { + ssl.trustManager(CERTIFICATES.resolve("server.crt").toFile()); + } + var client = LettuceClientConfiguration.builder().useSsl().and() + .commandTimeout(Duration.ofSeconds(3)) + .clientOptions(ClientOptions.builder().sslOptions(ssl.build()).build()).build(); + return new LettuceConnectionFactory(new RedisStandaloneConfiguration(TLS.getHost(), TLS.getMappedPort(6379)), client); + } + + private static Config tlsConfig() { + RedisProCacheProperties properties = properties(); + properties.getRedis().setHost(TLS.getHost()); + properties.getRedis().setPort(TLS.getMappedPort(6379)); + properties.getRedis().setTlsEnabled(true); + return new RedissonConfiguration().buildConfig(new DataRedisProperties(), properties); + } + + private static RedisProCacheProperties properties() { + var properties = new RedisProCacheProperties(); + properties.getRedisson().setConnectionPoolSize(2); + properties.getRedisson().setConnectionMinimumIdleSize(1); + properties.getRedisson().setConnectTimeout(3000); + properties.getRedisson().setTimeout(3000); + properties.getRedisson().setRetryAttempts(0); + return properties; + } + + private static Path certificates() { + try { + Path directory = Files.createTempDirectory("resicache-tls-"); + run("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=localhost", + "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1", "-keyout", directory.resolve("server.key").toString(), + "-out", directory.resolve("server.crt").toString()); + run(Path.of(System.getProperty("java.home"), "bin", "keytool").toString(), "-importcert", "-noprompt", + "-alias", "redis", "-file", directory.resolve("server.crt").toString(), "-keystore", + directory.resolve("trust.p12").toString(), "-storepass", "test-only", "-storetype", "PKCS12"); + return directory; + } catch (Exception exception) { + throw new IllegalStateException("TLS smoke requires openssl and JDK keytool", exception); + } + } + + private static void run(String... command) throws Exception { + int status = new ProcessBuilder(command).redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD).start().waitFor(); + if (status != 0) { + throw new IllegalStateException("Certificate generation failed"); + } + } + + @AfterAll + static void removeCertificates() throws Exception { + try (var paths = Files.walk(CERTIFICATES)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } + + private static String sentinelCommand() { + return "set -eu; IP=$(hostname -i | awk '{print $1}'); " + + "redis-server --port 6379 --bind 0.0.0.0 --protected-mode no --save '' --daemonize yes; " + + "redis-server --port 6380 --bind 0.0.0.0 --protected-mode no --save '' " + + "--replicaof $IP 6379 --daemonize yes; " + + "for PORT in 26379 26380 26381; do " + + "printf 'port %s\\nbind 0.0.0.0\\nprotected-mode no\\nsentinel monitor cache %s 6379 2\\n' " + + "$PORT $IP > /tmp/sentinel-$PORT.conf; " + + "redis-server /tmp/sentinel-$PORT.conf --sentinel --daemonize yes; done; " + + "until redis-cli -p 26379 sentinel get-master-addr-by-name cache | grep -q 6379; do sleep 0.1; done; " + + "echo SENTINEL_READY; tail -f /dev/null"; + } +} From c15ceb832b4a056d964c83a29cdecc68c6197b2e Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Wed, 7 Oct 2026 21:10:45 +0800 Subject: [PATCH 2/4] fix(ci): await Sentinel topology and retain scan evidence --- .github/workflows/_quality.yml | 4 ++-- .github/workflows/_verify.yml | 1 + scripts/ci/pipeline.py | 2 +- scripts/ci/tests/test_pipeline.py | 3 ++- .../redis/cache/RedisTopologyIntegrationTest.java | 13 +++++++++++++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index 78be999b..941eb76b 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -21,8 +21,8 @@ permissions: contents: read jobs: - # Dependency scans and Qodana are advisory until their result aggregation is - # wired into the canonical blocking gate. They never represent release + # Scheduled dependency diagnostics and Qodana are advisory and stay outside + # the canonical blocking gate. They never represent release # compatibility or contract closure. dependency-analysis: name: "Advisory · Dependency Analysis" diff --git a/.github/workflows/_verify.yml b/.github/workflows/_verify.yml index d945749e..e7e30216 100644 --- a/.github/workflows/_verify.yml +++ b/.github/workflows/_verify.yml @@ -99,6 +99,7 @@ jobs: fail-on-severity: high comment-summary-in-pr: never - name: Resolved core and benchmark dependency evidence + if: ${{ !cancelled() }} env: BASE: ${{ github.event.pull_request.base.sha || github.event.before }} EVENT: ${{ github.event_name }} diff --git a/scripts/ci/pipeline.py b/scripts/ci/pipeline.py index d7292dfd..5cc14d84 100644 --- a/scripts/ci/pipeline.py +++ b/scripts/ci/pipeline.py @@ -119,7 +119,7 @@ def report_tests(report_dir, test_root, unit): else: for path in test_root.rglob("*IntegrationTest.java"): source = path.read_text() - if re.search(r"\babstract\s+class\b", source): + if re.search(r"^\s*(?:(?:public|protected|private)\s+)?abstract\s+class\s+", source, re.M): continue package = re.search(r"\bpackage\s+([\w.]+)\s*;", source)[1] require(package + "." + path.stem in actual, f"Integration test did not execute: {path}") diff --git a/scripts/ci/tests/test_pipeline.py b/scripts/ci/tests/test_pipeline.py index 22028ddf..8c953bbc 100644 --- a/scripts/ci/tests/test_pipeline.py +++ b/scripts/ci/tests/test_pipeline.py @@ -96,7 +96,8 @@ def test_report_rejects_missing_skipped_failed_and_unit_integration(self): with workspace(): Path('reports').mkdir() Path('src').mkdir() - Path('src/RedisIntegrationTest.java').write_text('package sample; class RedisIntegrationTest {}') + Path('src/RedisIntegrationTest.java').write_text('package sample;\n// Uses an abstract class fixture.\nclass RedisIntegrationTest {}') + Path('src/AbstractIntegrationTest.java').write_text('package sample;\npublic abstract class AbstractIntegrationTest {}') report = Path('reports/TEST-sample.xml') for case in ['', '', '', '']: name = 'sample.OtherTest' if 'sample.OtherTest' in case else 'sample.RedisIntegrationTest' diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java index dd502172..15bf7595 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java @@ -25,6 +25,7 @@ import org.testcontainers.utility.MountableFile; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; /** Real Sentinel discovery and certificate verification; these are not HA/SLO claims. */ @Testcontainers(disabledWithoutDocker = false) @@ -49,6 +50,18 @@ class RedisTopologyIntegrationTest { @Test void sentinelDiscoversMasterForSpringReadsAndRedissonLocks() throws Exception { + // Process readiness precedes Sentinel's asynchronous peer discovery. + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + for (int port : new int[] {26379, 26380, 26381}) { + var master = SENTINEL.execInContainer("redis-cli", "--raw", "-p", Integer.toString(port), + "sentinel", "master", "cache"); + assertThat(master.getExitCode()).isZero(); + assertThat(master.getStdout()).contains("num-other-sentinels\n2\n", "num-slaves\n1\n"); + var quorum = SENTINEL.execInContainer("redis-cli", "-p", Integer.toString(port), + "sentinel", "ckquorum", "cache"); + assertThat(quorum.getStdout()).startsWith("OK"); + } + }); String ip = SENTINEL.getContainerInfo().getNetworkSettings().getNetworks() .values().iterator().next().getIpAddress(); var discovered = SENTINEL.execInContainer("redis-cli", "-p", "26379", "sentinel", "get-master-addr-by-name", "cache"); From 381c803c175d0b379f45bb70626e098ad9d7bd6e Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Wed, 7 Oct 2026 21:20:27 +0800 Subject: [PATCH 3/4] fix(ci): wait for initial Redis replica synchronization --- CONTRIBUTING.md | 8 ++++---- docs/OPERATIONS.md | 5 +++-- .../cache/redis/cache/RedisTopologyIntegrationTest.java | 7 ++++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be3aafe3..03735c76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,13 +101,13 @@ SLA and no pinned production-adopter guarantee. ## Releases and CI infrastructure -CI runs on pushes to `main` or `master` and on pull requests through +CI runs on pushes to `main`, manual dispatch and pull requests/merge groups through [`.github/workflows/ci.yml`](.github/workflows/ci.yml) and [`.github/workflows/pr.yml`](.github/workflows/pr.yml). The composite [setup-jdk-21 action](.github/actions/setup-jdk-21/action.yml) centralizes JDK and Maven cache setup; `pom.xml` remains the Java-version source of truth. -Release-time `OSSRH_*` and `GPG_*` secrets are configured at repository or -environment level out of band. Do not edit `release.yml` to add secrets; open -an issue first. Release behavior and the current publication boundary are in +Release-time `CENTRAL_*` and `GPG_*` secrets are configured in the +`maven-central` environment out of band; `GPG_FINGERPRINT` is an environment +variable. Never commit credentials. Release behavior and the current publication boundary are in [`docs/OPERATIONS.md`](docs/OPERATIONS.md). diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index b0942d7e..f5aeab70 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -134,8 +134,9 @@ raw candidate is retained for 30 days. Preserve both externally if longer recovery is needed. If an upload response was lost before an ID was recorded, locate the uniquely named deployment in the Portal; do not blindly rerun upload. -Main requires PRs and the GitHub Actions `ci-ok` check against an up-to-date -branch, and disallows force pushes/deletion. The single-maintainer configuration +After the shared workflow passes, configure main to require PRs and the GitHub +Actions `ci-ok` check against an up-to-date branch, and disallow force pushes/deletion. +The single-maintainer configuration does not require another person's approval. Secret scanning and push protection remain enabled; Dependabot alerts and security updates complement CI scans. Workflow files describe intended checks; remote branch/environment settings diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java index 15bf7595..a5be9cdf 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java @@ -50,7 +50,7 @@ class RedisTopologyIntegrationTest { @Test void sentinelDiscoversMasterForSpringReadsAndRedissonLocks() throws Exception { - // Process readiness precedes Sentinel's asynchronous peer discovery. + // Process readiness precedes peer discovery and the replica's initial RDB sync. await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { for (int port : new int[] {26379, 26380, 26381}) { var master = SENTINEL.execInContainer("redis-cli", "--raw", "-p", Integer.toString(port), @@ -61,6 +61,11 @@ void sentinelDiscoversMasterForSpringReadsAndRedissonLocks() throws Exception { "sentinel", "ckquorum", "cache"); assertThat(quorum.getStdout()).startsWith("OK"); } + var replica = SENTINEL.execInContainer("redis-cli", "-p", "6380", "info", "replication"); + assertThat(replica.getExitCode()).isZero(); + assertThat(replica.getStdout()).contains("master_link_status:up", "master_sync_in_progress:0"); + var master = SENTINEL.execInContainer("redis-cli", "-p", "6379", "info", "replication"); + assertThat(master.getStdout()).contains("connected_slaves:1", "state=online"); }); String ip = SENTINEL.getContainerInfo().getNetworkSettings().getNetworks() .values().iterator().next().getIpAddress(); From 8e48b3cafac9442ff9ffc30377703c2aa37e6db6 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Wed, 7 Oct 2026 21:52:16 +0800 Subject: [PATCH 4/4] fix(deps): declare Micrometer Core runtime requirement --- CHANGELOG.md | 6 ++++++ COMPATIBILITY.md | 7 +++++-- pom.xml | 6 ++++++ scripts/ci/check-external-consumer.sh | 2 +- .../src/main/java/com/example/consumer/BootConsumer.java | 2 ++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97cd937e..fc2cf009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ contract. reports; prepare tag/POM/Changelog-validated Central publishing and recovery using the exact signed candidate bytes. +### Fixed + +- Declare Micrometer Core as a runtime dependency so a minimal packaged Boot + consumer can start without Actuator. Metrics publishing remains opt-in; + Actuator and Redisson remain optional. + The project is on a **single build line**: Spring Boot 4.0 / SDR 4.0 / Spring 7 / Java 21 / Redisson 3.50.0. Dual-branch (`master` / `boot4`) is abandoned; the current build line is `main` and Boot diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 86236322..042eb87d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -48,14 +48,17 @@ baseline. is maintained. - **Pre-1.0 caveat**: matrix coverage is best-effort until 1.0. -## Optional dependencies +## Runtime and optional dependencies | Dependency | Required? | Notes | |---|---|---| | **Redisson** | Optional | Needed for distributed-lock (`sync=true`). Without it, a sync operation fails fast unless `resi-cache.sync-lock.local-only=true` is explicitly configured. | -| **Micrometer / Actuator** | Optional | Cache metrics require +| **Micrometer Core** | Required | Runtime handler and metrics seams reference its + types even when metrics publishing is disabled. Its version is managed by the + Spring Boot dependency management in `pom.xml`; it does not create a registry. | +| **Actuator / metrics registry provider** | Optional | Cache metrics require `resi-cache.metrics.enabled=true` (default OFF) and a `MeterRegistry`; otherwise the resolved metrics seam is a no-op adapter. `RedisCacheHealthIndicator` requires Actuator and the `HealthIndicator` diff --git a/pom.xml b/pom.xml index 2bc0ab3f..ef553295 100644 --- a/pom.xml +++ b/pom.xml @@ -121,6 +121,12 @@ spring-boot-starter-validation + + + io.micrometer + micrometer-core + + org.springframework.boot spring-boot-starter-actuator diff --git a/scripts/ci/check-external-consumer.sh b/scripts/ci/check-external-consumer.sh index a8fedea5..a7aeac64 100755 --- a/scripts/ci/check-external-consumer.sh +++ b/scripts/ci/check-external-consumer.sh @@ -46,7 +46,7 @@ for profile in minimal redisson observability; do if [[ "$classpath" =~ junit|mockito|testcontainers|lombok ]]; then echo 'Consumer classpath contains test/provided dependencies' >&2; exit 1 fi - if [[ "$profile" == minimal && "$classpath" =~ redisson|actuator|micrometer-core ]]; then + if [[ "$profile" == minimal && "$classpath" =~ redisson|actuator ]]; then echo 'Minimal consumer contains optional dependencies' >&2; exit 1 fi "$JAVA_HOME/bin/java" -cp "$task_dir/target/classes:$classpath" com.example.consumer.ExternalConsumerDemo diff --git a/scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java b/scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java index e29bba16..364dda53 100644 --- a/scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java +++ b/scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java @@ -56,6 +56,8 @@ public static void main(String[] args) throws Exception { Class.forName("io.micrometer.core.instrument.MeterRegistry")).isEmpty()) { throw new AssertionError("Optional observability did not assemble"); } + } else if (!app.getBeansOfType(Class.forName("io.micrometer.core.instrument.MeterRegistry")).isEmpty()) { + throw new AssertionError("Metrics registry should require an observability provider"); } System.out.println("BOOT_CONSUMER_OK " + profile); }