diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml
new file mode 100644
index 0000000..195c2e3
--- /dev/null
+++ b/.github/workflows/java-release.yml
@@ -0,0 +1,110 @@
+name: Publish Java package to Maven Central
+
+# A java-v* release tests every supported Java version, then signs the artifacts and
+# publishes them to Maven Central through the Central Portal, after approval.
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: read
+
+# cancel-in-progress: false — cancelling mid-upload can leave a deployment half done.
+concurrency:
+ group: java-release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ # Source of truth for the test matrix: java.supported.versions in pom.xml.
+ # java-v* releases only — test/publish inherit this via `needs`.
+ versions:
+ name: java versions
+ if: startsWith(github.ref_name, 'java-v')
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ data: ${{ steps.set.outputs.data }}
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Load supported versions from pom.xml
+ id: set
+ run: echo "data=$(python3 scripts/java_dev.py list-supported-versions)" >> "$GITHUB_OUTPUT"
+
+ # Full support matrix gates the release. Ubuntu only: the CI workflow already covers
+ # Windows and macOS. No dependency caching in release jobs.
+ test:
+ name: test (Java ${{ matrix.java-version }})
+ needs: versions
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false # report every version, not just the first failure
+ matrix:
+ java-version: ${{ fromJson(needs.versions.outputs.data).all }}
+ defaults:
+ run:
+ working-directory: java
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up Java ${{ matrix.java-version }}
+ uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
+ with:
+ distribution: corretto
+ java-version: ${{ matrix.java-version }}
+
+ - name: Build and test
+ run: mvn --batch-mode --no-transfer-progress install
+
+ - name: Run installed-artifact integration tests
+ run: mvn --batch-mode --no-transfer-progress --file integration-tests/pom.xml verify
+
+ publish:
+ name: publish to Maven Central
+ needs: [versions, test]
+ # A fork that copies this workflow and its tags cannot publish.
+ if: startsWith(github.ref_name, 'java-v') && github.repository_owner == 'aws'
+ runs-on: ubuntu-latest
+ # Publishing can take up to 60 minutes and the smoke test up to ~45.
+ timeout-minutes: 120
+ # Holds the Central token and signing key. Its required-reviewer gate is the
+ # approval to publish.
+ environment: maven
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ # Tag must match the pom version, which must not be a SNAPSHOT, and the
+ # CHANGELOG must have an entry.
+ - name: Verify the tag matches the pom version
+ run: python3 scripts/java_dev.py verify "$GITHUB_REF_NAME"
+
+ # Writes a settings.xml whose `central` server and GPG passphrase read the
+ # environment variables named below, and imports the signing key.
+ - name: Set up Java with the Central credentials and signing key
+ uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
+ with:
+ distribution: corretto
+ java-version: ${{ fromJson(needs.versions.outputs.data).max }}
+ server-id: central
+ server-username: CENTRAL_TOKEN_USERNAME
+ server-password: CENTRAL_TOKEN_PASSWORD
+ gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
+ gpg-passphrase: MAVEN_GPG_PASSPHRASE
+
+ # Tests already ran in the test job. The plugin uploads, waits for validation,
+ # publishes, and fails this step if any of that fails.
+ - name: Sign and publish to Maven Central
+ env:
+ CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }}
+ CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }}
+ MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
+ run: mvn --batch-mode --no-transfer-progress --file java/pom.xml -P release -DskipTests deploy
+
+ # Retries for up to ~45 minutes: Maven Central lags behind a publish.
+ - name: Smoke-test Maven Central
+ run: python3 scripts/java_dev.py smoke "${GITHUB_REF_NAME#java-v}"
diff --git a/java/pom.xml b/java/pom.xml
index 353bf6d..8406856 100644
--- a/java/pom.xml
+++ b/java/pom.xml
@@ -23,6 +23,22 @@
+
+
+ amazonwebservices
+ Amazon Web Services
+ https://aws.amazon.com
+
+ developer
+
+
+
+
+
+ scm:git:https://github.com/aws/developer-toolkit-elasticache.git
+ https://github.com/aws/developer-toolkit-elasticache/tree/main/java
+
+
UTF-8
8
@@ -178,4 +194,48 @@
+
+
+
+
+ release
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 3.2.8
+
+
+ sign-artifacts
+ verify
+
+ sign
+
+
+ true
+
+
+
+
+
+ org.sonatype.central
+ central-publishing-maven-plugin
+ 0.11.0
+ true
+
+ central
+ true
+ published
+
+ 3600
+ ${project.artifactId} ${project.version}
+
+
+
+
+
+
diff --git a/scripts/java_dev.py b/scripts/java_dev.py
index 1162329..62587d5 100644
--- a/scripts/java_dev.py
+++ b/scripts/java_dev.py
@@ -1,14 +1,40 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
+# ruff: noqa: INP001, T201, S310, S314, PLR2004 # CLI: prints, parses our own pom, fixed https URLs
import argparse
+import http.client
import json
+import time
+import urllib.error
+import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
POM = ROOT / "java" / "pom.xml"
+CHANGELOG = ROOT / "java" / "CHANGELOG.md"
+DEPENDENT_POMS = [
+ ROOT / "java" / "examples" / "pom.xml",
+ ROOT / "java" / "integration-tests" / "pom.xml",
+]
MAVEN_NAMESPACE = {"m": "http://maven.apache.org/POM/4.0.0"}
+TAG_PREFIX = "java-v"
+CENTRAL = "https://repo1.maven.org/maven2"
+# Maven Central usually serves a newly published release within 10-30 minutes.
+SMOKE_ATTEMPTS = 45
+SMOKE_RETRY_SECONDS = 60
+
+
+def fail(message: str) -> None:
+ raise SystemExit(f"::error::{message}")
+
+
+def pom_field(name: str) -> str:
+ value = ET.parse(POM).getroot().findtext(f"m:{name}", namespaces=MAVEN_NAMESPACE)
+ if not value:
+ fail(f"<{name}> is missing from java/pom.xml")
+ return value
def list_supported_versions() -> None:
@@ -25,20 +51,80 @@ def list_supported_versions() -> None:
or not versions
or not all(isinstance(version, str) and version for version in versions)
):
- raise SystemExit(
- "java.supported.versions must be a non-empty JSON string array"
- )
+ raise SystemExit("java.supported.versions must be a non-empty JSON string array")
result = {"all": versions, "min": versions[0], "max": versions[-1]}
print(json.dumps(result, separators=(",", ":")))
+def verify(tag: str) -> None:
+ version = pom_field("version")
+ if tag != f"{TAG_PREFIX}{version}":
+ fail(f"Release tag version does not match java/pom.xml version {version}.")
+ if version.endswith("-SNAPSHOT"):
+ fail(f"java/pom.xml version {version} is a SNAPSHOT; set the release version.")
+ if not CHANGELOG.is_file() or f"## [{version}]" not in CHANGELOG.read_text():
+ fail(f"java/CHANGELOG.md has no [{version}] entry.")
+ # The examples and integration tests depend on the published artifact.
+ for pom in DEPENDENT_POMS:
+ depends_on = (
+ ET.parse(pom)
+ .getroot()
+ .findtext(
+ f"m:dependencies/m:dependency[m:artifactId='{pom_field('artifactId')}']/m:version",
+ namespaces=MAVEN_NAMESPACE,
+ )
+ )
+ if depends_on != version:
+ fail(f"{pom.relative_to(ROOT)} depends on {depends_on}, not {version}.")
+ print(f"Publishing version {version}.")
+
+
+def smoke(version: str) -> None:
+ group = pom_field("groupId")
+ artifact = pom_field("artifactId")
+ base = (
+ f"{CENTRAL}/{group.replace('.', '/')}/{artifact}/{version}/{artifact}-{version}"
+ )
+ artifacts = [".pom", ".jar", "-sources.jar", "-javadoc.jar"]
+ files = artifacts + [suffix + ".asc" for suffix in artifacts]
+ for attempt in range(1, SMOKE_ATTEMPTS + 1):
+ missing = [suffix for suffix in files if not exists(base + suffix)]
+ if not missing:
+ print(f"Smoke test passed for {group}:{artifact}:{version}.")
+ return
+ if attempt == SMOKE_ATTEMPTS:
+ fail(f"{group}:{artifact}:{version} is not on Maven Central: {missing}.")
+ print(
+ f"attempt {attempt}/{SMOKE_ATTEMPTS}: not on Maven Central yet "
+ f"({', '.join(missing)}); retrying in {SMOKE_RETRY_SECONDS}s",
+ flush=True,
+ )
+ time.sleep(SMOKE_RETRY_SECONDS)
+
+
+def exists(url: str) -> bool:
+ request = urllib.request.Request(url, method="HEAD")
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ return response.status == 200
+ except (OSError, http.client.HTTPException):
+ return False
+
+
def main() -> None:
parser = argparse.ArgumentParser()
- parser.add_argument("command", choices=["list-supported-versions"])
+ commands = parser.add_subparsers(dest="command", required=True)
+ commands.add_parser("list-supported-versions")
+ commands.add_parser("verify").add_argument("tag")
+ commands.add_parser("smoke").add_argument("version")
args = parser.parse_args()
if args.command == "list-supported-versions":
list_supported_versions()
+ elif args.command == "verify":
+ verify(args.tag)
+ elif args.command == "smoke":
+ smoke(args.version)
if __name__ == "__main__":