diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a8c113ef..090db397 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -42,6 +42,8 @@ body: A clear description of the unexpected behavior. Include the relevant `@RedisCacheable` / `@RedisCacheEvict` annotation and your `resi-cache.*` configuration. + Redact passwords, credentials, raw cache keys/values and personal data + from configuration and examples before posting publicly. placeholder: "When I call ... I expected ... but got ..." validations: required: true @@ -49,7 +51,7 @@ body: id: repro attributes: label: Minimal reproduction - description: The smallest code/config that reproduces the issue. A failing test is ideal. + description: The smallest code/config that reproduces the issue. A failing test is ideal. Remove credentials, raw cache keys/values and personal data. render: java validations: required: false @@ -62,6 +64,8 @@ body: capture the `[chain] handler=... decision=... key=... requestId=...` trace, which correlates every handler in one cache operation by `requestId`. + DEBUG output can contain sensitive keys or values. Redact those, + credentials and personal data; share only the relevant excerpt. render: shell validations: required: false diff --git a/.github/workflows/_docs.yml b/.github/workflows/_docs.yml index 7bcd5a31..8dc19d6b 100644 --- a/.github/workflows/_docs.yml +++ b/.github/workflows/_docs.yml @@ -16,33 +16,5 @@ jobs: - name: Checkout uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - - name: Guard against removed-feature regressions in docs - run: | - set -e - # 1. 黑名单:被移除的过度工程符号不应再出现在对外 README 中 - banned="CircuitBreakerCacheWrapper RateLimiterCacheWrapper \ - SpelConditionEvaluator CacheEvictedEvent CacheMetricsRecorder \ - BloomFilterProvider LockProvider RedissonLockProvider" - for sym in $banned; do - hits=$(grep -rnF "\`$sym\`" README.md README.zh-CN.md 2>/dev/null || true) - if [ -n "$hits" ]; then - echo "::error::Removed symbol '$sym' is still referenced as a code identifier in README:" - echo "$hits" - exit 1 - fi - done - echo "OK: No removed-symbol regressions (as code identifiers) in README." - - # 2. 白名单:README 宣称的关键类必须在 src 存在 - for cls in RedisCacheAutoConfiguration RedisProCacheProperties RedisProCache \ - RedisProCacheManager CacheHandlerChainFactory RedisCacheInterceptor \ - RedisCacheOperationSource RedissonConfiguration; do - if ! find src/main/java -name "${cls}.java" | grep -q .; then - echo "::error::Key class '$cls' referenced in docs not found in src/main/java" - exit 1 - fi - done - echo "OK: All key classes referenced in docs exist in src." - - name: Validate contract documentation run: bash ./scripts/ci/check-docs-contracts.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2cf009..39f4826c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ contract. ### CI/CD +- Validate the packaged-consumer JDK before building, resolve PATH Java shims, + and reject missing explicitly supplied candidates. Consolidate documentation + checks in one guard with canonical-owner and POM-derived version assertions. +- Document migration CLI preparation, sidecar namespace and recovery limits; + clarify root-file tracking and redact configuration/debug input in bug reports. - 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; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03735c76..bd8edc4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,12 @@ truly necessary, document its reader, distinct responsibility, and lifecycle in the documentation map. Preserve changelog history, performance evidence, and unresolved task entries. +The root `.gitignore` uses an allowlist: new top-level files or directories are +silently ignored unless explicitly included. When adding a permanent root +entry, update its allowlist rule and check `git status --short` and +`git check-ignore -v ` before staging. Keep temporary reports and generated +outputs ignored; do not force-add them to bypass this boundary. + ## Pull requests Use the repository PR template. Summarize the behavior and evidence, identify diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d9876702..76862947 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -56,8 +56,11 @@ failures separately from test failures. 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). + an existing verified candidate; an explicitly missing directory fails rather + than rebuilding. Without an argument, the script reuses `target/ci-candidate` + or packages it if absent. JDK selection uses `RESICACHE_JDK21`, then + `JAVA_HOME`, then the Java installation on `PATH`; it requires JDK 21 before + packaging or installing anything. `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 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index f5aeab70..95302c30 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -88,9 +88,106 @@ JSON or JDK serializers. A safe adoption flow is: 4. retain a rollback path until the new representation is trusted. The migration CLI and properties are operator-directed surfaces. They are not -run automatically at application startup. A serializer change without this -workflow can make existing values unreadable; a cache flush is not the only -rollback strategy and is not required by the documented migration flow. +run automatically at application startup. `DUAL_WRITE` is a one-time batch +conversion to shadow sidecars, not interception of subsequent application +writes. Provide actual application dual writes separately, or quiesce writers +and evictions for the affected keys through validation and cutover. CAS rejects +source changes during cutover; it does not establish a continuous-write protocol. + +### Prepare and invoke the operator + +Use JDK 21 and the checked-out core JAR with its production runtime dependencies. +The plain library JAR has no executable launcher; do not use `java -jar`. +From the repository root, reuse the existing independent consumer POM template: + +```bash +( +set -euo pipefail +./mvnw install -DskipTests -B +operator_dir="$(mktemp -d)" +trap 'rm -rf "$operator_dir"' EXIT +boot_version="$(./mvnw help:evaluate -Dexpression=project.parent.version -q -DforceStdout)" +core_version="$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout)" +sed "s/@BOOT_VERSION@/$boot_version/g" scripts/ci/consumer/pom.xml > "$operator_dir/pom.xml" +./mvnw -f "$operator_dir/pom.xml" -Pminimal -Dresicache.version="$core_version" \ + dependency:build-classpath -Dmdep.includeScope=runtime \ + -Dmdep.outputFile="$operator_dir/classpath" -B +java -cp "$(cat "$operator_dir/classpath")${APP_VALUE_CLASSPATH:+:$APP_VALUE_CLASSPATH}" \ + io.github.davidhlp.spring.cache.redis.serialization.migration.SerializationMigrationCli \ + --spring.config.additional-location=file:/secure/resicache-migration.properties \ + --resi-cache.serializer.migration.pattern='myapp:orders:*' \ + --resi-cache.serializer.migration.phase=SHADOW_READ \ + --resi-cache.serializer.migration.dry-run=true +) +``` + +Run these build commands in the project development environment; run the Java +invocation only in the approved operator environment. Replace the example +pattern and trusted configuration path. Set `APP_VALUE_CLASSPATH` to the JARs +containing the actual application value classes and their dependencies when +needed; the minimal consumer profile supplies library dependencies, not host +classes. Do not put a host Boot executable JAR's nested libraries directly on +this classpath. The subshell stops on failure and cleans up its temporary files. + +In the protected configuration file or the deployment's existing secrets +injection, configure `spring.data.redis.*` for the intended Redis deployment, +`resi-cache.serializer.allowed-package-prefixes` for only the trusted value +packages (and required internal types), and the actual legacy format through +`resi-cache.serializer.migration.legacy-serializer`. Keep passwords and payloads +out of command arguments and public logs. Set +`resi-cache.serializer.fail-on-unknown-type=true`: permissive decoding can return +null for invalid current envelopes, which the engine can count as `envelopes` +rather than `failed`. Successful CLI validation alone does not establish a +usable cache hit: test representative converted values through the application's +actual read path, including the compatible `CachedValue` wrapper, nested values +and expiry metadata. Bare legacy DTO/String values are not automatically given +that runtime structure; regenerate incompatible entries through application +cache writes instead of cutting them over. + +### Write preflight and recovery + +Before any write phase, inventory the complete intended source set and reserve +collision-free `shadow-suffix` and `backup-suffix` namespaces. No source key may +end in either suffix; every derived destination must be absent or verified as +belonging to this migration. Stop on unrelated or unverifiable destinations. +The CLI skips source keys ending in these suffixes and uses UPSERT to overwrite +differing sidecar bytes; a dry run does not detect namespace conflicts. Prevent +other writers from creating reserved keys throughout the migration. + +Always specify pattern, phase and dry-run explicitly. The default phase is +read-only `SHADOW_READ`, but `dry-run` itself defaults to false. After a successful +preflight, run `DUAL_WRITE` with dry-run false to produce representative sidecars, +verify actual application reads, then authorize `CUTOVER` separately. Reuse the +same source pattern and suffix settings for `ROLLBACK`; the engine appends the +backup suffix itself. Phase and budget semantics are in +[`REFERENCE.md`](REFERENCE.md#migration-phase-and-budget-semantics). + +Inspect the final summary (`scanned`, `envelopes`, `decodedLegacy`, `written`, +`skippedSidecars`, `failed`) and process exit status. Rejected/failed keys make +the CLI exit nonzero, but earlier writes can already have succeeded. Resolve +the cause and reconcile source/sidecar state before retrying; an interrupted +run is incomplete. There is no persisted SCAN cursor or durable checkpoint, +and repeating a dry run or SHADOW_READ can validate the same subset again. +`max-keys` is not a scan, attempt or elapsed-time budget. Even a narrow MATCH +pattern does not bound Redis SCAN work; independently scope the key population +and apply an external execution deadline when required. + +Rollback rejects changed existing source bytes, but recreates missing sources +from backups with SET_IF_ABSENT. This can resurrect intentionally evicted stale +values. Quiesce writes and evictions, reconcile prior deletions against the +backup inventory, and exclude their backups before authorizing rollback. +Backups may already have expired; rollback is not guaranteed for every key. + +Sidecars copy the source's remaining TTL at creation; persistent sources produce +persistent sidecars. Neither cutover nor rollback removes them and there is no +cleanup phase. Retain backups for the agreed rollback window; after validating +the result and retiring migration/dual-write activity, review and manually +remove only the inventoried sidecars belonging to this migration in controlled +batches. Sidecars are not a durable backup service. + +A serializer change without this workflow can make existing values unreadable; +a cache flush is not the only rollback strategy and is not required by the +documented migration flow. ## Release and publication boundary diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 174b9a76..ee4fb51b 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -147,6 +147,38 @@ Use the bounded shadow-read → dual-write → cutover migration described in Do not claim that an in-place serializer swap, a cache flush, or a historical Maven Central artifact proves compatibility with the current line. +### Migration phase and budget semantics + +`SerializationMigrationCli` is an independent operator entry point. Exact +settings live in `SerializationMigrationProperties`; the runnable preparation, +namespace preflight and recovery procedure belong to +[`OPERATIONS.md`](OPERATIONS.md#prepare-and-invoke-the-operator). + +| Phase | Current effect | +|---|---| +| `SHADOW_READ` | Default; decodes legacy values and checks current envelopes without writing. | +| `DUAL_WRITE` | Writes current-envelope shadow sidecars once; leaves source bytes in place and does not capture future application writes. | +| `CUTOVER` | Writes legacy backup sidecars, then compares/replaces unchanged source bytes with the current envelope using KEEPTTL. | +| `ROLLBACK` | Scans the source pattern plus backup suffix; restores the expected converted source or recreates a missing source from its backup. Changed existing bytes are rejected. | + +`dry-run=true` prevents writes; its default is false. `max-keys` limits the +private selected count, not SCAN results or all attempts. In forward phases, +selection follows successful legacy decoding/serialization; current envelopes, +sidecars, completed DUAL_WRITE sidecars and failures before selection do not +consume the limit. Failures after selection do. Rollback selects before legacy +decoding, so its decode failures consume the limit. `batch-size` is a SCAN hint, +not a hard cap. `scanned` excludes forward sidecars and is not the selected +count; `written` counts successful writes and can include both backup and source +writes for one CUTOVER key. Rollback dry runs expose no planned-restoration +count: zero written does not mean no restorations are pending. + +Runs have no durable cursor/checkpoint. Completed write-phase state can permit +skipping some keys on a later invocation; dry runs and SHADOW_READ persist no +completion state and may select the same keys again. Reaching max-keys or a +successful exit does not prove the entire matching keyspace was validated. +Per-key failures are counted and scanning continues, then the CLI exits nonzero; +successful earlier writes are not rolled back automatically. + ## Errors and diagnostics Binding validation reports concrete property paths. Missing distributed lock diff --git a/scripts/ci/check-docs-contracts.sh b/scripts/ci/check-docs-contracts.sh index a68476b5..82cbce95 100755 --- a/scripts/ci/check-docs-contracts.sh +++ b/scripts/ci/check-docs-contracts.sh @@ -9,14 +9,7 @@ docs=( docs/README.md docs/PRODUCT.md docs/ARCHITECTURE.md docs/DEVELOPMENT.md docs/OPERATIONS.md docs/REFERENCE.md ) -required_docs=( - README.md README.zh-CN.md AGENTS.md - STABILITY.md COMPATIBILITY.md CHANGELOG.md CONTRIBUTING.md SECURITY.md PERFORMANCE.md - docs/README.md docs/PRODUCT.md docs/ARCHITECTURE.md docs/DEVELOPMENT.md - docs/OPERATIONS.md docs/REFERENCE.md -) - -for doc in "${required_docs[@]}"; do +for doc in "${docs[@]}"; do if [[ ! -f "$doc" ]]; then printf 'Missing canonical documentation entry point: %s\n' "$doc" >&2 exit 1 @@ -26,32 +19,47 @@ done for forbidden in \ 'pr-checks.yml' \ 'maven-failsafe-plugin' \ - 'Testcontainers | 1.20.4' \ '| Java | 21+ |' \ '| JDK | 21+ |' \ '@ComponentScan' \ 'Java 21+' \ 'JDK 21+'; do - if grep -nF -- "$forbidden" "${docs[@]}"; then - printf 'Forbidden stale documentation value: %s\n' "$forbidden" >&2 + for doc in "${docs[@]}"; do + # History may quote superseded guidance without making it current policy. + case "$doc" in CHANGELOG.md|PERFORMANCE.md) continue ;; esac + if grep -nF -- "$forbidden" "$doc"; then + printf 'Forbidden stale documentation value: %s\n' "$forbidden" >&2 + exit 1 + fi + done +done + +while IFS='|' read -r owner required; do + if ! grep -qF -- "$required" "$owner"; then + printf 'Missing required contract documentation value in %s: %s\n' "$owner" "$required" >&2 + exit 1 + fi +done <<'CONTRACTS' +COMPATIBILITY.md|PUT, PUT_IF_ABSENT, and CLEAN +COMPATIBILITY.md|Reactive +docs/REFERENCE.md|resi-cache.bloom +CONTRACTS + +# README/source checks have one owner so the local guard matches CI. +for symbol in CircuitBreakerCacheWrapper RateLimiterCacheWrapper \ + SpelConditionEvaluator CacheEvictedEvent CacheMetricsRecorder \ + BloomFilterProvider LockProvider RedissonLockProvider; do + if grep -nF -- "\`$symbol\`" README.md README.zh-CN.md; then + printf 'Removed symbol referenced in README: %s\n' "$symbol" >&2 exit 1 fi done -for required in \ - 'PUT, PUT_IF_ABSENT, and CLEAN' \ - 'Reactive' \ - 'Testcontainers | 1.20.6' \ - 'resi-cache.bloom'; do - found=0 - for doc in "${docs[@]}"; do - if grep -qF -- "$required" "$doc"; then - found=1 - break - fi - done - if [[ "$found" -ne 1 ]]; then - printf 'Missing required contract documentation value: %s\n' "$required" >&2 +for class in RedisCacheAutoConfiguration RedisProCacheProperties RedisProCache \ + RedisProCacheManager CacheHandlerChainFactory RedisCacheInterceptor \ + RedisCacheOperationSource RedissonConfiguration; do + if [[ -z "$(find src/main/java -name "${class}.java" -print -quit)" ]]; then + printf 'Key class referenced in documentation missing from source: %s\n' "$class" >&2 exit 1 fi done @@ -94,6 +102,11 @@ if [[ -z "$pom_testcontainers_version" ]]; then exit 1 fi +if ! grep -qF -- "| Testcontainers | $pom_testcontainers_version (test scope) |" COMPATIBILITY.md; then + printf 'Testcontainers compatibility table must match POM version: %s\n' "$pom_testcontainers_version" >&2 + exit 1 +fi + for resource in \ src/test/resources/testcontainers.properties \ src/test/resources/docker-java.properties; do diff --git a/scripts/ci/check-external-consumer.sh b/scripts/ci/check-external-consumer.sh index a7aeac64..1006944d 100755 --- a/scripts/ci/check-external-consumer.sh +++ b/scripts/ci/check-external-consumer.sh @@ -3,9 +3,44 @@ set -euo pipefail root="$(cd "$(dirname "$0")/../.." && pwd)" cd "$root" -if [[ -n "${RESICACHE_JDK21:-}" ]]; then - export JAVA_HOME="$RESICACHE_JDK21" +if (( $# > 1 )); then + echo 'Usage: check-external-consumer.sh [candidate-directory]' >&2 + exit 1 fi +if (( $# == 1 )) && [[ ! -d "$1" ]]; then + echo "Candidate directory does not exist: $1" >&2 + exit 1 +fi +jdk_home="${RESICACHE_JDK21:-${JAVA_HOME:-}}" +if [[ -z "$jdk_home" ]]; then + if ! java_path="$(command -v java)"; then + echo 'JDK 21 is required: set RESICACHE_JDK21 or JAVA_HOME, or put it on PATH.' >&2 + exit 1 + fi + if ! java_settings="$("$java_path" -XshowSettings:properties -version 2>&1)"; then + echo "Cannot run Java from PATH: $java_path." >&2 + exit 1 + fi + jdk_home="$(awk '/^[[:space:]]*java.home[[:space:]]*=/ {sub(/^[^=]*=[[:space:]]*/, ""); print}' <<< "$java_settings")" + if [[ -z "$jdk_home" ]]; then + echo "Cannot determine java.home from PATH: $java_path." >&2 + exit 1 + fi +fi +if [[ ! -x "$jdk_home/bin/java" || ! -x "$jdk_home/bin/javac" ]]; then + echo "JDK 21 java and javac are required in $jdk_home/bin." >&2 + exit 1 +fi +if ! java_settings="$("$jdk_home/bin/java" -XshowSettings:properties -version 2>&1)"; then + echo "Cannot run Java from $jdk_home." >&2 + exit 1 +fi +java_version="$(awk '$1 == "java.specification.version" && $2 == "=" {print $3}' <<< "$java_settings")" +if [[ "$java_version" != 21 ]]; then + echo "JDK 21 is required; found Java ${java_version:-unknown} in $jdk_home." >&2 + exit 1 +fi +export JAVA_HOME="$jdk_home" if [[ ! -d "${1:-target/ci-candidate}" ]]; then ./mvnw clean package -DskipTests -B python3 scripts/ci/pipeline.py candidate target/ci-candidate diff --git a/scripts/ci/tests/test_consumer.py b/scripts/ci/tests/test_consumer.py new file mode 100644 index 00000000..87518d29 --- /dev/null +++ b/scripts/ci/tests/test_consumer.py @@ -0,0 +1,95 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class ConsumerBoundaryTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + scripts = self.root / 'scripts/ci' + scripts.mkdir(parents=True) + shutil.copy(Path(__file__).resolve().parents[1] / 'check-external-consumer.sh', scripts) + self.executable(self.root / 'mvnw', '#!/bin/sh\ntouch maven-called\nexit 74\n') + (scripts / 'pipeline.py').write_text( + 'import os, pathlib, sys\n' + 'pathlib.Path("selected-jdk").write_text(os.environ["JAVA_HOME"])\n' + 'sys.exit(73)\n') + self.env = os.environ.copy() + self.env.pop('JAVA_HOME', None) + self.env.pop('RESICACHE_JDK21', None) + + def executable(self, path, content): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + path.chmod(0o755) + + def jdk(self, name, version='21', compiler=True): + home = self.root / name + self.executable(home / 'bin/java', + '#!/bin/sh\necho " java.specification.version = ' + version + '" >&2\n' + 'echo " java.home = ' + str(home) + '" >&2\n') + if compiler: + self.executable(home / 'bin/javac', '#!/bin/sh\nexit 0\n') + return home + + def run_consumer(self, *args): + return subprocess.run(['bash', str(self.root / 'scripts/ci/check-external-consumer.sh'), *args], + cwd=self.root, env=self.env, capture_output=True, text=True) + + def test_missing_explicit_candidate_never_packages(self): + self.env['JAVA_HOME'] = str(self.jdk('jdk')) + result = self.run_consumer('missing-candidate') + self.assertNotEqual(0, result.returncode) + self.assertIn('Candidate directory does not exist', result.stderr) + self.assertFalse((self.root / 'maven-called').exists()) + self.assertFalse((self.root / 'selected-jdk').exists()) + + def test_wrong_or_incomplete_jdk_fails_before_packaging(self): + for home in [self.jdk('jdk17', '17'), self.jdk('jre21', compiler=False), self.root / 'missing-jdk']: + with self.subTest(home=home): + self.env['JAVA_HOME'] = str(home) + self.assertNotEqual(0, self.run_consumer().returncode) + self.assertFalse((self.root / 'maven-called').exists()) + + def test_jdk_precedence_and_path_fallback(self): + candidate = self.root / 'candidate' + candidate.mkdir() + path_jdk = self.jdk('path-jdk') + home_jdk = self.jdk('home-jdk') + override_jdk = self.jdk('override-jdk') + self.env['PATH'] = str(path_jdk / 'bin') + os.pathsep + self.env['PATH'] + for overrides, expected in [({}, path_jdk), + ({'JAVA_HOME': str(home_jdk)}, home_jdk), + ({'RESICACHE_JDK21': str(override_jdk)}, override_jdk)]: + self.env.update(overrides) + with self.subTest(expected=expected): + self.assertEqual(73, self.run_consumer(str(candidate)).returncode) + self.assertEqual(str(expected), (self.root / 'selected-jdk').read_text()) + self.assertFalse((self.root / 'maven-called').exists()) + + def test_invalid_override_does_not_fall_back_to_valid_java_home(self): + self.env['JAVA_HOME'] = str(self.jdk('jdk21')) + self.env['RESICACHE_JDK21'] = str(self.jdk('override17', '17')) + self.assertNotEqual(0, self.run_consumer().returncode) + self.assertFalse((self.root / 'maven-called').exists()) + + def test_path_shim_uses_reported_java_home(self): + candidate = self.root / 'candidate' + candidate.mkdir() + home = self.jdk('actual jdk') + shim = self.root / 'mise/shims/java' + self.executable(shim, '#!/bin/sh\nexec "$FIXTURE_JDK/bin/java" "$@"\n') + self.env['FIXTURE_JDK'] = str(home) + self.env['PATH'] = str(shim.parent) + os.pathsep + self.env['PATH'] + self.assertEqual(73, self.run_consumer(str(candidate)).returncode) + self.assertEqual(str(home), (self.root / 'selected-jdk').read_text()) + self.assertFalse((self.root / 'maven-called').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/ci/tests/test_docs_contracts.py b/scripts/ci/tests/test_docs_contracts.py new file mode 100644 index 00000000..17346d2b --- /dev/null +++ b/scripts/ci/tests/test_docs_contracts.py @@ -0,0 +1,78 @@ +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / 'scripts/ci/check-docs-contracts.sh' + + +class DocsContractTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + for source in ROOT.glob('*.md'): + shutil.copy2(source, self.root / source.name) + for directory in ('docs', 'src/main/java', 'src/test/resources'): + shutil.copytree(ROOT / directory, self.root / directory) + shutil.copy2(ROOT / 'pom.xml', self.root / 'pom.xml') + subprocess.run(['git', 'init', '-q'], cwd=self.root, check=True) + subprocess.run(['git', 'add', 'src/main/java'], cwd=self.root, check=True) + + def guard(self, succeeds): + result = subprocess.run(['bash', str(SCRIPT)], cwd=self.root, capture_output=True, text=True) + self.assertEqual(succeeds, result.returncode == 0, result.stdout + result.stderr) + + def replace(self, name, old, new): + path = self.root / name + contents = path.read_text() + self.assertIn(old, contents) + path.write_text(contents.replace(old, new)) + + def test_contract_cannot_be_satisfied_by_another_owner(self): + self.guard(True) + statement = 'PUT, PUT_IF_ABSENT, and CLEAN' + self.replace('COMPATIBILITY.md', statement, 'write operations') + with (self.root / 'README.md').open('a') as document: + document.write('\n' + statement + '\n') + self.guard(False) + + def test_historical_quotes_do_not_become_current_guidance(self): + for name in ('CHANGELOG.md', 'PERFORMANCE.md'): + with (self.root / name).open('a') as document: + document.write('\nHistorical example: Java 21+ and pr-checks.yml.\n') + self.guard(True) + with (self.root / 'docs/DEVELOPMENT.md').open('a') as document: + document.write('\nJava 21+\n') + self.guard(False) + + def test_version_change_requires_current_docs_and_resources(self): + ns = {'m': 'http://maven.apache.org/POM/4.0.0'} + pom = ET.parse(self.root / 'pom.xml').getroot() + dependency = next(node for node in pom.findall('m:dependencyManagement/m:dependencies/m:dependency', ns) + if node.findtext('m:artifactId', namespaces=ns) == 'testcontainers-bom') + version = dependency.findtext('m:version', namespaces=ns) + updated = '99.0.1' + self.replace('pom.xml', '' + version + '', '' + updated + '') + for name in ('src/test/resources/testcontainers.properties', 'src/test/resources/docker-java.properties'): + self.replace(name, 'testcontainers-bom:' + version, 'testcontainers-bom:' + updated) + self.guard(False) + self.replace('COMPATIBILITY.md', '| Testcontainers | ' + version, '| Testcontainers | ' + updated) + self.guard(True) + + def test_local_guard_preserves_readme_and_source_checks(self): + with (self.root / 'README.md').open('a') as document: + document.write('\n`BloomFilterProvider`\n') + self.guard(False) + self.replace('README.md', '\n`BloomFilterProvider`\n', '\n') + source = next((self.root / 'src/main/java').rglob('RedisCacheAutoConfiguration.java')) + source.unlink() + self.guard(False) + + +if __name__ == '__main__': + unittest.main()