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..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" @@ -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..e7e30216 --- /dev/null +++ b/.github/workflows/_verify.yml @@ -0,0 +1,131 @@ +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 + if: ${{ !cancelled() }} + 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..fc2cf009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,22 @@ 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. + +### 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/CONTRIBUTING.md b/CONTRIBUTING.md index b485d45c..03735c76 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`; @@ -100,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/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..f5aeab70 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -94,17 +94,58 @@ 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. + +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 +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..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 @@ -239,16 +245,6 @@ - - org.sonatype.central - central-publishing-maven-plugin - 0.8.0 - true - - central - - - org.apache.maven.plugins maven-source-plugin @@ -282,21 +278,6 @@ - - org.apache.maven.plugins - maven-gpg-plugin - 3.1.0 - - - sign-artifacts - deploy - - sign - - - - - org.jacoco jacoco-maven-plugin @@ -367,6 +348,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..a7aeac64 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 ]]; 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..364dda53 --- /dev/null +++ b/scripts/ci/consumer/src/main/java/com/example/consumer/BootConsumer.java @@ -0,0 +1,65 @@ +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"); + } + } 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); + } + } +} 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..5cc14d84 --- /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"^\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}") + 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..8c953bbc --- /dev/null +++ b/scripts/ci/tests/test_pipeline.py @@ -0,0 +1,244 @@ +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;\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' + 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..a5be9cdf --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisTopologyIntegrationTest.java @@ -0,0 +1,208 @@ +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; +import static org.awaitility.Awaitility.await; + +/** 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 { + // 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), + "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"); + } + 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(); + 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"; + } +}