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__":