Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ on:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build_type || 'branch' }}
cancel-in-progress: true
cancel-in-progress: ${{ github.ref_type != 'tag' }}

permissions: {}

Expand Down Expand Up @@ -78,8 +78,7 @@ jobs:
packages: read
pull-requests: read
uses: ./.github/workflows/matrix-job.yaml
# Artifacts are not published from these jobs, so it's safe to run for multiple CUDA versions.
# If these jobs start producing artifacts, the names will have to differentiate between CUDA versions.
# Keep CUDA builds separate; the release gather job retains their native classifiers.
with:
build_type: ${{ inputs.build_type || 'branch' }}
branch: ${{ inputs.branch }}
Expand All @@ -96,8 +95,7 @@ jobs:
packages: read
pull-requests: read
uses: ./.github/workflows/matrix-job.yaml
# Artifacts are not published from these jobs, so it's safe to run for multiple CUDA versions.
# If these jobs start producing artifacts, the names will have to differentiate between CUDA versions.
# Build against the matching cuvs-java artifact from this workflow run.
with:
build_type: ${{ inputs.build_type || 'branch' }}
branch: ${{ inputs.branch }}
Expand Down Expand Up @@ -148,6 +146,75 @@ jobs:
script: "ci/build_lucene.sh"
artifact-name-prefix: "cuvs-lucene"
file_to_upload: "java/cuvs-lucene/target/"
java-maven-gather:
if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
needs: [java-build, lucene-build]
runs-on: linux-amd64-cpu4
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix:
module: [cuvs-java, cuvs-lucene]
steps:
- name: Checkout release source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ inputs.sha || github.sha }}
persist-credentials: false
- name: Download module builds from this run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: ${{ matrix.module }}-cuda*
path: ${{ runner.temp }}/java-builds
- name: Assemble Maven repository
env:
MODULE: ${{ matrix.module }}
run: |
python3 java/ci/assemble_maven_repo.py \
--module "${MODULE}" --tag "${GITHUB_REF_NAME}" \
--artifacts-dir "${RUNNER_TEMP}/java-builds" \
--output-dir "${RUNNER_TEMP}/maven-repo"
- name: Upload assembled Maven repository
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ matrix.module }}_maven_repo
path: ${{ runner.temp }}/maven-repo
if-no-files-found: error
java-publish:
needs: java-maven-gather
permissions:
actions: read
contents: read
uses: rapidsai/shared-workflows/.github/workflows/maven-publish.yaml@main
secrets:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
MAVEN_DEPLOY_TOKEN: ${{ secrets.MAVEN_DEPLOY_TOKEN }}
with:
artifact-name: cuvs-java_maven_repo
source-git-sha: ${{ inputs.sha || github.sha }}
profile-id: ${{ vars.MAVEN_STAGING_PROFILE_ID || 'com.nvidia.cuvs' }}
docs-project: cuvs-java
lucene-publish:
# The Lucene release depends on the matching published Java coordinate.
# Documentation is dispatched separately from release-tracking.
if: ${{ !cancelled() && needs.java-publish.outputs.deployment-state == 'PUBLISHED' }}
needs: [java-maven-gather, java-publish]
permissions:
actions: read
contents: read
uses: rapidsai/shared-workflows/.github/workflows/maven-publish.yaml@main
secrets:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
MAVEN_DEPLOY_TOKEN: ${{ secrets.MAVEN_DEPLOY_TOKEN }}
with:
artifact-name: cuvs-lucene_maven_repo
source-git-sha: ${{ inputs.sha || github.sha }}
profile-id: ${{ vars.MAVEN_STAGING_PROFILE_ID || 'com.nvidia.cuvs' }}
docs-project: cuvs-lucene
python-build:
needs: [build-details, cpp-build]
permissions:
Expand Down
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ repos:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: https://github.com/PyCQA/bandit
rev: 1.8.6
hooks:
- id: bandit
exclude: (^|/)test_[^/]*[.]py$
- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v1.19.1'
hooks:
Expand Down
47 changes: 47 additions & 0 deletions java/ci/RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Java and Lucene Maven releases

Release tags build both modules using the existing CUDA matrix. The release
gather job downloads each module's artifacts from the same workflow run, keeps
the CUDA builds in separate directories, and creates one Maven repository tree
per coordinate:

- `com.nvidia.cuvs:cuvs-java`
- `com.nvidia.cuvs.lucene:cuvs-lucene`

`assemble_maven_repo.py` takes the unclassified jar, sources, javadoc, and POM
from the highest numeric CUDA version in the matrix. This preserves the existing
higher-CUDA selection for common release artifacts and avoids merging
same-named jars from separate builds. Java retains each native architecture/CUDA
classifier. Lucene retains its attached `jar-with-dependencies` assembly when
present. Test jars and other Maven target directory contents are excluded.

Every input POM must match the release tag after normalizing a zero-padded patch
number. Lucene's POM must depend on the same Java version. Conflicting native
classifiers, missing required artifacts, invalid JARs, and unexpected build
directories fail gathering before publication.

Both modules use the shared Maven publisher with automatic publication enabled.
Lucene waits for the Java publisher's `PUBLISHED` output, so its dependency is
released first. Each published module emits a completed-publication receipt.
The standalone dispatcher in release-tracking starts the registered docs project
in `rapidsai/docs`; docs failures do not prevent Lucene publication.
Branch and nightly builds keep their existing build behavior; these newly added
publication jobs run on release tags.

## Configuration and rollout

Use the existing `GPG_PRIVATE_KEY`, `GPG_PASSPHRASE`, `MAVEN_DEPLOY_USERNAME`, and
`MAVEN_DEPLOY_TOKEN` conventions. The Sonatype token must authorize both Maven
coordinates. The common staging profile defaults to `com.nvidia.cuvs`; set
`MAVEN_STAGING_PROFILE_ID` to the actual verified parent namespace if different,
for example `com.nvidia`. Confirm the namespace and account access during rollout.

App credentials remain in release-tracking's existing `release-dispatch`
environment. Deploy the standalone docs dispatcher, `cuvs-java` and `cuvs-lucene`
docs registrations, and the shared workflow receipt/output contract before
enabling this caller on the release branch. Only one system should publish these
coordinates for a release; retire the legacy manual publishing path at cutover.

The local checks exercise Maven assembly and docs configuration without compiling
Java or CUDA. The complete matrix build, Sonatype namespace authorization, App
dispatch, and S3 upload still require validation in the release environment.
148 changes: 148 additions & 0 deletions java/ci/assemble_maven_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Gather cuVS Java build outputs into one Maven coordinate per module."""

import argparse
import filecmp
import re
import shutil
import xml.etree.ElementTree as ET # nosec B405: DTDs are rejected below.
import zipfile
from pathlib import Path

GROUPS = {
"cuvs-java": "com.nvidia.cuvs",
"cuvs-lucene": "com.nvidia.cuvs.lucene",
}
NS = {"m": "http://maven.apache.org/POM/4.0.0"}


def read_pom(pom):
"""Read direct POM coordinates without accepting entity declarations."""
text = pom.read_text()
if "<!DOCTYPE" in text.upper() or "<!ENTITY" in text.upper():
raise ValueError("POM entity declarations are unsupported")
return ET.fromstring(text) # nosec B314: Entity declarations rejected.


def read_coordinates(pom):
"""Read the module's coordinates rather than a dependency's coordinates."""
root = read_pom(pom)
return tuple(
root.findtext(f"m:{key}", namespaces=NS)
for key in ("groupId", "artifactId", "version")
)


def assemble(module, tag, artifacts_dir, output_dir):
"""Select common jars explicitly and preserve all native classifiers."""
match = re.fullmatch(r"v([0-9]{2})\.([0-9]{2})\.([0-9]+)", tag)
if not match or module not in GROUPS:
raise ValueError("A registered module and release tag are required")
major, minor, patch = match.groups()
version = f"{major}.{minor}.{int(patch)}"
builds = []
for path in artifacts_dir.iterdir():
cuda = re.fullmatch(
rf"{re.escape(module)}-cuda([0-9]+(?:\.[0-9]+)*)", path.name
)
if not path.is_dir() or not cuda:
raise ValueError(f"Unexpected build artifact: {path.name}")
cuda_version = tuple(map(int, cuda[1].split(".")))
if read_coordinates(path / "pom.xml") != (
GROUPS[module],
module,
version,
):
raise ValueError(f"POM does not match {module}:{version}")
builds.append((cuda_version, path))
if module == "cuvs-lucene":
dependencies = [
dependency
for dependency in read_pom(path / "pom.xml").findall(
"m:dependencies/m:dependency", NS
)
if dependency.findtext("m:groupId", namespaces=NS)
== GROUPS["cuvs-java"]
and dependency.findtext("m:artifactId", namespaces=NS)
== "cuvs-java"
]
if (
len(dependencies) != 1
or dependencies[0].findtext("m:version", namespaces=NS)
!= version
):
raise ValueError(
"Lucene must depend on the matching Java release"
)
if not builds:
raise ValueError("No module build artifacts were downloaded")
# Keep the established higher-CUDA selection for the unclassified release
# artifact, sources, javadoc, and POM. Never merge same-named jars blindly.
builds.sort(key=lambda entry: entry[0])
primary = builds[-1][1]
selected = {}
for suffix in (".jar", "-sources.jar", "-javadoc.jar"):
name = f"{module}-{version}{suffix}"
selected[name] = primary / name
selected[f"{module}-{version}.pom"] = primary / "pom.xml"
if module == "cuvs-java":
for cuda, path in builds:
natives = list(
path.glob(f"{module}-{version}-*-cuda{cuda[0]}.jar")
)
if len(natives) != 1:
raise ValueError(f"Expected one native classifier in {path}")
native = natives[0]
if not re.fullmatch(
rf"{module}-{re.escape(version)}-"
rf"(x86_64|aarch64)-cuda{cuda[0]}\.jar",
native.name,
):
raise ValueError(f"Invalid native classifier: {native.name}")
previous = selected.get(native.name)
if previous and not filecmp.cmp(previous, native, shallow=False):
raise ValueError(
f"Conflicting native classifier: {native.name}"
)
selected[native.name] = native
else:
assembled = primary / f"{module}-{version}-jar-with-dependencies.jar"
if assembled.is_file():
selected[assembled.name] = assembled
for name, source in selected.items():
if not source.is_file():
raise ValueError(f"Missing release artifact: {source}")
if name.endswith(".jar") and not zipfile.is_zipfile(source):
raise ValueError(f"Invalid JAR: {source}")
if output_dir.exists():
raise ValueError("Output directory must not already exist")
destination = output_dir / GROUPS[module].replace(".", "/")
destination = destination / module / version
destination.mkdir(parents=True)
try:
for name, source in selected.items():
shutil.copyfile(source, destination / name)
except Exception:
shutil.rmtree(output_dir)
raise
print(f"Primary build: {primary.name}")
print(f"Assembled {GROUPS[module]}:{module}:{version}")
return destination


def main():
"""Assemble artifacts downloaded from the current release workflow run."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--module", choices=GROUPS, required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--artifacts-dir", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args()
assemble(args.module, args.tag, args.artifacts_dir, args.output_dir)


if __name__ == "__main__":
main()
Loading
Loading