Skip to content
Merged
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
110 changes: 110 additions & 0 deletions .github/workflows/java-release.yml
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The release matrix skips the documentation examples. java.yml runs mvn --file examples/pom.xml verify after install, and verify now requires examples/pom.xml to use the release version. Please add the same step here between Build and test and the integration tests so the release artifact is checked against the examples before publishing.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validation runs only after a reviewer approves the maven environment because it is inside publish. A mistyped tag or missed version bump therefore runs the full matrix and requests approval before failing. Please move this step into versions, which already checks out the repository and has Python 3, so invalid releases fail before the matrix and environment gate.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack, will add guard as follow ups


# 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 }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setup-java@v4.7.1 defaults gpg-passphrase to the environment variable GPG_PASSPHRASE and writes that name into Maven settings. The publish step only defines MAVEN_GPG_PASSPHRASE, so a passphrase-protected key cannot be unlocked.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With bestPractices=true, maven-gpg-plugin 3.2.8 reads the passphrase from the MAVEN_GPG_PASSPHRASE environment variable directly and ignores the gpg.passphrase server in settings.xml, so this worked locally as written. But to ensure the two agree, will add setup-java to get gpg-passphrase: MAVEN_GPG_PASSPHRASE. thank you!

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}"
60 changes: 60 additions & 0 deletions java/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@
</license>
</licenses>

<developers>
<developer>
<id>amazonwebservices</id>
<organization>Amazon Web Services</organization>
<organizationUrl>https://aws.amazon.com</organizationUrl>
<roles>
<role>developer</role>
</roles>
</developer>
</developers>

<scm>
<connection>scm:git:https://github.com/aws/developer-toolkit-elasticache.git</connection>
<url>https://github.com/aws/developer-toolkit-elasticache/tree/main/java</url>
</scm>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>8</maven.compiler.release>
Expand Down Expand Up @@ -178,4 +194,48 @@
</plugin>
</plugins>
</build>

<profiles>
<!-- Maven Central release: `mvn -P release deploy`. Signs every artifact,
uploads them to the Central Portal and publishes them once validation
passes. Run only by java-release.yml, behind approval. -->
<profile>
<id>release</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.2.8</version>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
<configuration>
<bestPractices>true</bestPractices>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.11.0</version>
<extensions>true</extensions>
<configuration>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>published</waitUntil>
<!-- Publishing can take longer than the 30-minute default. -->
<waitMaxTime>3600</waitMaxTime>
<deploymentName>${project.artifactId} ${project.version}</deploymentName>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
94 changes: 90 additions & 4 deletions scripts/java_dev.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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__":
Expand Down
Loading