From 18b06e5a1b9cd51d70dee71643caf4da4a94a710 Mon Sep 17 00:00:00 2001 From: Brandur Date: Sun, 25 Oct 2026 11:59:49 -0500 Subject: [PATCH 1/5] [java] Publish complete GitHub release assets for the Maven proxy --- .github/workflows/java.yaml | 6 +- Makefile | 7 +- java/Makefile | 4 +- java/README.md | 13 +- java/bin/github-release.py | 461 ++++++++++++++++++++++++++++++++ java/bin/github_release_test.py | 221 +++++++++++++++ java/docs/development.md | 226 ++++++++-------- 7 files changed, 826 insertions(+), 112 deletions(-) create mode 100644 java/bin/github-release.py create mode 100644 java/bin/github_release_test.py diff --git a/.github/workflows/java.yaml b/.github/workflows/java.yaml index 61f1ab706..64c92b1ef 100644 --- a/.github/workflows/java.yaml +++ b/.github/workflows/java.yaml @@ -60,14 +60,14 @@ jobs: - name: Compile, check formatting, and lint maintenance tools run: make lint/java - - name: Test Go maintenance tools + - name: Test maintenance and release tools run: make test/java/tools - name: Verify canonical migration mirror run: make verify/java-migrations - - name: Check publishable archives - run: make check/java/package + - name: Check and stage complete GitHub release archives + run: make check/java/github-release java_versions: name: Test (Java ${{ matrix.java-version }}, SQLite) diff --git a/Makefile b/Makefile index 12e92428e..7955d7781 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, release tooling, 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 @@ -278,6 +279,10 @@ check/java/package: ## Build and verify publishable Maven archives without publi check/java/package: build/java go run ./java/bin/check-packages/main.go +.PHONY: check/java/github-release +check/java/github-release: ## Build, test, and stage all Java GitHub release assets without publishing + python3 java/bin/github-release.py prepare + .PHONY: check/java/release check/java/release: ## Build a signed Maven Central bundle without uploading (requires an OpenPGP key) check/java/release: generate/fixtures verify/java-migrations diff --git a/java/Makefile b/java/Makefile index c103031c3..ab08f0ca0 100644 --- a/java/Makefile +++ b/java/Makefile @@ -1,4 +1,4 @@ -.PHONY: test test/conformance test/postgres test/sqlite test/tools lint lint/tools format package package/release +.PHONY: test test/conformance test/postgres test/sqlite test/tools lint lint/tools format package package/github-release package/release test: $(MAKE) -C .. test/java test/conformance: @@ -17,5 +17,7 @@ format: mvn spotless:apply package: $(MAKE) -C .. check/java/package +package/github-release: + $(MAKE) -C .. check/java/github-release package/release: $(MAKE) -C .. check/java/release diff --git a/java/README.md b/java/README.md index f31742519..212702325 100644 --- a/java/README.md +++ b/java/README.md @@ -9,6 +9,15 @@ retry, or work them using the same database. Java 21 or newer is required. Add `com.riverqueue:river:0.1.0-alpha.1` and a JDBC driver to your application's dependencies. Jackson 3 is included transitively. ```xml + + + river + https://riverqueue.com/maven + fail + false + + + com.riverqueue river @@ -21,6 +30,8 @@ Java 21 or newer is required. Add `com.riverqueue:river:0.1.0-alpha.1` and a JDB ``` +The River repository is public and requires no credentials. Maven Central supplies third-party dependencies. + For SQLite, replace the Postgres JDBC dependency with `org.xerial:sqlite-jdbc:3.53.4.0`. To install a local copy of River from source, see the [development guide](docs/development.md#building-from-source). ## Quick start @@ -80,7 +91,7 @@ for each operation. River never closes an application-owned data source. An The `river-cli` executable JAR includes Postgres and SQLite JDBC drivers and runs with Java 21 or newer. It needs no application classpath. -For a local build, see the [development guide](docs/development.md#building-from-source). Set `RIVER_CLI` to the executable JAR, then run: +Download `river-cli-VERSION-all.jar` from the [Java GitHub releases](https://github.com/riverqueue/river/releases?q=java%2Fv), or build it using the [development guide](docs/development.md#building-from-source). Set `RIVER_CLI` to the executable JAR, then run: ```sh RIVER_CLI=/path/to/river-cli-0.1.0-alpha.1-all.jar diff --git a/java/bin/github-release.py b/java/bin/github-release.py new file mode 100644 index 000000000..ad0be2e0c --- /dev/null +++ b/java/bin/github-release.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 +"""Stage Java release artifacts, upload a GitHub draft, or verify a Maven repository.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_DIRECTORY = ROOT / "java/target/github-release" +GROUP = "com.riverqueue" +NS = {"m": "http://maven.apache.org/POM/4.0.0"} + + +def artifact_files(version): + result = [] + for artifact in ("river-java", "river", "river-cli"): + suffixes = [".pom"] + if artifact != "river-java": + suffixes += [".jar", "-sources.jar", "-javadoc.jar"] + if artifact == "river-cli": + suffixes += ["-all.jar"] + result.extend( + (artifact, f"{artifact}-{version}{suffix}") for suffix in suffixes + ) + return result + + +def checked_version(version): + number = r"(?:0|[1-9][0-9]*)" + prerelease = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + if ( + not re.fullmatch( + rf"{number}\.{number}\.{number}(?:-{prerelease}(?:\.{prerelease})*)?", + version, + ) + or "snapshot" in version.lower() + ): + raise ValueError( + "Expected a canonical release version, without v, SNAPSHOT, or build metadata." + ) + return version + + +def command(args, **kwargs): + return subprocess.run(args, check=True, **kwargs) + + +def gh_json(args): + return json.loads(command(["gh", *args], capture_output=True, text=True).stdout) + + +def download(url): + request = urllib.request.Request( + url, headers={"User-Agent": "river-java-release/1.0"} + ) + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + + +def git(*args): + return command( + ["git", *args], cwd=ROOT, capture_output=True, text=True + ).stdout.strip() + + +def load_manifest(directory): + manifest = json.loads((directory / "manifest.json").read_text()) + if manifest.get("schema_version") != 1: + raise ValueError("Unrecognized release manifest.") + version = checked_version(manifest["version"]) + expected = {name for _, name in artifact_files(version)} + if set(manifest["files"]) != expected: + raise ValueError( + "Release manifest must contain the complete public artifact set." + ) + for name, info in manifest["files"].items(): + path = directory / name + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Expected a regular release file: {name}") + if ( + path.stat().st_size != info["size"] + or hashlib.sha256(path.read_bytes()).hexdigest() != info["sha256"] + ): + raise ValueError(f"Staged artifact changed: {name}") + return manifest + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + actions = parser.add_subparsers(dest="action", required=True) + prepare_parser = actions.add_parser( + "prepare", help="Build, test, and stage all public POMs/JARs locally" + ) + prepare_parser.add_argument("--directory", type=Path, default=DEFAULT_DIRECTORY) + upload_parser = actions.add_parser( + "upload", help="Create or resume a draft and verify every uploaded asset" + ) + upload_parser.add_argument("--directory", type=Path, default=DEFAULT_DIRECTORY) + upload_parser.add_argument("--repo", default="riverqueue/river") + upload_parser.add_argument("--notes-file", required=True, type=Path) + verify_parser = actions.add_parser( + "verify", help="Resolve and run a release through a public Maven repository" + ) + verify_parser.add_argument("--version", required=True, type=checked_version) + verify_parser.add_argument("--repository", default="https://riverqueue.com/maven") + verify_parser.add_argument( + "--directory", + type=Path, + help="Also compare downloads with this staged manifest", + ) + args = parser.parse_args() + if args.action == "prepare": + prepare(args.directory.resolve()) + elif args.action == "upload": + upload(args.directory.resolve(), args.repo, args.notes_file.resolve()) + else: + verify(args.repository, args.version, args.directory) + + +def prepare(directory): + version = checked_version( + ET.parse(ROOT / "java/pom.xml").findtext("m:version", namespaces=NS) + ) + commit = git("rev-parse", "HEAD") + clean = not git("status", "--porcelain") + if directory.exists() and directory != DEFAULT_DIRECTORY: + raise ValueError( + "Choose an empty staging directory; custom directories are never overwritten." + ) + command(["make", "generate/fixtures", "verify/java-migrations"], cwd=ROOT) + with tempfile.TemporaryDirectory(prefix="river-java-stage-") as temporary: + repository = Path(temporary) / "repository" + destination = "github-staging::" + repository.as_uri() + # File deployment uses Maven's canonical artifact names/POMs without + # installing release artifacts locally or activating Central signing. + command( + [ + str(ROOT / "java/bin/check-maven-warnings.sh"), + "mvn", + "--batch-mode", + "--no-transfer-progress", + "-Dstyle.color=never", + "-f", + "java/pom.xml", + "-P!release", + "clean", + "verify", + "org.apache.maven.plugins:maven-deploy-plugin:3.2.0:deploy", + "-Driver.test.database=sqlite", + "-DexcludedGroups=postgres", + "-DaltDeploymentRepository=" + destination, + "-DaltReleaseDeploymentRepository=" + destination, + ], + cwd=ROOT, + ) + command(["go", "run", "./java/bin/check-packages/main.go"], cwd=ROOT) + stage = Path(temporary) / "assets" + stage.mkdir() + files = {} + for artifact, filename in artifact_files(version): + source = repository / "com/riverqueue" / artifact / version / filename + validate_artifact(source.read_bytes(), artifact, filename, version) + shutil.copyfile(source, stage / filename) + files[filename] = { + "sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "size": source.stat().st_size, + } + if (repository / "com/riverqueue/river-conformance").exists(): + raise ValueError("The conformance adapter must not be published.") + if commit != git("rev-parse", "HEAD"): + raise ValueError("Checkout changed during the build.") + manifest = { + "schema_version": 1, + "version": version, + "commit": commit, + "clean": clean and not git("status", "--porcelain"), + "files": files, + } + (stage / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + directory.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(stage), directory) + print(f"Staged {len(files)} artifacts for {version} in {directory}") + if not manifest["clean"]: + print( + "This was a development build; upload requires artifacts prepared from a clean release commit." + ) + + +def release_details(repo, tag): + result = subprocess.run( + ["gh", "release", "view", tag, "--repo", repo, "--json", "databaseId"], + capture_output=True, + text=True, + ) + if result.returncode: + if "release not found" in result.stderr.lower(): + return None + raise RuntimeError(result.stderr.strip()) + release_id = json.loads(result.stdout)["databaseId"] + return gh_json(["api", f"repos/{repo}/releases/{release_id}"]) + + +def resolved_from_proxy(provenance, filename): + # Newer Maven Resolver versions qualify repository IDs with a URL hash. + return ( + re.search( + rf"^{re.escape(filename)}>river-release-check(?:-[0-9a-f]{{40}})?=$", + provenance, + re.M, + ) + is not None + ) + + +def upload(directory, repo, notes_file): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo): + raise ValueError("Expected a GitHub owner/repository.") + manifest = load_manifest(directory) + if ( + not manifest["clean"] + or git("status", "--porcelain") + or git("rev-parse", "HEAD") != manifest["commit"] + ): + raise ValueError( + "Upload requires a clean checkout at the commit used to prepare the artifacts." + ) + if not notes_file.is_file(): + raise ValueError("Release notes file does not exist.") + tag = "java/v" + manifest["version"] + if git("rev-parse", tag + "^{commit}") != manifest["commit"]: + raise ValueError("Local release tag does not match the prepared commit.") + remote = gh_json(["api", f"repos/{repo}/git/ref/tags/{tag}"])["object"] + for _ in range(4): + if remote["type"] != "tag": + break + remote = gh_json(["api", f"repos/{repo}/git/tags/{remote['sha']}"])["object"] + if remote["type"] != "commit" or remote["sha"] != manifest["commit"]: + raise ValueError("Push the matching release tag before uploading assets.") + release = release_details(repo, tag) + if release is None: + args = [ + "gh", + "release", + "create", + tag, + "--repo", + repo, + "--verify-tag", + "--draft", + "--title", + tag, + "--notes-file", + str(notes_file), + ] + if "-" in manifest["version"]: + args += ["--prerelease", "--latest=false"] + command(args) + release = release_details(repo, tag) + if not release["draft"]: + raise ValueError( + "Only draft releases may be uploaded by this script; published versions are immutable." + ) + pending = verify_assets(release["assets"], manifest, allow_missing=True) + if pending: + command( + [ + "gh", + "release", + "upload", + tag, + "--repo", + repo, + *[str(directory / name) for name in pending], + ] + ) + verify_assets(release_details(repo, tag)["assets"], manifest) + print(f"All {len(manifest['files'])} assets verified on draft {tag}.") + print(f"Publish with: gh release edit {tag} --repo {repo} --draft=false") + + +def validate_artifact(data, artifact, filename, version): + if filename.endswith(".pom"): + if b"\n") + repository_xml = ET.Element("repositories") + entry = ET.SubElement(repository_xml, "repository") + ET.SubElement(entry, "id").text = "river-release-check" + ET.SubElement(entry, "url").text = repository + ET.SubElement(ET.SubElement(entry, "releases"), "checksumPolicy").text = "fail" + ET.SubElement(ET.SubElement(entry, "snapshots"), "enabled").text = "false" + (work / "pom.xml").write_text( + f"4.0.0checkcheck1{ET.tostring(repository_xml, encoding='unicode')}{GROUP}river-cli{version}" + ) + environment = os.environ.copy() + environment.update(MAVEN_ARGS="", MAVEN_OPTS="") + for name in list(environment): + if name.startswith(("MAVEN_GPG_", "CENTRAL_")): + environment.pop(name) + args = [ + "mvn", + "--batch-mode", + "--no-transfer-progress", + "--strict-checksums", + "-s", + str(settings), + "-gs", + str(settings), + "-Dmaven.repo.local=" + str(work / "m2"), + ] + plugin = "org.apache.maven.plugins:maven-dependency-plugin:3.11.0" + for goals in ( + [plugin + ":build-classpath", "-Dmdep.outputFile=classpath.txt"], + [plugin + ":get", f"-Dartifact={GROUP}:river-cli:{version}:jar:all"], + ): + command(args + goals, cwd=work, env=environment) + for artifact, filename in artifact_files(version): + if "-sources.jar" in filename or "-javadoc.jar" in filename: + continue + path = work / "m2/com/riverqueue" / artifact / version / filename + if hashlib.sha256(path.read_bytes()).hexdigest() != downloaded[filename]: + raise ValueError(f"Maven downloaded different bytes: {filename}") + provenance = (path.parent / "_remote.repositories").read_text() + if not resolved_from_proxy(provenance, filename): + raise ValueError(f"Maven resolved {filename} from another repository.") + classpath = (work / "classpath.txt").read_text().strip() + (work / "Smoke.java").write_text( + 'public class Smoke { public static void main(String[] args) { if (!com.riverqueue.Client.class.getName().equals("com.riverqueue.Client")) throw new AssertionError(); if (!tools.jackson.databind.json.JsonMapper.builder().build().writeValueAsString("river").equals("\\"river\\"")) throw new AssertionError(); } }' + ) + command( + ["javac", "--release", "21", "-cp", classpath, "Smoke.java"], + cwd=work, + env=environment, + ) + command( + ["java", "-cp", "." + os.pathsep + classpath, "Smoke"], + cwd=work, + env=environment, + ) + cli = ( + work + / "m2/com/riverqueue/river-cli" + / version + / f"river-cli-{version}-all.jar" + ) + output = command( + ["java", "-jar", str(cli), "--version"], + cwd=work, + env=environment, + capture_output=True, + text=True, + ).stdout.strip() + if output != "river " + version: + raise ValueError(f"CLI version differs: {output}") + print( + f"Verified all 10 artifacts, fresh Maven resolution, consumer compilation, and {output}." + ) + + +if __name__ == "__main__": + try: + main() + except ( + OSError, + ValueError, + RuntimeError, + subprocess.CalledProcessError, + ET.ParseError, + zipfile.BadZipFile, + ) as error: + print(f"Java release: {error}", file=sys.stderr) + sys.exit(1) diff --git a/java/bin/github_release_test.py b/java/bin/github_release_test.py new file mode 100644 index 000000000..3eda07fa1 --- /dev/null +++ b/java/bin/github_release_test.py @@ -0,0 +1,221 @@ +import contextlib +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location( + "github_release", Path(__file__).with_name("github-release.py") +) +release = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release) + + +class GitHubReleaseTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.version = "0.1.0-alpha.1" + self.manifest = { + "schema_version": 1, + "version": self.version, + "commit": "a" * 40, + "clean": True, + "files": {}, + } + for _, name in release.artifact_files(self.version): + data = name.encode() + (self.root / name).write_bytes(data) + self.manifest["files"][name] = { + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + } + self.write_manifest() + self.assets = [ + { + "name": name, + "state": "uploaded", + "size": data["size"], + "digest": "sha256:" + data["sha256"], + } + for name, data in self.manifest["files"].items() + ] + self.notes = self.root / "notes.md" + self.notes.write_text("Release notes") + + def write_manifest(self): + (self.root / "manifest.json").write_text(json.dumps(self.manifest)) + + def mock_git(self, *args): + return "" if args[0] == "status" else self.manifest["commit"] + + def test_artifacts_include_parent_and_all_classifiers_without_conformance(self): + names = {name for _, name in release.artifact_files(self.version)} + self.assertEqual(len(names), 10) + self.assertIn("river-java-" + self.version + ".pom", names) + self.assertIn("river-cli-" + self.version + "-all.jar", names) + self.assertFalse(any("conformance" in name for name in names)) + + def test_manifest_rejects_missing_extra_changed_and_symlinked_files(self): + release.load_manifest(self.root) + name = next(iter(self.manifest["files"])) + (self.root / name).write_text("changed") + with self.assertRaisesRegex(ValueError, "changed"): + release.load_manifest(self.root) + (self.root / name).unlink() + (self.root / name).symlink_to(self.notes) + with self.assertRaisesRegex(ValueError, "regular"): + release.load_manifest(self.root) + del self.manifest["files"][name] + self.write_manifest() + with self.assertRaisesRegex(ValueError, "complete"): + release.load_manifest(self.root) + self.manifest["files"]["../unexpected.jar"] = {} + self.write_manifest() + with self.assertRaisesRegex(ValueError, "complete"): + release.load_manifest(self.root) + + def test_pom_coordinates_parent_inheritance_and_packaging(self): + parent = f"com.riverqueueriver-java{self.version}" + xml = f'{parent}river' + release.validate_artifact(xml.encode(), "river", "river.pom", self.version) + with self.assertRaisesRegex(ValueError, "coordinates"): + release.validate_artifact( + xml.encode(), "river-cli", "river-cli.pom", self.version + ) + xml = xml.replace( + "river", "river-java" + ) + with self.assertRaisesRegex(ValueError, "packaging"): + release.validate_artifact( + xml.encode(), "river-java", "river-java.pom", self.version + ) + release.validate_artifact( + xml.replace("", "pom").encode(), + "river-java", + "river-java.pom", + self.version, + ) + + def test_repository_tracking_accepts_old_and_url_qualified_ids(self): + name = "river-" + self.version + ".jar" + for repository in ("river-release-check", "river-release-check-" + "a" * 40): + self.assertTrue( + release.resolved_from_proxy(f"{name}>{repository}=\n", name) + ) + for repository in ( + "", + "central", + "central-" + "a" * 40, + "river-release-check-other", + ): + self.assertFalse( + release.resolved_from_proxy(f"{name}>{repository}=\n", name) + ) + self.assertFalse( + release.resolved_from_proxy(f"different.jar>river-release-check=\n", name) + ) + + def test_upload_creates_prerelease_draft_for_annotated_tag(self): + before = {"draft": True, "assets": []} + after = {"draft": True, "assets": self.assets} + remote = [ + {"object": {"type": "tag", "sha": "b" * 40}}, + {"object": {"type": "commit", "sha": self.manifest["commit"]}}, + ] + with ( + patch.object(release, "git", side_effect=self.mock_git), + patch.object(release, "gh_json", side_effect=remote), + patch.object(release, "release_details", side_effect=[None, before, after]), + patch.object(release, "command") as command, + contextlib.redirect_stdout(io.StringIO()), + ): + release.upload(self.root, "riverqueue/river", self.notes) + create, upload = [call.args[0] for call in command.call_args_list] + self.assertEqual( + create[:4], ["gh", "release", "create", "java/v" + self.version] + ) + for flag in ("--draft", "--verify-tag", "--prerelease", "--latest=false"): + self.assertIn(flag, create) + self.assertEqual(create[create.index("--notes-file") + 1], str(self.notes)) + self.assertEqual(upload[:3], ["gh", "release", "upload"]) + self.assertEqual(len(upload[6:]), 10) + + def test_upload_resumes_matching_draft_and_never_clobbers(self): + before = {"draft": True, "assets": self.assets[:2]} + after = {"draft": True, "assets": self.assets} + remote = {"object": {"type": "commit", "sha": self.manifest["commit"]}} + with ( + patch.object(release, "git", side_effect=self.mock_git), + patch.object(release, "gh_json", return_value=remote), + patch.object(release, "release_details", side_effect=[before, after]), + patch.object(release, "command") as command, + contextlib.redirect_stdout(io.StringIO()), + ): + release.upload(self.root, "riverqueue/river", self.notes) + args = command.call_args.args[0] + self.assertEqual(args[:3], ["gh", "release", "upload"]) + self.assertNotIn("--clobber", args) + self.assertEqual(len(args[6:]), 8) + + def test_upload_refuses_dirty_build_wrong_tag_and_published_release(self): + self.manifest["clean"] = False + self.write_manifest() + with self.assertRaisesRegex(ValueError, "clean"): + release.upload(self.root, "riverqueue/river", self.notes) + self.manifest["clean"] = True + self.write_manifest() + remote = {"object": {"type": "commit", "sha": "b" * 40}} + with ( + patch.object(release, "git", side_effect=self.mock_git), + patch.object(release, "gh_json", return_value=remote), + ): + with self.assertRaisesRegex(ValueError, "matching release tag"): + release.upload(self.root, "riverqueue/river", self.notes) + remote["object"]["sha"] = self.manifest["commit"] + with ( + patch.object(release, "git", side_effect=self.mock_git), + patch.object(release, "gh_json", return_value=remote), + patch.object(release, "release_details", return_value={"draft": False}), + ): + with self.assertRaisesRegex(ValueError, "Only draft"): + release.upload(self.root, "riverqueue/river", self.notes) + + def test_verify_assets_refuses_mismatches_before_uploading_anything(self): + self.assets[-1]["digest"] = "sha256:wrong" + with self.assertRaisesRegex(ValueError, "refusing to overwrite"): + release.verify_assets(self.assets[2:], self.manifest, allow_missing=True) + with self.assertRaisesRegex(ValueError, "Missing"): + release.verify_assets([], self.manifest) + + def test_verify_refuses_credentials_and_non_https_urls(self): + for url in ( + "https://user:secret@example.com/maven", + "http://example.com/maven", + "https://example.com/?token=secret", + ): + with self.subTest(url=url), self.assertRaises(ValueError): + release.verify(url, self.version) + + def test_versions_reject_snapshots_aliases_and_paths(self): + for value in ( + "1.0", + "v1.0.0", + "01.0.0", + "1.0.0-SNAPSHOT", + "1.0.0+build", + "../1.0.0", + "1.0.0-01", + ): + with self.subTest(version=value), self.assertRaises(ValueError): + release.checked_version(value) + self.assertEqual(release.checked_version(self.version), self.version) + + +if __name__ == "__main__": + unittest.main() diff --git a/java/docs/development.md b/java/docs/development.md index 708c96149..d4b6f23c4 100644 --- a/java/docs/development.md +++ b/java/docs/development.md @@ -15,22 +15,128 @@ mvn --batch-mode --no-transfer-progress -f java/pom.xml install The executable migration CLI is built at `java/cli/target/river-cli-0.1.0-alpha.1-all.jar`. For a build without running tests, use `make build/java`. -## Maven Central artifacts +## Release artifacts -Normal builds attach sources and Javadoc JARs for the library and CLI. The -`release` Maven profile additionally signs the artifacts and builds a Central -Publisher Portal bundle with checksums. It includes three coordinates, all under -`com.riverqueue` and using the version in `java/pom.xml`: +GitHub releases tagged `java/vVERSION` are the publication source for +`https://riverqueue.com/maven`. The proxy imports the complete asset set into R2 +and serves it anonymously to Maven and Gradle. Publishers use `gh`; no Sonatype +account, OpenPGP key, or Cloudflare credential is needed. -| Artifact | Contents | +| Artifact | GitHub attachments | | --- | --- | -| `river-java` | Parent POM with shared dependencies and publication metadata | -| `river` | Library, sources, Javadoc, and POM | -| `river-cli` | Thin CLI, executable `all` JAR, sources, Javadoc, and POM | +| `river-java` | Parent POM | +| `river` | POM, library JAR, sources JAR, Javadoc JAR | +| `river-cli` | POM, thin JAR, executable `all` JAR, sources JAR, Javadoc JAR | -The parent POM must be published with its children so consumers can resolve their -dependencies. `river-conformance` is explicitly excluded from Central publication -and skips signing, Javadoc, and local installation. +Publish all ten files together. The parent POM is required by this release's +library and CLI; the conformance adapter is excluded. The proxy generates +checksums and preserves imported versions, so publish a new version to change +bytes or attachments. + +## Releasing a new version + +Run from a clean checkout with JDK 21 or 25, Maven 3.9+, Go, `golangci-lint`, +Python 3.9+, and `gh`. A human runs `gh auth login` in their terminal once; a human +or local agent can then use the same authenticated `gh` session. Keep tokens out +of chat, shell arguments, and release files. No additional credential handoff is +needed. Stop if a command fails. Versions omit `v`; tags use `java/vVERSION`. + +1. Start from a clean checkout, fetch changes and tags, and choose the release version: + + ```sh + git checkout master && git pull --rebase + git fetch --tags + export VERSION=0.1.0-alpha.2 + git checkout -b "$USER-java-$VERSION" + ``` + +2. Update the POMs, CLI tests, documentation examples, and adapter version references together: + + ```sh + python3 - <<'PY' + import json, os, pathlib, subprocess, xml.etree.ElementTree as ET + old = ET.parse("java/pom.xml").findtext("{http://maven.apache.org/POM/4.0.0}version") + files = subprocess.check_output([ + "git", "grep", "-Il", "-F", old, "--", "java", + ":!java/CHANGELOG.md", ":!java/VALIDATION.md", + ":!java/conformance/src/main/resources/manifest.json", + ]).decode().splitlines() + for name in files: + path = pathlib.Path(name) + path.write_text(path.read_text().replace(old, os.environ["VERSION"])) + path = pathlib.Path("java/conformance/src/main/resources/manifest.json") + manifest = json.loads(path.read_text()) + manifest["implementations"]["java"]["version"] = os.environ["VERSION"] + path.write_text(json.dumps(manifest, indent=2) + "\n") + PY + git diff -- java + ``` + + Move `java/CHANGELOG.md` entries from `Unreleased` into a `[$VERSION] - YYYY-MM-DD` section. For the first release, describe the initial library and CLI; for a stable release, remove "Prerelease" from the README introduction. + +3. Check the release and open a PR. Keep CI enabled and have the PR reviewed and merged after the Java checks pass: + + ```sh + make lint/java test/java/tools test/java/sqlite check/java/package + git add java + git commit -m "[java] Release v$VERSION" + git push -u origin HEAD + gh pr create --fill + ``` + +4. After merge, pull the release commit into a clean checkout, confirm its version, and create the local tag: + + ```sh + git checkout master && git pull --rebase && + test -z "$(git status --porcelain)" && + test "$(python3 -c 'import xml.etree.ElementTree as E; print(E.parse("java/pom.xml").findtext("{http://maven.apache.org/POM/4.0.0}version"))')" = "$VERSION" && + git tag "java/v$VERSION" -m "release java/v$VERSION" + ``` + +5. Build, test, and stage the complete release. This uses a temporary **local file repository**, without signing, uploading, or installing release artifacts into your normal Maven cache: + + ```sh + python3 java/bin/github-release.py prepare + python3 - <<'PYNOTES' + import os, pathlib, re + text = pathlib.Path("java/CHANGELOG.md").read_text() + match = re.search(r"^## \[" + re.escape(os.environ["VERSION"]) + r"\][^\n]*\n(.*?)(?=^## |\Z)", text, re.M | re.S) + if not match: + raise SystemExit("Version missing from Java changelog") + pathlib.Path("java/target/github-release-notes.md").write_text(match.group(1).strip() + "\n") + PYNOTES + ``` + +6. Push the tag and upload all artifacts to a draft. The script checks the local/remote tag against the build commit, resumes matching draft uploads, and refuses changed assets or an already published release: + + ```sh + git push origin "java/v$VERSION" + python3 java/bin/github-release.py upload \ + --notes-file java/target/github-release-notes.md + gh release view "java/v$VERSION" --repo riverqueue/river + ``` + +7. Publish the verified draft. Alpha, beta, and RC versions are marked as prereleases automatically: + + ```sh + gh release edit "java/v$VERSION" --repo riverqueue/river --draft=false + ``` + +8. Verify the public proxy, all asset checksums, fresh Maven dependency resolution, a compiled consumer, and the executable CLI. This also checks that Maven obtained River artifacts from the proxy rather than Central. If discovery has not caught up, wait a minute and retry: + + ```sh + python3 java/bin/github-release.py verify --version "$VERSION" \ + --directory java/target/github-release + ``` + + To test a dev deployment, add `--repository https://mod-workers-dev.riverqueue.workers.dev/maven`. To verify an existing release without a local build, omit `--directory`. + +## Optional Maven Central publication + +The GitHub/proxy release path above does not use Central publication. The existing +`release` Maven profile remains available for separately publishing to Central; +it signs the same public coordinates and creates a Portal bundle. A version +published to both repositories must use the same artifact bytes. ### Local tooling and signing @@ -109,102 +215,10 @@ 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. -## 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 -publishing setup above first. Stop if any command fails. Java versions have no -leading `v`; tags use `java/vX.Y.Z`. - -1. Start from a clean checkout, fetch changes and tags, and choose the release version: - - ```sh - git checkout master && git pull --rebase - git fetch --tags - export VERSION=0.1.0-alpha.1 - git checkout -b "$USER-java-$VERSION" - ``` - -2. Update the POMs, CLI tests, documentation examples, and adapter version references together: - - ```sh - python3 - <<'PY' - import json, os, pathlib, subprocess, xml.etree.ElementTree as ET - old = ET.parse("java/pom.xml").findtext("{http://maven.apache.org/POM/4.0.0}version") - files = subprocess.check_output([ - "git", "grep", "-Il", "-F", old, "--", "java", - ":!java/CHANGELOG.md", ":!java/VALIDATION.md", - ":!java/conformance/src/main/resources/manifest.json", - ]).decode().splitlines() - for name in files: - path = pathlib.Path(name) - path.write_text(path.read_text().replace(old, os.environ["VERSION"])) - path = pathlib.Path("java/conformance/src/main/resources/manifest.json") - manifest = json.loads(path.read_text()) - manifest["implementations"]["java"]["version"] = os.environ["VERSION"] - path.write_text(json.dumps(manifest, indent=2) + "\n") - PY - git diff -- java - ``` - - Move `java/CHANGELOG.md` entries from `Unreleased` into a `[$VERSION] - YYYY-MM-DD` section. For the first release, describe the initial library and CLI; for a stable release, remove "Prerelease" from the README introduction. - -3. Check the release and open a PR. Keep CI enabled and have the PR reviewed and merged after the Java checks pass: - - ```sh - make lint/java test/java/sqlite check/java/package - git add java - git commit -m "[java] Release v$VERSION" - git push -u origin HEAD - gh pr create --fill - ``` - -4. After merge, pull the release commit into a clean checkout, confirm its version, and create the local tag: - - ```sh - git checkout master && git pull --rebase && - test -z "$(git status --porcelain)" && - test "$(python3 -c 'import xml.etree.ElementTree as E; print(E.parse("java/pom.xml").findtext("{http://maven.apache.org/POM/4.0.0}version"))')" = "$VERSION" && - 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: - - ```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 \ - -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. - -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 - export JAVA_RELEASE_CHECK_DIR="$(mktemp -d)" - (cd "$JAVA_RELEASE_CHECK_DIR" && mvn --batch-mode --no-transfer-progress \ - -Dmaven.repo.local="$JAVA_RELEASE_CHECK_DIR/m2" dependency:get \ - -Dartifact="com.riverqueue:river-cli:$VERSION:jar:all") && - java -jar "$JAVA_RELEASE_CHECK_DIR/m2/com/riverqueue/river-cli/$VERSION/river-cli-$VERSION-all.jar" --version && - git push origin "java/v$VERSION" - ``` - -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. - ## 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 @@ -251,7 +265,7 @@ canonical Go drivers. `verify/java-migrations` detects changed, missing, or extr migrations. `check/java/package` checks the library and CLI archives, including source JARs, for development content and verifies bundled runtime resources. The Go maintenance tools live in `java/bin/`. `make test/java` and `make lint/java` -include their tests and lint checks; `make test/java/tools` and +include their tests and lint checks; the tool tests also cover GitHub release staging and upload guards; `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. From 22f44f75fd1ba0cf79a105664813e3f8148bc45b Mon Sep 17 00:00:00 2001 From: Brandur Date: Sun, 25 Oct 2026 14:52:34 -0500 Subject: [PATCH 2/5] [java] Preserve the Go Latest release and isolate release test versions --- java/bin/github-release.py | 7 +++- java/bin/github_release_test.py | 70 ++++++++++++++++++++------------- java/docs/development.md | 5 ++- 3 files changed, 51 insertions(+), 31 deletions(-) diff --git a/java/bin/github-release.py b/java/bin/github-release.py index ad0be2e0c..a9fe77d40 100644 --- a/java/bin/github-release.py +++ b/java/bin/github-release.py @@ -258,13 +258,14 @@ def upload(directory, repo, notes_file): repo, "--verify-tag", "--draft", + "--latest=false", "--title", tag, "--notes-file", str(notes_file), ] if "-" in manifest["version"]: - args += ["--prerelease", "--latest=false"] + args += ["--prerelease"] command(args) release = release_details(repo, tag) if not release["draft"]: @@ -286,7 +287,9 @@ def upload(directory, repo, notes_file): ) verify_assets(release_details(repo, tag)["assets"], manifest) print(f"All {len(manifest['files'])} assets verified on draft {tag}.") - print(f"Publish with: gh release edit {tag} --repo {repo} --draft=false") + print( + f"Publish with: gh release edit {tag} --repo {repo} --draft=false --latest=false" + ) def validate_artifact(data, artifact, filename, version): diff --git a/java/bin/github_release_test.py b/java/bin/github_release_test.py index 3eda07fa1..d189deeb0 100644 --- a/java/bin/github_release_test.py +++ b/java/bin/github_release_test.py @@ -20,7 +20,12 @@ def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) - self.version = "0.1.0-alpha.1" + self.prepare_version("99.88.77-alpha.4") + self.notes = self.root / "notes.md" + self.notes.write_text("Release notes") + + def prepare_version(self, version): + self.version = version self.manifest = { "schema_version": 1, "version": self.version, @@ -45,8 +50,6 @@ def setUp(self): } for name, data in self.manifest["files"].items() ] - self.notes = self.root / "notes.md" - self.notes.write_text("Release notes") def write_manifest(self): (self.root / "manifest.json").write_text(json.dumps(self.manifest)) @@ -121,30 +124,43 @@ def test_repository_tracking_accepts_old_and_url_qualified_ids(self): release.resolved_from_proxy(f"different.jar>river-release-check=\n", name) ) - def test_upload_creates_prerelease_draft_for_annotated_tag(self): - before = {"draft": True, "assets": []} - after = {"draft": True, "assets": self.assets} - remote = [ - {"object": {"type": "tag", "sha": "b" * 40}}, - {"object": {"type": "commit", "sha": self.manifest["commit"]}}, - ] - with ( - patch.object(release, "git", side_effect=self.mock_git), - patch.object(release, "gh_json", side_effect=remote), - patch.object(release, "release_details", side_effect=[None, before, after]), - patch.object(release, "command") as command, - contextlib.redirect_stdout(io.StringIO()), - ): - release.upload(self.root, "riverqueue/river", self.notes) - create, upload = [call.args[0] for call in command.call_args_list] - self.assertEqual( - create[:4], ["gh", "release", "create", "java/v" + self.version] - ) - for flag in ("--draft", "--verify-tag", "--prerelease", "--latest=false"): - self.assertIn(flag, create) - self.assertEqual(create[create.index("--notes-file") + 1], str(self.notes)) - self.assertEqual(upload[:3], ["gh", "release", "upload"]) - self.assertEqual(len(upload[6:]), 10) + def test_upload_creates_drafts_without_replacing_latest(self): + for version, prerelease in (("99.88.77-alpha.4", True), ("99.88.77", False)): + with self.subTest(version=version): + self.prepare_version(version) + before = {"draft": True, "assets": []} + after = {"draft": True, "assets": self.assets} + remote = [ + {"object": {"type": "tag", "sha": "b" * 40}}, + {"object": {"type": "commit", "sha": self.manifest["commit"]}}, + ] + output = io.StringIO() + with ( + patch.object(release, "git", side_effect=self.mock_git), + patch.object(release, "gh_json", side_effect=remote), + patch.object( + release, "release_details", side_effect=[None, before, after] + ), + patch.object(release, "command") as command, + contextlib.redirect_stdout(output), + ): + release.upload(self.root, "riverqueue/river", self.notes) + create, upload = [call.args[0] for call in command.call_args_list] + self.assertEqual( + create[:4], ["gh", "release", "create", "java/v" + version] + ) + for flag in ("--draft", "--verify-tag", "--latest=false"): + self.assertIn(flag, create) + self.assertEqual("--prerelease" in create, prerelease) + self.assertEqual( + create[create.index("--notes-file") + 1], str(self.notes) + ) + self.assertEqual(upload[:3], ["gh", "release", "upload"]) + self.assertEqual(len(upload[6:]), 10) + self.assertIn( + f"gh release edit java/v{version} --repo riverqueue/river --draft=false --latest=false", + output.getvalue(), + ) def test_upload_resumes_matching_draft_and_never_clobbers(self): before = {"draft": True, "assets": self.assets[:2]} diff --git a/java/docs/development.md b/java/docs/development.md index d4b6f23c4..d7b959c63 100644 --- a/java/docs/development.md +++ b/java/docs/development.md @@ -59,6 +59,7 @@ needed. Stop if a command fails. Versions omit `v`; tags use `java/vVERSION`. files = subprocess.check_output([ "git", "grep", "-Il", "-F", old, "--", "java", ":!java/CHANGELOG.md", ":!java/VALIDATION.md", + ":!java/bin/github_release_test.py", ":!java/conformance/src/main/resources/manifest.json", ]).decode().splitlines() for name in files: @@ -116,10 +117,10 @@ needed. Stop if a command fails. Versions omit `v`; tags use `java/vVERSION`. gh release view "java/v$VERSION" --repo riverqueue/river ``` -7. Publish the verified draft. Alpha, beta, and RC versions are marked as prereleases automatically: +7. Publish the verified draft. Alpha, beta, and RC versions are marked as prereleases automatically. Keep `--latest=false` for every Java release, including stable versions, so the repository's Latest release remains the Go release: ```sh - gh release edit "java/v$VERSION" --repo riverqueue/river --draft=false + gh release edit "java/v$VERSION" --repo riverqueue/river --draft=false --latest=false ``` 8. Verify the public proxy, all asset checksums, fresh Maven dependency resolution, a compiled consumer, and the executable CLI. This also checks that Maven obtained River artifacts from the proxy rather than Central. If discovery has not caught up, wait a minute and retry: From 71a5c87640a3455907047be326f1d56b5b94716d Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 26 Oct 2026 01:10:14 -0500 Subject: [PATCH 3/5] [java] Release a thin CLI with dependency-resolving launch instructions --- java/README.md | 61 ++++++++++++++----- java/bin/check-packages/main.go | 1 - java/bin/github-release.py | 36 ++++++----- java/bin/github_release_test.py | 11 ++-- java/cli/pom.xml | 27 ++++---- .../java/com/riverqueue/cli/ExecutableIT.java | 9 ++- java/docs/development.md | 34 ++++++++--- 7 files changed, 114 insertions(+), 65 deletions(-) diff --git a/java/README.md b/java/README.md index 212702325..6b321eb7f 100644 --- a/java/README.md +++ b/java/README.md @@ -89,24 +89,30 @@ for each operation. River never closes an application-owned data source. An ## Migration CLI -The `river-cli` executable JAR includes Postgres and SQLite JDBC drivers and -runs with Java 21 or newer. It needs no application classpath. -Download `river-cli-VERSION-all.jar` from the [Java GitHub releases](https://github.com/riverqueue/river/releases?q=java%2Fv), or build it using the [development guide](docs/development.md#building-from-source). Set `RIVER_CLI` to the executable JAR, then run: +The CLI runs with Java 21 or newer. [JBang](https://www.jbang.dev/documentation/jbang/latest/installation.html) +resolves the thin CLI JAR and its dependencies and installs a reusable command: ```sh -RIVER_CLI=/path/to/river-cli-0.1.0-alpha.1-all.jar +jbang app install --name river-java --java 21 \ + --java-options=--enable-native-access=ALL-UNNAMED \ + --repos https://riverqueue.com/maven \ + --main com.riverqueue.cli.MigrationCli \ + com.riverqueue:river-cli:0.1.0-alpha.1 +export PATH="$HOME/.jbang/bin:$PATH" export DATABASE_URL=postgres://localhost/myapp # For SQLite: export DATABASE_URL=jdbc:sqlite:/absolute/path/myapp.db -java -jar "$RIVER_CLI" --help -java -jar "$RIVER_CLI" migrate-list -java -jar "$RIVER_CLI" migrate-up --dry-run -java -jar "$RIVER_CLI" migrate-up +river-java --help +river-java migrate-list +river-java migrate-up --dry-run +river-java migrate-up ``` -The Maven executable artifact is `com.riverqueue:river-cli` with version -`0.1.0-alpha.1` and classifier `all`. Copy that single JAR into your deployment image to run -migrations before starting workers. `--database-url URL` overrides `DATABASE_URL`. +Dependencies, including both JDBC drivers, are downloaded once and cached. No +`all` classifier is needed. For containers or installations with Maven and a JDK, +use the [Maven-only instructions](#running-with-maven-and-java) below, or +[build from source](docs/development.md#building-from-source). +`--database-url URL` overrides `DATABASE_URL`. `--schema background_jobs` selects a Postgres schema; an actual migration up creates it if needed. Listing and dry runs do not create schemas or migration tables. @@ -124,10 +130,10 @@ version `0` explicitly removes the entire migration line. A zero `--max-steps` uses the command's default: all pending up migrations or one down migration. ```sh -java -jar "$RIVER_CLI" migrate-down --max-steps 2 --dry-run --show-sql -java -jar "$RIVER_CLI" migrate-up --target-version 8 -java -jar "$RIVER_CLI" migrate-get --version 3 --up > river_3.up.sql -java -jar "$RIVER_CLI" migrate-get --driver sqlite --all --up > river_sqlite.up.sql +river-java migrate-down --max-steps 2 --dry-run --show-sql +river-java migrate-up --target-version 8 +river-java migrate-get --version 3 --up > river_3.up.sql +river-java migrate-get --driver sqlite --all --up > river_sqlite.up.sql ``` SQL export defaults to Postgres without a database URL. Use `--driver sqlite` @@ -147,6 +153,31 @@ var preview = new Migrator(database).migrate(Migrator.Direction.DOWN, Migrator.Options.defaults().maxSteps(2).dryRun(true)); ``` +### Running with Maven and Java + +In an empty directory, create this consumer POM and download the runtime JARs: + +```sh +cat > pom.xml <<'EOF' + + 4.0.0 + localriver-cli-install1 + riverhttps://riverqueue.com/maven + com.riverqueueriver-cli0.1.0-alpha.1 + +EOF +mvn --batch-mode --no-transfer-progress \ + org.apache.maven.plugins:maven-dependency-plugin:3.11.0:copy-dependencies \ + -DincludeScope=runtime +java --enable-native-access=ALL-UNNAMED -cp 'target/dependency/*' \ + com.riverqueue.cli.MigrationCli --help +``` + +Replace `--help` with any migration command above. Copy the entire +`target/dependency` directory into a deployment image and adjust the classpath to +its location. Running it needs only Java; Maven and network access are needed +when downloading the dependencies. + ## Transactional enqueueing [Enqueue in the application's transaction](https://riverqueue.com/docs/transactional-enqueueing) diff --git a/java/bin/check-packages/main.go b/java/bin/check-packages/main.go index ee1ed072e..0c6ed6f73 100644 --- a/java/bin/check-packages/main.go +++ b/java/bin/check-packages/main.go @@ -98,7 +98,6 @@ func run(root string) error { {"river/target/river-" + project.Version + "-sources.jar", false}, {"cli/target/river-cli.jar", false}, {"cli/target/river-cli-sources.jar", false}, - {"cli/target/river-cli-" + project.Version + "-all.jar", true}, } { var resources string if artifact.runtime { diff --git a/java/bin/github-release.py b/java/bin/github-release.py index a9fe77d40..3364e5108 100644 --- a/java/bin/github-release.py +++ b/java/bin/github-release.py @@ -28,8 +28,6 @@ def artifact_files(version): suffixes = [".pom"] if artifact != "river-java": suffixes += [".jar", "-sources.jar", "-javadoc.jar"] - if artifact == "river-cli": - suffixes += ["-all.jar"] result.extend( (artifact, f"{artifact}-{version}{suffix}") for suffix in suffixes ) @@ -401,11 +399,11 @@ def verify(repository, version, directory=None): "-Dmaven.repo.local=" + str(work / "m2"), ] plugin = "org.apache.maven.plugins:maven-dependency-plugin:3.11.0" - for goals in ( - [plugin + ":build-classpath", "-Dmdep.outputFile=classpath.txt"], - [plugin + ":get", f"-Dartifact={GROUP}:river-cli:{version}:jar:all"], - ): - command(args + goals, cwd=work, env=environment) + command( + args + [plugin + ":build-classpath", "-Dmdep.outputFile=classpath.txt"], + cwd=work, + env=environment, + ) for artifact, filename in artifact_files(version): if "-sources.jar" in filename or "-javadoc.jar" in filename: continue @@ -429,14 +427,15 @@ def verify(repository, version, directory=None): cwd=work, env=environment, ) - cli = ( - work - / "m2/com/riverqueue/river-cli" - / version - / f"river-cli-{version}-all.jar" - ) + cli = [ + "java", + "--enable-native-access=ALL-UNNAMED", + "-cp", + classpath, + "com.riverqueue.cli.MigrationCli", + ] output = command( - ["java", "-jar", str(cli), "--version"], + cli + ["--version"], cwd=work, env=environment, capture_output=True, @@ -444,8 +443,15 @@ def verify(repository, version, directory=None): ).stdout.strip() if output != "river " + version: raise ValueError(f"CLI version differs: {output}") + environment["DATABASE_URL"] = "jdbc:sqlite:" + str(work / "river.db") + for operation in ( + ["migrate-up"], + ["migrate-list"], + ["migrate-down", "--target-version", "0"], + ): + command(cli + operation, cwd=work, env=environment) print( - f"Verified all 10 artifacts, fresh Maven resolution, consumer compilation, and {output}." + f"Verified all {len(downloaded)} artifacts, fresh Maven resolution, consumer compilation, and {output} with SQLite migrations." ) diff --git a/java/bin/github_release_test.py b/java/bin/github_release_test.py index d189deeb0..c7f7bb2d1 100644 --- a/java/bin/github_release_test.py +++ b/java/bin/github_release_test.py @@ -57,11 +57,12 @@ def write_manifest(self): def mock_git(self, *args): return "" if args[0] == "status" else self.manifest["commit"] - def test_artifacts_include_parent_and_all_classifiers_without_conformance(self): + def test_artifacts_include_parent_without_fat_jar_or_conformance(self): names = {name for _, name in release.artifact_files(self.version)} - self.assertEqual(len(names), 10) + self.assertEqual(len(names), 9) self.assertIn("river-java-" + self.version + ".pom", names) - self.assertIn("river-cli-" + self.version + "-all.jar", names) + self.assertIn("river-cli-" + self.version + ".jar", names) + self.assertNotIn("river-cli-" + self.version + "-all.jar", names) self.assertFalse(any("conformance" in name for name in names)) def test_manifest_rejects_missing_extra_changed_and_symlinked_files(self): @@ -156,7 +157,7 @@ def test_upload_creates_drafts_without_replacing_latest(self): create[create.index("--notes-file") + 1], str(self.notes) ) self.assertEqual(upload[:3], ["gh", "release", "upload"]) - self.assertEqual(len(upload[6:]), 10) + self.assertEqual(len(upload[6:]), 9) self.assertIn( f"gh release edit java/v{version} --repo riverqueue/river --draft=false --latest=false", output.getvalue(), @@ -177,7 +178,7 @@ def test_upload_resumes_matching_draft_and_never_clobbers(self): args = command.call_args.args[0] self.assertEqual(args[:3], ["gh", "release", "upload"]) self.assertNotIn("--clobber", args) - self.assertEqual(len(args[6:]), 8) + self.assertEqual(len(args[6:]), 7) def test_upload_refuses_dirty_build_wrong_tag_and_published_release(self): self.manifest["clean"] = False diff --git a/java/cli/pom.xml b/java/cli/pom.xml index 57fdf6b21..ed2ae00ca 100644 --- a/java/cli/pom.xml +++ b/java/cli/pom.xml @@ -14,22 +14,17 @@ river-cli - org.apache.maven.pluginsmaven-shade-plugin3.6.1 - packageshade - false - trueall - - - META-INF/LICENSE - META-INF/NOTICE - - com.riverqueue.cli.MigrationCli - true${project.version}ALL-UNNAMED - - - - - *:*META-INF/MANIFEST.MFMETA-INF/*.SFMETA-INF/*.DSAMETA-INF/*.RSAmodule-info.classMETA-INF/versions/*/module-info.class + org.apache.maven.pluginsmaven-jar-plugin + + com.riverqueue.cli.MigrationCli + ${project.version} + + + + org.apache.maven.pluginsmaven-dependency-plugin3.11.0 + prepare-packagecopy-dependencies + runtime + ${project.build.directory}/dependency org.apache.maven.pluginsmaven-failsafe-plugin3.5.4 diff --git a/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java b/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java index 27465e02f..bc7ebd4d7 100644 --- a/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java +++ b/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.*; +import java.io.File; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -17,8 +18,10 @@ private String command(String... args) throws Exception { new ArrayList<>( List.of( Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-jar", - "target/river-cli-0.1.0-alpha.1-all.jar")); + "--enable-native-access=ALL-UNNAMED", + "-cp", + "target/river-cli.jar" + File.pathSeparator + "target/dependency/*", + "com.riverqueue.cli.MigrationCli")); command.addAll(List.of(args)); var output = directory.resolve("process.log").toFile(); var builder = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(output); @@ -35,7 +38,7 @@ private String command(String... args) throws Exception { } @Test - void bundledJarRunsWithoutExternalClasspath() throws Exception { + void thinJarRunsWithRuntimeDependencies() throws Exception { assertTrue(command("--version").contains("0.1.0-alpha.1")); assertTrue(command("migrate-up").contains("008 [up]")); assertTrue(command("migrate-list").contains("applied")); diff --git a/java/docs/development.md b/java/docs/development.md index d7b959c63..4daf723b4 100644 --- a/java/docs/development.md +++ b/java/docs/development.md @@ -13,7 +13,17 @@ make generate/fixtures mvn --batch-mode --no-transfer-progress -f java/pom.xml install ``` -The executable migration CLI is built at `java/cli/target/river-cli-0.1.0-alpha.1-all.jar`. For a build without running tests, use `make build/java`. +The CLI is built at `java/cli/target/river-cli.jar`, with its runtime dependencies +in `java/cli/target/dependency/`. Run it with: + +```sh +java --enable-native-access=ALL-UNNAMED \ + -cp 'java/cli/target/river-cli.jar:java/cli/target/dependency/*' \ + com.riverqueue.cli.MigrationCli --help +``` + +On Windows, use `;` instead of `:` between classpath entries. For a build without +running tests, use `make build/java`. ## Release artifacts @@ -26,12 +36,14 @@ account, OpenPGP key, or Cloudflare credential is needed. | --- | --- | | `river-java` | Parent POM | | `river` | POM, library JAR, sources JAR, Javadoc JAR | -| `river-cli` | POM, thin JAR, executable `all` JAR, sources JAR, Javadoc JAR | +| `river-cli` | POM, thin JAR, sources JAR, Javadoc JAR | -Publish all ten files together. The parent POM is required by this release's +Publish all nine files together. The parent POM is required by this release's library and CLI; the conformance adapter is excluded. The proxy generates checksums and preserves imported versions, so publish a new version to change -bytes or attachments. +bytes or attachments. New releases do not build or upload a shaded `-all.jar`. +Use [JBang or Maven](../README.md#migration-cli) to resolve CLI dependencies; +older published standalone JARs remain available. ## Releasing a new version @@ -123,7 +135,7 @@ needed. Stop if a command fails. Versions omit `v`; tags use `java/vVERSION`. gh release edit "java/v$VERSION" --repo riverqueue/river --draft=false --latest=false ``` -8. Verify the public proxy, all asset checksums, fresh Maven dependency resolution, a compiled consumer, and the executable CLI. This also checks that Maven obtained River artifacts from the proxy rather than Central. If discovery has not caught up, wait a minute and retry: +8. Verify the public proxy, all asset checksums, fresh Maven dependency resolution, a compiled consumer, and thin CLI SQLite migrations. This also checks that Maven obtained River artifacts from the proxy rather than Central. If discovery has not caught up, wait a minute and retry: ```sh python3 java/bin/github-release.py verify --version "$VERSION" \ @@ -302,11 +314,13 @@ the existing API documentation does not annotate every parameter and return valu Automatic external documentation links are disabled so Javadoc generation does not depend on another documentation site being reachable. -Executable JARs merge dependency `META-INF/LICENSE` and `META-INF/NOTICE` text, -retain service registrations, and generate their own executable manifests. -Dependency module descriptors and signatures are excluded from these combined -archives. Other overlapping classes or resources still produce warnings and fail -the checked Make targets. Library and source JARs keep their normal packaging. +The development-only conformance adapter JAR merges dependency +`META-INF/LICENSE` and `META-INF/NOTICE` text, retains service registrations, and +generates its own executable manifest. +Dependency module descriptors and signatures are excluded from this combined +archive. Other overlapping classes or resources still produce warnings and fail +the checked Make targets. Library, CLI, and source JARs keep their normal +packaging; CLI dependencies stay in separate JARs. ## Legacy cross-process harness From 6b44eb30d45cdad140d2cd3c247ff56abfd90916 Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 26 Oct 2026 01:52:00 -0500 Subject: [PATCH 4/5] [java] Document scheduled proxy imports before release verification --- java/docs/development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/java/docs/development.md b/java/docs/development.md index 4daf723b4..0ef27adf8 100644 --- a/java/docs/development.md +++ b/java/docs/development.md @@ -135,14 +135,14 @@ needed. Stop if a command fails. Versions omit `v`; tags use `java/vVERSION`. gh release edit "java/v$VERSION" --repo riverqueue/river --draft=false --latest=false ``` -8. Verify the public proxy, all asset checksums, fresh Maven dependency resolution, a compiled consumer, and thin CLI SQLite migrations. This also checks that Maven obtained River artifacts from the proxy rather than Central. If discovery has not caught up, wait a minute and retry: +8. Verify the public proxy, all asset checksums, fresh Maven dependency resolution, a compiled consumer, and thin CLI SQLite migrations. This also checks that Maven obtained River artifacts from the proxy rather than Central. The proxy imports on a schedule; requests do not trigger discovery. If the release is not visible yet, wait a minute and retry: ```sh python3 java/bin/github-release.py verify --version "$VERSION" \ --directory java/target/github-release ``` - To test a dev deployment, add `--repository https://mod-workers-dev.riverqueue.workers.dev/maven`. To verify an existing release without a local build, omit `--directory`. + To test a dev deployment, wait for its scheduled import, then add `--repository https://mod-workers-dev.riverqueue.workers.dev/maven`. For local initialization, follow the [proxy refresh instructions](https://github.com/riverqueue/gomodproxy-workers/blob/master/docs/maven.md#configuration-and-development). To verify an existing release without a local build, omit `--directory`. ## Optional Maven Central publication From 9640510fa8895765c26e3c7c24bab53a523ec73b Mon Sep 17 00:00:00 2001 From: Brandur Date: Mon, 26 Oct 2026 19:40:32 -0500 Subject: [PATCH 5/5] [java] Address release workflow review feedback --- java/README.md | 2 +- java/cli/pom.xml | 6 ++- .../java/com/riverqueue/cli/ExecutableIT.java | 42 ++++++++++++------- java/docs/development.md | 12 +++--- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/java/README.md b/java/README.md index 6b321eb7f..583b1f310 100644 --- a/java/README.md +++ b/java/README.md @@ -95,7 +95,7 @@ resolves the thin CLI JAR and its dependencies and installs a reusable command: ```sh jbang app install --name river-java --java 21 \ --java-options=--enable-native-access=ALL-UNNAMED \ - --repos https://riverqueue.com/maven \ + --repos https://riverqueue.com/maven --repos central \ --main com.riverqueue.cli.MigrationCli \ com.riverqueue:river-cli:0.1.0-alpha.1 export PATH="$HOME/.jbang/bin:$PATH" diff --git a/java/cli/pom.xml b/java/cli/pom.xml index ed2ae00ca..52fe00ab7 100644 --- a/java/cli/pom.xml +++ b/java/cli/pom.xml @@ -16,7 +16,11 @@ org.apache.maven.pluginsmaven-jar-plugin - com.riverqueue.cli.MigrationCli + + true + dependency/ + com.riverqueue.cli.MigrationCli + ${project.version} diff --git a/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java b/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java index bc7ebd4d7..fe6b57714 100644 --- a/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java +++ b/java/cli/src/test/java/com/riverqueue/cli/ExecutableIT.java @@ -7,24 +7,36 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class ExecutableIT { @TempDir Path directory; - private String command(String... args) throws Exception { + private String command(boolean executableJar, String... args) throws Exception { var command = new ArrayList<>( List.of( Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "--enable-native-access=ALL-UNNAMED", - "-cp", - "target/river-cli.jar" + File.pathSeparator + "target/dependency/*", - "com.riverqueue.cli.MigrationCli")); + "--enable-native-access=ALL-UNNAMED")); + var jar = Path.of("target/river-cli.jar").toAbsolutePath().toString(); + if (executableJar) { + command.addAll(List.of("-jar", jar)); + } else { + command.addAll( + List.of( + "-cp", + jar + File.pathSeparator + Path.of("target/dependency/*").toAbsolutePath(), + "com.riverqueue.cli.MigrationCli")); + } command.addAll(List.of(args)); var output = directory.resolve("process.log").toFile(); - var builder = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(output); + var builder = + new ProcessBuilder(command) + .directory(directory.toFile()) + .redirectErrorStream(true) + .redirectOutput(output); builder.environment().put("DATABASE_URL", "jdbc:sqlite:" + directory.resolve("river.db")); var process = builder.start(); try { @@ -37,12 +49,14 @@ private String command(String... args) throws Exception { } } - @Test - void thinJarRunsWithRuntimeDependencies() throws Exception { - assertTrue(command("--version").contains("0.1.0-alpha.1")); - assertTrue(command("migrate-up").contains("008 [up]")); - assertTrue(command("migrate-list").contains("applied")); - assertTrue(command("migrate-down", "--target-version", "0").contains("001 [down]")); - assertFalse(command("migrate-list").contains("applied")); + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void thinJarRunsWithRuntimeDependencies(boolean executableJar) throws Exception { + assertEquals("river 0.1.0-alpha.1", command(executableJar, "--version").strip()); + assertTrue(command(executableJar, "migrate-up").contains("008 [up]")); + assertTrue(command(executableJar, "migrate-list").contains("applied")); + assertTrue( + command(executableJar, "migrate-down", "--target-version", "0").contains("001 [down]")); + assertFalse(command(executableJar, "migrate-list").contains("applied")); } } diff --git a/java/docs/development.md b/java/docs/development.md index 0ef27adf8..2131479e3 100644 --- a/java/docs/development.md +++ b/java/docs/development.md @@ -18,12 +18,11 @@ in `java/cli/target/dependency/`. Run it with: ```sh java --enable-native-access=ALL-UNNAMED \ - -cp 'java/cli/target/river-cli.jar:java/cli/target/dependency/*' \ - com.riverqueue.cli.MigrationCli --help + -jar java/cli/target/river-cli.jar --help ``` -On Windows, use `;` instead of `:` between classpath entries. For a build without -running tests, use `make build/java`. +Keep the `dependency/` directory beside the CLI JAR when moving it. For a build +without running tests, use `make build/java`. ## Release artifacts @@ -142,7 +141,10 @@ needed. Stop if a command fails. Versions omit `v`; tags use `java/vVERSION`. --directory java/target/github-release ``` - To test a dev deployment, wait for its scheduled import, then add `--repository https://mod-workers-dev.riverqueue.workers.dev/maven`. For local initialization, follow the [proxy refresh instructions](https://github.com/riverqueue/gomodproxy-workers/blob/master/docs/maven.md#configuration-and-development). To verify an existing release without a local build, omit `--directory`. + To verify an existing release without a local build, omit `--directory`. This + requires a thin CLI JAR with `Implementation-Version` manifest metadata. The + originally published alpha.1 thin JAR reports `development` instead of its + release version and fails the CLI version check. ## Optional Maven Central publication