Skip to content
Draft
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
2 changes: 0 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,6 @@ jobs:
# 'inputs' context at all, so fall back to github.sha or the retained bundle name ends
# in a trailing "-" with nothing after it.
source-git-sha: ${{ inputs.sha || github.sha }}
# com.nvidia.cuopt isn't its own verified Sonatype namespace; com.nvidia is.
profile-id: com.nvidia
# false = validate + drop (safe). true = stage PENDING for manual publish.
stage-for-maven-central-publish: true

Expand Down
15 changes: 1 addition & 14 deletions .github/workflows/maven-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# file is GPG-signed. The destination is chosen inside this workflow with
# `rapids-is-release-build`:
# - release build (vYY.MM.PP tag) -> Maven Central via the Sonatype Central
# Publisher Portal (OSSRH staging + validation, human-gated publish).
# Publisher Portal API (validation, human-gated publish).
# - non-release build (nightly, branch push) -> Sonatype snapshot repository
# (direct HTTP PUT, immediately available, no staging or portal step).
#
Expand Down Expand Up @@ -40,15 +40,6 @@ on:
required: false
type: string
default: ''
profile-id:
description: |
Sonatype staging profile to search, if different from the Maven groupId read from
the POM (e.g. a verified parent namespace like com.nvidia for a com.nvidia.cuopt
groupId). Defaults to the groupId when not set. Only applies to the Maven Central
path (release builds); ignored on the snapshot path.
required: false
type: string
default: ''
secrets:
GPG_PRIVATE_KEY:
description: Armored GPG private key used to sign every artifact at upload.
Expand Down Expand Up @@ -125,13 +116,9 @@ jobs:
--output-bundle central-bundle.zip
--auto-drop "${AUTO_DROP}"
)
if [[ -n ${PROFILE_ID} ]]; then
args+=(--profile-id "${PROFILE_ID}")
fi
java/cuopt/ci/maven-publish/maven_central_publish.sh "${args[@]}"
env:
GROUP_ID: ${{ steps.prepare.outputs.GROUP_ID }}
PROFILE_ID: ${{ inputs.profile-id }}
ARTIFACT_ID: ${{ steps.prepare.outputs.ARTIFACT_ID }}
VERSION: ${{ steps.prepare.outputs.VERSION }}
# Invert: stage-for-...=true means don't auto-drop.
Expand Down
78 changes: 17 additions & 61 deletions java/cuopt/ci/maven-publish/maven_central_publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Creates a deployment bundle from a signed Maven repository tree, uploads its
# files through Sonatype's OSSRH staging compatibility API, and polls the
# Publisher Portal until the deployment passes validation.
# Never calls the /publish endpoint: publication always requires a human click.
# Creates a deployment bundle from a signed Maven repository tree, uploads it to
# the Central Publisher Portal with the Publisher API, and polls until the
# deployment passes validation.
# Never calls the publish endpoint: publication always requires a human click.

set -euo pipefail

Expand All @@ -19,7 +19,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "${SCRIPT_DIR}/maven_central_publish_steps.sh"

CENTRAL_PORTAL_URL="https://central.sonatype.com"
OSSRH_STAGING_API_URL="https://ossrh-staging-api.central.sonatype.com"
# Used by wait_for_validated in the sourced step library.
# shellcheck disable=SC2034
POLL_INTERVAL_SEC=15
Expand All @@ -28,7 +27,6 @@ POLL_TIMEOUT_SEC=900

INPUT_DIR=""
GROUP_ID=""
PROFILE_ID=""
ARTIFACT_ID=""
VERSION=""
OUTPUT_BUNDLE=""
Expand All @@ -42,9 +40,9 @@ Usage: maven_central_publish.sh --input <path> --group-id <g> \\
--output-bundle <path> [OPTIONS]

Copies a signed Maven repository tree into scratch space, generates .md5 and
.sha1 sidecars, and creates a retained ZIP. It uploads the repository files
individually through Sonatype's OSSRH staging compatibility API, then hands the
repository to the Central Publisher Portal without publishing it.
.sha1 sidecars, and creates a retained ZIP. It uploads the ZIP to the Central
Publisher Portal with the Publisher API as a USER_MANAGED deployment, so it is
never published without a human click.

REQUIRED:
-i, --input Signed Maven repository directory.
Expand All @@ -54,18 +52,13 @@ REQUIRED:
-o, --output-bundle Path for the retained deployment ZIP.

OPTIONS:
--profile-id <p> Sonatype staging profile to search, if different from
--group-id (e.g. a verified parent namespace like
com.nvidia for a com.nvidia.cuopt groupId). Defaults to
--group-id.
--auto-drop <true|false> Drop after VALIDATED (default: true).
-h, --help Show this help message.

ENVIRONMENT VARIABLES:
MAVEN_DEPLOY_USERNAME Publisher Portal user token username.
MAVEN_DEPLOY_TOKEN Publisher Portal user token password.
GITHUB_OUTPUT If set, receives STAGING_REPOSITORY_KEY and
DEPLOYMENT_ID.
GITHUB_OUTPUT If set, receives DEPLOYMENT_ID.

EOF
}
Expand All @@ -87,11 +80,6 @@ parse_args() {
GROUP_ID=$2
shift 2
;;
--profile-id)
require_value "$1" "${2:-}"
PROFILE_ID=$2
shift 2
;;
-a|--artifact-id)
require_value "$1" "${2:-}"
ARTIFACT_ID=$2
Expand Down Expand Up @@ -134,11 +122,6 @@ if [[ ${AUTO_DROP} != "true" && ${AUTO_DROP} != "false" ]]; then
fi
require_maven_coordinates "${GROUP_ID}" "${ARTIFACT_ID}" "${VERSION}"
require_release_version "${VERSION}"
# A groupId nested under a verified parent namespace (e.g. com.nvidia.cuopt under the
# verified com.nvidia) still searches/creates its OSSRH staging repo under the parent's
# Sonatype profile, not the full groupId string -- so default to GROUP_ID, but let callers
# override it.
PROFILE_ID="${PROFILE_ID:-${GROUP_ID}}"
if [[ ! -d ${INPUT_DIR} ]]; then
fatal "--input '${INPUT_DIR}' does not exist or is not a directory"
fi
Expand All @@ -157,44 +140,18 @@ if [[ -e ${OUTPUT_BUNDLE} ]]; then
fi
echo "Maven Central upload"
echo " coordinates: ${GROUP_ID}:${ARTIFACT_ID}:${VERSION}"
echo " profile: ${PROFILE_ID}"
echo " auto-drop: ${AUTO_DROP}"
echo " input dir: ${INPUT_DIR}"
echo " output bundle: ${OUTPUT_BUNDLE}"
echo " staging API: ${OSSRH_STAGING_API_URL}"
echo " target portal: ${CENTRAL_PORTAL_URL}"
echo " portal: ${CENTRAL_PORTAL_URL}"

WORK_DIR="$(mktemp -d)"
STAGING_REPOSITORY_KEY=""
STAGING_FILE_UPLOADED="false"
STAGING_HANDED_OFF="false"

cleanup() {
local status=$?
if (( status != 0 )) \
&& [[ ${STAGING_FILE_UPLOADED} == "true" ]] \
&& [[ ${STAGING_HANDED_OFF} != "true" ]]; then
if [[ -z ${STAGING_REPOSITORY_KEY} ]]; then
STAGING_REPOSITORY_KEY=$(staging_search_repositories open 2>/dev/null \
| jq -r 'if (.repositories | length) == 1 then .repositories[0].key else empty end' \
2>/dev/null) || true
fi
if [[ -n ${STAGING_REPOSITORY_KEY} ]]; then
echo "Cleaning up open OSSRH staging repository ${STAGING_REPOSITORY_KEY}" >&2
staging_drop_repository "${STAGING_REPOSITORY_KEY}" \
|| echo "Warning: could not drop staging repository ${STAGING_REPOSITORY_KEY}; remove it before retrying" >&2
fi
fi
rm -rf "${WORK_DIR}"
return "${status}"
}
trap cleanup EXIT
trap 'rm -rf "${WORK_DIR}"' EXIT

BUNDLE_DIR="${WORK_DIR}/bundle"
BUNDLE_ARTIFACT_DIR="${BUNDLE_DIR}/$(maven_group_path "${GROUP_ID}")/${ARTIFACT_ID}/${VERSION}"
mkdir -p "${BUNDLE_DIR}"

require_no_open_staging_repository
copy_bundle "${INPUT_DIR}" "${BUNDLE_DIR}"
require_bundle_contents "${BUNDLE_ARTIFACT_DIR}"
generate_bundle_checksums "${BUNDLE_ARTIFACT_DIR}"
Expand All @@ -203,15 +160,14 @@ BUNDLE_ZIP="${WORK_DIR}/${ARTIFACT_ID}-${VERSION}.zip"
create_bundle_zip "${BUNDLE_ZIP}" "${BUNDLE_DIR}"
mv "${BUNDLE_ZIP}" "${OUTPUT_BUNDLE}"

DEPLOYMENT_ID=""
upload_tree_to_staging "${BUNDLE_DIR}"
find_open_staging_repository
echo "Handing staging repository to the Publisher Portal"
staging_handoff_repository "${STAGING_REPOSITORY_KEY}"
wait_for_portal_handoff "${STAGING_REPOSITORY_KEY}"
STAGING_HANDED_OFF="true"
PUBLISHING_TYPE="USER_MANAGED"
echo "Uploading bundle to the Publisher Portal"
DEPLOYMENT_ID=$(portal_upload_bundle "${OUTPUT_BUNDLE}" "${GROUP_ID}:${ARTIFACT_ID}:${VERSION}" "${PUBLISHING_TYPE}")
if [[ ! ${DEPLOYMENT_ID} =~ ^[A-Za-z0-9-]+$ ]]; then
fatal "Publisher Portal returned an invalid deployment id"
fi
echo " Portal deployment id: ${DEPLOYMENT_ID}"
if [[ -n ${GITHUB_OUTPUT:-} ]]; then
echo "STAGING_REPOSITORY_KEY=${STAGING_REPOSITORY_KEY}" >> "${GITHUB_OUTPUT}"
echo "DEPLOYMENT_ID=${DEPLOYMENT_ID}" >> "${GITHUB_OUTPUT}"
fi

Expand Down
128 changes: 14 additions & 114 deletions java/cuopt/ci/maven-publish/maven_central_publish_steps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Sonatype HTTP wrappers and local bundle helpers for maven_central_publish.sh.
# Central Publisher Portal HTTP wrappers and local bundle helpers for maven_central_publish.sh.

_portal_auth() {
printf '%s:%s' "${MAVEN_DEPLOY_USERNAME}" "${MAVEN_DEPLOY_TOKEN}" \
Expand Down Expand Up @@ -38,58 +38,28 @@ _sonatype_call() {
rm -f "${body_file}"
}

# _sonatype_upload FILE URL
# Basic auth, PUT FILE. Logs failure and returns 1 on non-2xx.
_sonatype_upload() {
local upload_file=$1 url=$2
local body_file status
# portal_upload_bundle ZIP NAME PUBLISHING_TYPE
# Bearer auth, multipart POST of the bundle. Prints the deployment id on 201;
# logs failure and returns 1 otherwise.
portal_upload_bundle() {
local zip_path=$1 name=$2 publishing_type=$3
local body_file status url
url="${CENTRAL_PORTAL_URL}/api/v1/publisher/upload?publishingType=${publishing_type}&name=$(printf %s "${name}" | jq -sRr @uri)"
body_file=$(mktemp)
status=$(curl -sS --retry 3 --retry-delay 2 \
--user "${MAVEN_DEPLOY_USERNAME}:${MAVEN_DEPLOY_TOKEN}" \
--upload-file "${upload_file}" \
-o "${body_file}" -w '%{http_code}' "${url}") \
-H "Authorization: Bearer $(_portal_auth)" \
--form "bundle=@${zip_path};type=application/octet-stream" \
-X POST -o "${body_file}" -w '%{http_code}' "${url}") \
|| status="transport-error"
if [[ ${status} != 2* ]]; then
_sonatype_error_log PUT "${url}" "${status}" "${body_file}" >&2
if [[ ${status} != 201 ]]; then
_sonatype_error_log POST "${url}" "${status}" "${body_file}" >&2
rm -f "${body_file}"
return 1
fi
tr -d '[:space:]' < "${body_file}"
rm -f "${body_file}"
}

staging_search_repositories() {
local state=${1:-}
local url
url="${OSSRH_STAGING_API_URL}/manual/search/repositories?ip=client&profile_id=$(printf %s "${PROFILE_ID}" | jq -sRr @uri)"
if [[ -n ${state} ]]; then
url+="&state=$(printf %s "${state}" | jq -sRr @uri)"
fi
_sonatype_call GET "${url}"
}

staging_upload_file() {
local file_path=$1 repository_path=$2
local encoded_path
encoded_path=$(jq -rn --arg path "${repository_path}" \
'$path | split("/") | map(@uri) | join("/")')
_sonatype_upload "${file_path}" \
"${OSSRH_STAGING_API_URL}/service/local/staging/deploy/maven2/${encoded_path}"
}

staging_handoff_repository() {
local repository_key=$1
_sonatype_call POST \
"${OSSRH_STAGING_API_URL}/manual/upload/repository/${repository_key}?publishing_type=portal_api" \
>/dev/null
}

staging_drop_repository() {
local repository_key=$1
_sonatype_call DELETE \
"${OSSRH_STAGING_API_URL}/manual/drop/repository/${repository_key}" \
>/dev/null
}

portal_get_status() {
local deployment_id=$1
_sonatype_call POST \
Expand Down Expand Up @@ -149,76 +119,6 @@ create_bundle_zip() {
(cd "${bundle_dir}" && zip -qr "${zip_path}" .)
}

require_no_open_staging_repository() {
local response count
response=$(staging_search_repositories open)
count=$(jq -er '.repositories | length' <<< "${response}") \
|| fatal "OSSRH staging search returned an invalid response"
if (( count != 0 )); then
jq . <<< "${response}" >&2 || true
fatal "found ${count} open OSSRH staging repository/repositories for ${PROFILE_ID} from this IP; drop or hand them off before retrying"
fi
}

upload_tree_to_staging() {
local root=$1 file repository_path count=0
echo "Uploading Maven repository files to the OSSRH staging service"
while IFS= read -r -d '' file; do
repository_path=${file#"${root}/"}
staging_upload_file "${file}" "${repository_path}"
# Consumed by the EXIT cleanup trap in maven_central_publish.sh.
# shellcheck disable=SC2034
STAGING_FILE_UPLOADED="true"
count=$((count + 1))
done < <(find "${root}" -type f -print0)
echo " uploaded files: ${count}"
}

find_open_staging_repository() {
local response count
response=$(staging_search_repositories open)
count=$(jq -er '.repositories | length' <<< "${response}") \
|| fatal "OSSRH staging search returned an invalid response"
if (( count != 1 )); then
jq . <<< "${response}" >&2 || true
fatal "expected exactly one open OSSRH staging repository after upload, found ${count}"
fi
STAGING_REPOSITORY_KEY=$(jq -er '.repositories[0].key | select(type == "string" and length > 0)' \
<<< "${response}") || fatal "OSSRH staging response did not contain a repository key"
# Keys are composite paths of the form <profileId>/<clientIp>/<groupId>--default-repository,
# so slashes are expected. Just guard against characters that would break URL path use.
if [[ ! ${STAGING_REPOSITORY_KEY} =~ ^[A-Za-z0-9._/-]+$ ]]; then
fatal "OSSRH staging service returned an invalid repository key: ${STAGING_REPOSITORY_KEY}"
fi
echo " staging repository: ${STAGING_REPOSITORY_KEY}"
}

wait_for_portal_handoff() {
local repository_key=$1
local response state elapsed=0
echo "Polling OSSRH staging handoff (interval=${POLL_INTERVAL_SEC}s, timeout=${POLL_TIMEOUT_SEC}s)"
while (( elapsed < POLL_TIMEOUT_SEC )); do
response=$(staging_search_repositories)
state=$(jq -r --arg key "${repository_key}" \
'.repositories[]? | select(.key == $key) | .state // "UNKNOWN"' \
<<< "${response}")
DEPLOYMENT_ID=$(jq -r --arg key "${repository_key}" \
'.repositories[]? | select(.key == $key) | .portal_deployment_id // empty' \
<<< "${response}")
echo " [${elapsed}s] staging state=${state:-UNKNOWN}"
if [[ -n ${DEPLOYMENT_ID} ]]; then
if [[ ! ${DEPLOYMENT_ID} =~ ^[A-Za-z0-9-]+$ ]]; then
fatal "OSSRH staging service returned an invalid Portal deployment id"
fi
echo " Portal deployment id: ${DEPLOYMENT_ID}"
return 0
fi
sleep "${POLL_INTERVAL_SEC}"
elapsed=$((elapsed + POLL_INTERVAL_SEC))
done
fatal "timed out waiting for OSSRH staging repository '${repository_key}' to reach the Portal"
}

wait_for_validated() {
local deployment_id=$1
local response state elapsed=0
Expand Down
Loading