diff --git a/Makefile b/Makefile index 12e92428e..014532605 100644 --- a/Makefile +++ b/Makefile @@ -193,8 +193,9 @@ test/java/sqlite: generate/fixtures verify/java-migrations $(java_mvn) -f java/pom.xml verify -Driver.test.database=sqlite -DexcludedGroups=postgres .PHONY: test/java/tools -test/java/tools: ## Test Java maintenance tools and Maven warning enforcement +test/java/tools: ## Test Java maintenance tools, credential handoff, and Maven warning enforcement bash java/bin/check-maven-warnings_test.sh + python3 -m unittest discover -s java/bin -p '*_test.py' go test ./java/bin/check-packages/*.go go test ./java/bin/sync-migrations/*.go diff --git a/java/.mvn/maven.config b/java/.mvn/maven.config new file mode 100644 index 000000000..76f401e91 --- /dev/null +++ b/java/.mvn/maven.config @@ -0,0 +1 @@ +-Daether.remoteRepositoryFilter.prefixes.resolvePrefixFiles.central=false diff --git a/java/bin/release-credentials.py b/java/bin/release-credentials.py new file mode 100644 index 000000000..1bd8892dc --- /dev/null +++ b/java/bin/release-credentials.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Keep release credentials in a private local handoff, outside the checkout.""" + +import argparse +import base64 +import getpass +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import warnings + + +FILES = {"credentials.json", "private-key.asc"} +PREFIX = "river-java-release-credentials-" +ROOT = Path(__file__).resolve().parents[2] + + +def check_permissions(path, directory=False): + info = path.lstat() + expected_type = stat.S_ISDIR if directory else stat.S_ISREG + if not expected_type(info.st_mode) or info.st_uid != os.getuid(): + raise ValueError(f"Expected an owned {'directory' if directory else 'regular file'}: {path}") + expected_mode = 0o700 if directory else 0o600 + if stat.S_IMODE(info.st_mode) != expected_mode: + raise ValueError(f"Expected permissions {expected_mode:o}: {path}") + + +def cleanup(directory): + load(directory) + if {path.name for path in directory.iterdir()} != FILES: + raise ValueError("Handoff contains unexpected files; refusing to remove it.") + for name in sorted(FILES): + (directory / name).unlink() + directory.rmdir() + print("Removed the temporary credential handoff.") + + +def load(directory): + if not directory.name.startswith(PREFIX): + raise ValueError("Expected a directory created by the prepare command.") + check_permissions(directory, directory=True) + if directory.resolve().is_relative_to(ROOT): + raise ValueError("Credential handoffs must be outside the checkout.") + for name in FILES: + check_permissions(directory / name) + credentials = json.loads((directory / "credentials.json").read_text()) + fields = ("CENTRAL_USERNAME", "CENTRAL_PASSWORD", "MAVEN_GPG_PASSPHRASE", "MAVEN_GPG_KEY_FINGERPRINT") + if credentials.get("schema_version") != 1 or any(not isinstance(credentials.get(name), str) for name in fields): + raise ValueError("Unrecognized credential handoff format.") + if not credentials["CENTRAL_USERNAME"] or not credentials["CENTRAL_PASSWORD"]: + raise ValueError("Both Central publishing-token fields are required.") + return credentials + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="action", required=True) + prepare_parser = commands.add_parser("prepare", help="Prompt locally and create a private handoff") + source = prepare_parser.add_mutually_exclusive_group(required=True) + source.add_argument("--key-file", type=Path, help="Existing ASCII-armored private-key export") + source.add_argument("--gpg-homedir", type=Path, help="Export once from an existing GnuPG home") + prepare_parser.add_argument("--fingerprint", default="", help="Full primary-key fingerprint; required with --gpg-homedir") + for action, help_text in ( + ("cleanup", "Remove the temporary handoff after release or abandonment"), + ("run", "Run a command with credentials in its environment and redacted output"), + ("update-token", "Replace only the Central token using hidden prompts"), + ): + command = commands.add_parser(action, help=help_text) + command.add_argument("directory", type=Path) + if action == "run": + command.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args() + os.umask(0o077) + warnings.simplefilter("error", getpass.GetPassWarning) + if args.action == "prepare": + if args.fingerprint and not re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", args.fingerprint): + parser.error("--fingerprint must be a full hexadecimal fingerprint without spaces") + if args.gpg_homedir and not args.fingerprint: + parser.error("--gpg-homedir requires --fingerprint") + prepare(args) + elif args.action == "cleanup": + cleanup(args.directory) + elif args.action == "run": + command = args.command[1:] if args.command[:1] == ["--"] else args.command + if not command: + parser.error("run requires a command after --") + return run(args.directory, command) + else: + update_token(args.directory) + return 0 + + +def prepare(args): + if Path(tempfile.gettempdir()).resolve().is_relative_to(ROOT): + raise ValueError("TMPDIR must be outside the checkout.") + key = args.key_file.expanduser().read_text() if args.key_file else None + if key is not None and "-----BEGIN PGP PRIVATE KEY BLOCK-----" not in key: + raise ValueError("--key-file must contain an ASCII-armored OpenPGP private key.") + credentials = { + "schema_version": 1, + "MAVEN_GPG_KEY_FINGERPRINT": args.fingerprint.upper(), + "MAVEN_GPG_PASSPHRASE": getpass.getpass("OpenPGP key passphrase (empty if unencrypted): "), + } + credentials.update(prompt_token()) + directory = Path(tempfile.mkdtemp(prefix=PREFIX)) + try: + private_key = directory / "private-key.asc" + if key is not None: + private_key.write_text(key) + else: + result = subprocess.run([ + "gpg", "--homedir", str(args.gpg_homedir.expanduser()), + "--batch", "--pinentry-mode", "loopback", "--passphrase-fd", "0", + "--armor", "--output", str(private_key), + "--export-secret-keys", args.fingerprint, + ], input=credentials["MAVEN_GPG_PASSPHRASE"] + "\n", text=True, capture_output=True) + if result.returncode: + raise ValueError("GnuPG export failed: " + redactor(credentials)(result.stderr).strip()) + if not private_key.is_file() or "-----BEGIN PGP PRIVATE KEY BLOCK-----" not in private_key.read_text(): + raise ValueError("GnuPG did not export an OpenPGP private key.") + private_key.chmod(0o600) + write_credentials(directory, credentials) + except BaseException: + shutil.rmtree(directory) + raise + # stdout contains only the path, so callers may capture it in a shell variable. + print(directory) + + +def prompt_token(): + credentials = { + "CENTRAL_USERNAME": getpass.getpass("Central publishing-token username: ").strip(), + "CENTRAL_PASSWORD": getpass.getpass("Central publishing-token password: ").strip(), + } + if not all(credentials.values()): + raise ValueError("Both Central publishing-token fields are required.") + return credentials + + +def redactor(credentials, key=""): + values = [credentials.get(name, "") for name in ("CENTRAL_USERNAME", "CENTRAL_PASSWORD", "MAVEN_GPG_PASSPHRASE")] + values.extend(key.splitlines()) + values.append(key) + if credentials.get("CENTRAL_USERNAME") and credentials.get("CENTRAL_PASSWORD"): + values.append(base64.b64encode((credentials["CENTRAL_USERNAME"] + ":" + credentials["CENTRAL_PASSWORD"]).encode()).decode()) + values.extend(json.dumps(value)[1:-1] for value in list(values)) + pattern = re.compile("|".join(re.escape(value) for value in sorted(set(values), key=len, reverse=True) if value)) + return lambda text: pattern.sub("[REDACTED]", text) if pattern.pattern else text + + +def run(directory, command): + credentials = load(directory) + key = (directory / "private-key.asc").read_text() + environment = os.environ.copy() + for name in ("CENTRAL_USERNAME", "CENTRAL_PASSWORD", "MAVEN_GPG_PASSPHRASE", "MAVEN_GPG_KEY_FINGERPRINT"): + environment[name] = credentials[name] + environment["MAVEN_GPG_KEY"] = key + if not credentials["MAVEN_GPG_KEY_FINGERPRINT"]: + environment.pop("MAVEN_GPG_KEY_FINGERPRINT", None) + redact = redactor(credentials, key) + with subprocess.Popen(command, env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace") as process: + try: + for line in process.stdout: + print(redact(line), end="", flush=True) + code = process.wait() + except BaseException: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise + return code if code >= 0 else 128 - code + + +def update_token(directory): + credentials = load(directory) + credentials.update(prompt_token()) + write_credentials(directory, credentials) + print("Updated the Central token; the signing key and passphrase are unchanged.") + + +def write_credentials(directory, credentials): + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", dir=directory, delete=False) as output: + temporary = Path(output.name) + json.dump(credentials, output) + output.write("\n") + temporary.chmod(0o600) + temporary.replace(directory / "credentials.json") + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (EOFError, getpass.GetPassWarning, OSError, ValueError) as error: + print(f"Release credentials: {error}", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + print("Cancelled.", file=sys.stderr) + sys.exit(130) diff --git a/java/bin/release_credentials_test.py b/java/bin/release_credentials_test.py new file mode 100644 index 000000000..c0458e27a --- /dev/null +++ b/java/bin/release_credentials_test.py @@ -0,0 +1,171 @@ +import argparse +import base64 +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + + +SPEC = importlib.util.spec_from_file_location("release_credentials", Path(__file__).with_name("release-credentials.py")) +release = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release) +KEY = "-----BEGIN PGP PRIVATE KEY BLOCK-----\nTEST-PRIVATE-MATERIAL\n-----END PGP PRIVATE KEY BLOCK-----\n" + + +class ReleaseCredentialsTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.key_file = self.root / "original.asc" + self.key_file.write_text(KEY) + self.umask = os.umask(0o077) + self.addCleanup(os.umask, self.umask) + + def prepare(self, fingerprint=""): + args = argparse.Namespace(key_file=self.key_file, gpg_homedir=None, fingerprint=fingerprint) + output = io.StringIO() + with patch.object(release.tempfile, "tempdir", str(self.root)), patch.object(release.getpass, "getpass", side_effect=["key-passphrase", "token-user", "token-password"]), contextlib.redirect_stdout(output): + release.prepare(args) + return Path(output.getvalue().strip()) + + def test_cleanup_preserves_original_key_and_refuses_unexpected_files(self): + directory = self.prepare() + extra = directory / "unrelated" + extra.write_text("keep") + with self.assertRaisesRegex(ValueError, "unexpected files"): + release.cleanup(directory) + self.assertTrue((directory / "credentials.json").exists()) + extra.unlink() + with contextlib.redirect_stdout(io.StringIO()): + release.cleanup(directory) + self.assertFalse(directory.exists()) + self.assertEqual(self.key_file.read_text(), KEY) + + def test_cli_runs_command_after_separator(self): + directory = self.prepare() + result = subprocess.run([ + sys.executable, str(Path(__file__).with_name("release-credentials.py")), + "run", str(directory), "--", sys.executable, "-c", + "import os; print(os.environ['CENTRAL_PASSWORD']); print('finished')", + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("token-password", result.stdout + result.stderr) + self.assertIn("[REDACTED]", result.stdout) + self.assertIn("finished", result.stdout) + + def test_failed_export_removes_partial_handoff(self): + args = argparse.Namespace(key_file=None, gpg_homedir=self.root, fingerprint="A" * 40) + before = set(self.root.iterdir()) + failure = subprocess.CompletedProcess([], 1, stdout="", stderr="bad key-passphrase") + with patch.object(release.tempfile, "tempdir", str(self.root)), patch.object(release.getpass, "getpass", side_effect=["key-passphrase", "token-user", "token-password"]), patch.object(release.subprocess, "run", return_value=failure): + with self.assertRaises(ValueError) as error: + release.prepare(args) + self.assertNotIn("key-passphrase", str(error.exception)) + self.assertEqual(set(self.root.iterdir()), before) + + def test_gpg_export_passes_passphrase_over_stdin(self): + args = argparse.Namespace(key_file=None, gpg_homedir=self.root, fingerprint="A" * 40) + def export(command, **kwargs): + self.assertNotIn("key-passphrase", command) + self.assertEqual(kwargs["input"], "key-passphrase\n") + self.assertIn("--passphrase-fd", command) + Path(command[command.index("--output") + 1]).write_text(KEY) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + output = io.StringIO() + with patch.object(release.tempfile, "tempdir", str(self.root)), patch.object(release.getpass, "getpass", side_effect=["key-passphrase", "token-user", "token-password"]), patch.object(release.subprocess, "run", side_effect=export), contextlib.redirect_stdout(output): + release.prepare(args) + directory = Path(output.getvalue().strip()) + self.assertEqual(release.load(directory)["MAVEN_GPG_KEY_FINGERPRINT"], "A" * 40) + + def test_handoff_permissions_and_no_gpg_for_exported_key(self): + with patch.object(release.subprocess, "run", side_effect=AssertionError("GnuPG must not run")): + directory = self.prepare() + self.assertEqual(directory.stat().st_mode & 0o777, 0o700) + for name in release.FILES: + self.assertEqual((directory / name).stat().st_mode & 0o777, 0o600) + self.assertEqual((directory / "private-key.asc").read_text(), KEY) + self.assertEqual(release.load(directory)["CENTRAL_USERNAME"], "token-user") + + def test_missing_hidden_input_does_not_save_credentials(self): + args = argparse.Namespace(key_file=self.key_file, gpg_homedir=None, fingerprint="") + before = set(self.root.iterdir()) + with patch.object(release.getpass, "getpass", side_effect=release.getpass.GetPassWarning("No hidden input")): + with self.assertRaises(release.getpass.GetPassWarning): + release.prepare(args) + self.assertEqual(set(self.root.iterdir()), before) + + def test_rejects_handoffs_inside_checkout(self): + directory = self.prepare() + with patch.object(release, "ROOT", self.root): + with self.assertRaisesRegex(ValueError, "outside the checkout"): + release.load(directory) + + def test_rejects_loose_permissions_and_symlinks(self): + directory = self.prepare() + credentials = directory / "credentials.json" + credentials.chmod(0o644) + with self.assertRaisesRegex(ValueError, "permissions 600"): + release.load(directory) + credentials.chmod(0o600) + external = self.root / "external.json" + credentials.rename(external) + credentials.symlink_to(external) + with self.assertRaisesRegex(ValueError, "regular file"): + release.load(directory) + + def test_rejects_public_key_and_empty_token(self): + self.key_file.write_text("-----BEGIN PGP PUBLIC KEY BLOCK-----") + args = argparse.Namespace(key_file=self.key_file, gpg_homedir=None, fingerprint="") + with self.assertRaisesRegex(ValueError, "private key"): + release.prepare(args) + self.key_file.write_text(KEY) + with patch.object(release.getpass, "getpass", side_effect=["pass", "", "token"]): + with self.assertRaisesRegex(ValueError, "Both Central"): + release.prepare(args) + + def test_run_injects_credentials_redacts_output_and_preserves_exit_status(self): + directory = self.prepare() + code = '''import base64, json, os, sys +assert os.environ['MAVEN_GPG_KEY'].startswith('-----BEGIN PGP PRIVATE KEY BLOCK-----') +assert 'MAVEN_GPG_KEY_FINGERPRINT' not in os.environ +assert os.environ['MAVEN_GPG_PASSPHRASE'] == 'key-passphrase' +for name in ('CENTRAL_USERNAME', 'CENTRAL_PASSWORD', 'MAVEN_GPG_KEY', 'MAVEN_GPG_PASSPHRASE'): + print(os.environ[name], flush=True) +print(json.dumps(os.environ['MAVEN_GPG_KEY']), file=sys.stderr, flush=True) +print(base64.b64encode((os.environ['CENTRAL_USERNAME'] + ':' + os.environ['CENTRAL_PASSWORD']).encode()).decode(), flush=True) +print('nonsecret build output', flush=True) +sys.exit(7) +''' + output = io.StringIO() + with patch.dict(os.environ, {"MAVEN_GPG_KEY_FINGERPRINT": "inherited-wrong-key"}), contextlib.redirect_stdout(output): + result = release.run(directory, [sys.executable, "-c", code]) + self.assertEqual(result, 7) + text = output.getvalue() + for value in ("token-user", "token-password", "key-passphrase", "TEST-PRIVATE-MATERIAL", base64.b64encode(b"token-user:token-password").decode()): + self.assertNotIn(value, text) + self.assertIn("nonsecret build output", text) + self.assertIn("[REDACTED]", text) + + def test_update_token_preserves_signing_credentials(self): + directory = self.prepare() + before = release.load(directory) + with patch.object(release.getpass, "getpass", side_effect=["new-user", "new-password"]), contextlib.redirect_stdout(io.StringIO()): + release.update_token(directory) + after = release.load(directory) + self.assertEqual(after["CENTRAL_USERNAME"], "new-user") + self.assertEqual(after["CENTRAL_PASSWORD"], "new-password") + self.assertEqual(after["MAVEN_GPG_PASSPHRASE"], before["MAVEN_GPG_PASSPHRASE"]) + self.assertEqual((directory / "private-key.asc").read_text(), KEY) + self.assertEqual({path.name for path in directory.iterdir()}, release.FILES) + + +if __name__ == "__main__": + unittest.main() diff --git a/java/docs/development.md b/java/docs/development.md index 708c96149..23a4315e5 100644 --- a/java/docs/development.md +++ b/java/docs/development.md @@ -109,10 +109,48 @@ Portal. Ordinary builds do not activate signing or Central publishing. See the [Central Maven plugin guide](https://central.sonatype.org/publish/publish-portal-maven/) for the upload and validation lifecycle. +### Credentials for a terminal or agent release + +The same commands below work for a human or an agent. A human prepares credentials +in a terminal with hidden prompts, using the **generated publishing-token username +and password** from [Central](https://central.sonatype.com/usertoken): + +```sh +JAVA_RELEASE_CREDENTIALS=$(python3 java/bin/release-credentials.py prepare \ + --key-file /absolute/path/to/private-key.asc) && +export JAVA_RELEASE_CREDENTIALS && +printf '%s\n' "$JAVA_RELEASE_CREDENTIALS" +``` + +The helper copies the private-key export and saves the passphrase and token in +mode-600 files inside a mode-700 temporary directory outside the checkout. These +are local plaintext credentials protected by filesystem permissions. For an +existing GnuPG keyring, replace `--key-file ...` with +`--gpg-homedir "$HOME/.gnupg" --fingerprint PRIMARY_KEY_FINGERPRINT` (use your +custom home directory if applicable). This invokes GnuPG once to export the key; +Maven signing still uses Bouncy Castle. `--fingerprint` also works with `--key-file`. + +For an agent release, give the agent only the printed directory path and ask it +to set `JAVA_RELEASE_CREDENTIALS` to that path. The agent must use the helper's +`run` command to supply secrets to publishing tools; never print the handoff's +contents or dump its environment. `run` injects credentials into the child +environment and redacts their values from stdout and stderr. Use trusted commands +without debug logging. The helper refuses prompts when hidden terminal input is unavailable. + +If Central returns `401: Invalid token`, the human can replace just the token: + +```sh +python3 java/bin/release-credentials.py update-token "$JAVA_RELEASE_CREDENTIALS" +``` + +Keep the directory until the release finishes, then run the cleanup in step 9. +Run cleanup as well if the release is abandoned; it removes the temporary copies, +leaving the original key and keyring intact. + ## Releasing a new version Run these commands from the repository root, in the same shell, with JDK 21 or 25, -Maven, Go, `golangci-lint`, Python 3, and `gh` available. Complete the signing and +Maven, Go, `golangci-lint`, Python 3.9+, and `gh` available. Complete the signing and publishing setup above first. Stop if any command fails. Java versions have no leading `v`; tags use `java/vX.Y.Z`. @@ -168,26 +206,23 @@ leading `v`; tags use `java/vX.Y.Z`. git tag "java/v$VERSION" -m "release java/v$VERSION" ``` -5. Load your private key, then enter its passphrase, the Central token username, and the Central token password at the three `read` commands. Build, test, sign, and upload the release for validation: +5. Prepare `JAVA_RELEASE_CREDENTIALS` as described above. The human or agent can then build, test, sign, and upload the release for validation: ```sh - export MAVEN_GPG_KEY="$(cat /absolute/path/to/private-key.asc)" - read -r -s MAVEN_GPG_PASSPHRASE - read -r CENTRAL_USERNAME - read -r -s CENTRAL_PASSWORD - export MAVEN_GPG_PASSPHRASE CENTRAL_USERNAME CENTRAL_PASSWORD test -z "$(git status --porcelain)" && test "$(git rev-parse HEAD)" = "$(git rev-parse "java/v$VERSION^{commit}")" && make generate/fixtures verify/java-migrations && - ./java/bin/check-maven-warnings.sh mvn --batch-mode --no-transfer-progress \ + python3 java/bin/release-credentials.py run "$JAVA_RELEASE_CREDENTIALS" -- \ + ./java/bin/check-maven-warnings.sh mvn --batch-mode --no-transfer-progress \ -Dstyle.color=never -f java/pom.xml -Prelease clean deploy \ -Driver.test.database=sqlite -DexcludedGroups=postgres \ -Dmaven.install.skip=true -Dcentral.skipUpload=false - unset MAVEN_GPG_KEY MAVEN_GPG_PASSPHRASE CENTRAL_USERNAME CENTRAL_PASSWORD ``` 6. In the [Central Portal deployments page](https://central.sonatype.com/publishing/deployments), open the validated deployment, confirm it contains `river-java`, `river`, and `river-cli` at `$VERSION`, and click **Publish**. Wait for **Published**. If uploading timed out, check this page before retrying. + An agent can use the [Portal API](https://central.sonatype.org/publish/publish-portal-api/) through the credential helper's `run` command. Construct authorization headers from the child environment in memory, never in command arguments. Record the deployment ID; check its status and coordinates before publishing, then wait for `PUBLISHED`. Reuse an existing validated deployment or signed bundle when retrying; do not upload duplicates while publication is pending. + 7. Verify the published CLI and its transitive library/parent dependencies using a fresh Maven cache. If Central has not propagated the release yet, wait and rerun with a new temporary directory: ```sh @@ -199,12 +234,21 @@ leading `v`; tags use `java/vX.Y.Z`. git push origin "java/v$VERSION" ``` + If Maven reports a stale Central prefixes file or `prefixes: Path ... NOT allowed`, retry the download with `-Daether.remoteRepositoryFilter.prefixes.resolvePrefixFiles.central=false`. This skips Central's automatic prefix index for that invocation; see [Maven's filter configuration](https://maven.apache.org/resolver/configuration.html). + 8. Create a [GitHub release](https://github.com/riverqueue/river/releases/new) for `java/v$VERSION`, copy that version's Java changelog notes into the body, and attach the downloaded `river-cli-$VERSION-all.jar`. Mark alpha, beta, and RC versions as prereleases. +9. Remove the temporary credential handoff: + + ```sh + python3 java/bin/release-credentials.py cleanup "$JAVA_RELEASE_CREDENTIALS" + unset JAVA_RELEASE_CREDENTIALS + ``` + ## Tests and checks From the repository root, with Go (the version in `go.work`), JDK 21 or 25, -and Maven installed: +Maven, and Python 3.9+ installed: ```sh make test/java/conformance @@ -255,6 +299,7 @@ include their tests and lint checks; `make test/java/tools` and `make lint/java/tools` run only the tool checks. From `java/`, use `make test/tools` and `make lint/tools`. These checks run in Java CI, separately from the root Go-only `make test` and `make lint` targets. +The tool tests also cover the Python release credential helper. The legacy adapter is not installed or deployed as a Maven artifact. `java/go.mod` excludes this directory from Go module archives and package discovery. The Make targets invoke the Go tools by file from the root workspace; @@ -281,6 +326,12 @@ messages from packaging and other plugins. Local checks and CI use these same targets. Direct Maven invocations also reject compiler warnings through the parent POM's `failOnWarning` setting. +`java/.mvn/maven.config` disables automatic discovery of Central's repository +prefix index. The index can lag newly published namespaces, rejecting available +River artifacts or emitting warnings that fail these checks. This applies to +Make targets and direct Maven builds; explicit local prefix filters and other +repositories retain their normal behavior. See [Maven's filter configuration](https://maven.apache.org/resolver/configuration.html). + Javadoc also fails on warnings. Its doclint configuration checks references, syntax, HTML, and accessibility, but permits missing comments and tags because the existing API documentation does not annotate every parameter and return value.