From 5d31a9700fa16320d81aa83bffb0a48db7bbcffd Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 9 Oct 2026 10:01:48 -0700 Subject: [PATCH 1/7] build: support OIDC-authenticated Maven publishing proxy --- .github/scripts/publish-maven-central.py | 226 ++++++++++++++++++ .github/scripts/test_publish_maven_central.py | 185 ++++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/create-releases.yml | 60 +++++ CONTRIBUTING.md | 33 +++ .../src/main/kotlin/openai.publish.gradle.kts | 16 +- 6 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/publish-maven-central.py create mode 100644 .github/scripts/test_publish_maven_central.py diff --git a/.github/scripts/publish-maven-central.py b/.github/scripts/publish-maven-central.py new file mode 100644 index 000000000..a5bc48fde --- /dev/null +++ b/.github/scripts/publish-maven-central.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Publish signed staging output through an OIDC-authenticated credential proxy.""" + +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +import zipfile + + +class PublishError(Exception): + pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def https_url(url): + parsed = urllib.parse.urlsplit(url) + if (parsed.scheme != "https" or not parsed.hostname or parsed.username + or parsed.password or parsed.fragment): + raise PublishError("Expected an HTTPS URL without userinfo or fragment") + return parsed + + +def request(url, *, token, data=None, content_type=None, length=None): + https_url(url) + headers = {"Authorization": "Bearer " + token} if token else {} + if content_type: + headers["Content-Type"] = content_type + if length is not None: + headers["Content-Length"] = str(length) + req = urllib.request.Request(url, data=data, headers=headers) + # A credential-bearing redirect must never be followed. Do not expose raw + # upstream error bodies: vendor responses can contain sensitive diagnostics. + opener = urllib.request.build_opener(NoRedirect()) + try: + with opener.open(req, timeout=120) as response: + return response.read() + except urllib.error.HTTPError as error: + raise PublishError(f"Publishing request returned HTTP {error.code}; body suppressed") from None + except (OSError, urllib.error.URLError): + raise PublishError("Publishing transport failed; reconcile before retrying") from None + + +class EntraToken: + def __init__(self): + self.tenant = str(uuid.UUID(os.environ["MAVEN_CENTRAL_AZURE_TENANT_ID"])) + self.client = str(uuid.UUID(os.environ["MAVEN_CENTRAL_AZURE_CLIENT_ID"])) + self.resource = os.environ["MAVEN_CENTRAL_AZURE_RESOURCE"] + if not self.resource or self.resource.endswith("/.default"): + raise PublishError("Set the Azure resource identifier, without /.default") + self.value, self.expires = "", 0 + + def __call__(self): + if self.expires > time.monotonic() + 60: + return self.value + endpoint = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"] + parsed = https_url(endpoint) + if not parsed.hostname.endswith(".actions.githubusercontent.com"): + raise PublishError("Unexpected GitHub OIDC endpoint") + query = urllib.parse.parse_qsl(parsed.query) + query = [(k, v) for k, v in query if k != "audience"] + query.append(("audience", "api://AzureADTokenExchange")) + endpoint = urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query))) + assertion = json.loads(request(endpoint, token=os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]))["value"] + form = urllib.parse.urlencode({ + "client_id": self.client, + "scope": self.resource.rstrip("/") + "/.default", + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": assertion, + }).encode() + # Entra authenticates the assertion in the form, not an Authorization header. + value = json.loads(request( + f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token", + token="", data=form, content_type="application/x-www-form-urlencoded")) + self.value = value["access_token"] + lifetime = int(value["expires_in"]) + if not self.value or lifetime <= 0: + raise PublishError("Entra returned an unusable access token") + self.expires = time.monotonic() + lifetime + return self.value + + +def bundle(staging, output, version, artifacts, provenance): + """Whitelist release entries and check staged JARs against attested JAR hashes.""" + if staging.is_symlink() or not staging.is_dir(): + raise PublishError("Expected a real staging directory") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version) or version.endswith("-SNAPSHOT"): + raise PublishError("Expected a non-SNAPSHOT release version") + if not artifacts or len(set(artifacts)) != len(artifacts): + raise PublishError("Expected distinct release artifacts") + digests = {} + for line in provenance.read_text().splitlines(): + digest, path = line.split(" ", 1) + if not re.fullmatch(r"[0-9a-f]{64}", digest) or path in digests: + raise PublishError("Invalid provenance digest manifest") + digests[path] = digest + required, allowed, jars = set(), set(), {} + for artifact in artifacts: + if not re.fullmatch(r"openai-java(?:-[a-z0-9-]+)?", artifact): + raise PublishError("Unexpected Maven artifact name") + prefix = f"com/openai/{artifact}/{version}/{artifact}-{version}" + for suffix in (".jar", ".pom", "-sources.jar", "-javadoc.jar", ".module"): + name = prefix + suffix + if suffix != ".module": + required.update((name, name + ".asc")) + for signature in ("", ".asc"): + allowed.add(name + signature) + allowed.update(name + signature + "." + alg for alg in ("md5", "sha1", "sha256", "sha512")) + jars[prefix + ".jar"] = f"{artifact}/build/libs/{artifact}-{version}.jar" + if set(digests) != set(jars.values()): + raise PublishError("Provenance manifest does not match release artifact inventory") + files = {} + for path in staging.rglob("*"): + if path.is_symlink(): + raise PublishError("Symlinks are not allowed in release staging") + if path.is_file(): + name = path.relative_to(staging).as_posix() + if path.name.startswith("maven-metadata.xml"): + continue + if name not in allowed or path.stat().st_size == 0: + raise PublishError("Unexpected or empty staged artifact") + files[name] = path + if not required.issubset(files): + raise PublishError("Missing signed release artifact") + for name, path in files.items(): + algorithm = path.suffix.removeprefix(".") + if algorithm in {"md5", "sha1", "sha256", "sha512"}: + original = path.with_suffix("") + if not original.is_file(): + raise PublishError("Checksum has no corresponding artifact") + with original.open("rb") as stream: + if hashlib.file_digest(stream, algorithm).hexdigest() != path.read_text().strip().lower(): + raise PublishError("Staged artifact checksum verification failed") + elif not name.endswith(".asc") and name + ".asc" not in files: + raise PublishError("Missing artifact signature") + if name in jars: + with path.open("rb") as stream: + if hashlib.file_digest(stream, "sha256").hexdigest() != digests[jars[name]]: + raise PublishError("Staged JAR differs from attested JAR") + if name.endswith(".asc"): + result = subprocess.run(["gpg", "--batch", "--verify", str(path), str(path.with_suffix(""))], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode: + raise PublishError("Staged artifact signature verification failed") + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + for name, path in sorted(files.items()): + archive.write(path, name) + + +def receipt(message): + print(message, flush=True) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: + summary.write(message + "\n\n") + + +def publish(origin, archive, token, *, clock=time.monotonic, sleep=time.sleep): + parsed = https_url(origin) + if parsed.path not in ("", "/") or parsed.query: + raise PublishError("Proxy URL must be an HTTPS origin") + base = origin.rstrip("/") + "/api/v1/publisher" + # Streaming multipart from a temporary file avoids keeping release ZIPs in RAM. + with tempfile.TemporaryFile() as body: + boundary = uuid.uuid4().hex + body.write((f'--{boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="bundle.zip"\r\n' + 'Content-Type: application/octet-stream\r\n\r\n').encode()) + archive.seek(0) + shutil.copyfileobj(archive, body) + body.write(f"\r\n--{boundary}--\r\n".encode()) + length = body.tell() + body.seek(0) + credential = token() + receipt("Submitting Maven bundle. If no deployment ID follows, inspect Central Portal before retrying.") + try: + raw = request(base + "/upload?publishingType=USER_MANAGED", token=credential, + data=body, length=length, content_type="multipart/form-data; boundary=" + boundary) + deployment = str(uuid.UUID(raw.decode().strip())) + except (PublishError, ValueError, UnicodeError): + raise PublishError("Upload outcome unknown. Inspect Central Portal; do not automatically resubmit.") from None + receipt("Maven Central deployment ID: " + deployment) + deadline, released = clock() + 15 * 60, False + while clock() < deadline: + state = json.loads(request(base + "/status?id=" + deployment, token=token(), data=b""))["deploymentState"] + if state == "PUBLISHED": + return + if state == "VALIDATED" and not released: + request(base + "/deployment/" + deployment, token=token(), data=b"") + released = True + elif state not in {"PENDING", "VALIDATING", "VALIDATED", "PUBLISHING"}: + raise PublishError("Central validation failed or returned an unknown state; inspect the deployment ID") + sleep(5) + raise PublishError("Central polling timed out; inspect the recorded deployment ID before retrying") + + +def main(): + token = EntraToken() + with tempfile.TemporaryFile() as archive: + bundle(Path("build/auth-proxy-staging"), archive, os.environ["RELEASE_TAG"].removeprefix("v"), + os.environ["MAVEN_ARTIFACTS"].split(), Path(os.environ["RUNNER_TEMP"]) / "maven-artifact-provenance.sha256") + archive.seek(0) + receipt("Maven bundle SHA-256: " + hashlib.file_digest(archive, "sha256").hexdigest()) + publish(os.environ["MAVEN_CENTRAL_AUTH_PROXY_URL"], archive, token) + + +if __name__ == "__main__": + try: + main() + except PublishError as error: + raise SystemExit("::error::" + str(error)) from None + except Exception: + # Never print raw token responses, request objects or exception tracebacks. + raise SystemExit("::error::Publishing failed. Inspect the deployment receipt and configuration before retrying.") from None diff --git a/.github/scripts/test_publish_maven_central.py b/.github/scripts/test_publish_maven_central.py new file mode 100644 index 000000000..2019831e8 --- /dev/null +++ b/.github/scripts/test_publish_maven_central.py @@ -0,0 +1,185 @@ +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch +import urllib.error +import zipfile + +spec = importlib.util.spec_from_file_location("publisher", Path(__file__).with_name("publish-maven-central.py")) +publisher = importlib.util.module_from_spec(spec) +spec.loader.exec_module(publisher) +DEPLOYMENT = "11111111-1111-4111-8111-111111111111" +PROXY = "https://proxy.example.invalid" + + +class PublishingTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.staging = self.root / "staging" + self.base = self.staging / "com/openai/openai-java/1.2.3" + self.base.mkdir(parents=True) + self.prefix = self.base / "openai-java-1.2.3" + for suffix in (".jar", ".pom", "-sources.jar", "-javadoc.jar"): + Path(str(self.prefix) + suffix).write_bytes(b"synthetic") + Path(str(self.prefix) + suffix + ".asc").write_bytes(b"fake-signature") + self.manifest = self.root / "digests" + self.manifest.write_text(hashlib.sha256(b"synthetic").hexdigest() + " openai-java/build/libs/openai-java-1.2.3.jar\n") + + def bundle(self, **kwargs): + output = io.BytesIO() + publisher.bundle(self.staging, output, kwargs.get("version", "1.2.3"), + kwargs.get("artifacts", ["openai-java"]), self.manifest) + return output + + @patch.object(publisher.subprocess, "run", return_value=Mock(returncode=0)) + def test_bundle_inventory_and_signature_checks(self, verify): + (self.base.parent / "maven-metadata.xml").write_text("repository metadata") + archive = self.bundle() + with zipfile.ZipFile(archive) as z: + self.assertEqual(8, len(z.namelist())) + self.assertFalse(any("metadata" in name for name in z.namelist())) + self.assertEqual(4, verify.call_count) + self.assertTrue(all(call.args[0][:3] == ["gpg", "--batch", "--verify"] for call in verify.call_args_list)) + + @patch.object(publisher.subprocess, "run", return_value=Mock(returncode=0)) + def test_tampered_attested_jar_is_rejected(self, _): + Path(str(self.prefix) + ".jar").write_bytes(b"different") + with self.assertRaisesRegex(publisher.PublishError, "attested"): + self.bundle() + + def test_missing_signature_wrong_version_extra_file_and_symlink(self): + for case in ("missing", "extra", "symlink", "snapshot", "inventory"): + with self.subTest(case=case), patch.object(publisher.subprocess, "run", return_value=Mock(returncode=0)): + changed = self.base / "unexpected" + if case == "missing": + changed = Path(str(self.prefix) + ".pom.asc") + changed.unlink() + elif case == "extra": + changed.write_text("unexpected") + elif case == "symlink": + changed.symlink_to(self.manifest) + try: + with self.assertRaises(publisher.PublishError): + self.bundle(version="1.2.3-SNAPSHOT" if case == "snapshot" else "1.2.3", + artifacts=["openai-java", "openai-java-core"] if case == "inventory" else ["openai-java"]) + finally: + if case == "missing": + changed.write_bytes(b"fake-signature") + elif changed.exists() or changed.is_symlink(): + changed.unlink() + + @patch.object(publisher.subprocess, "run", return_value=Mock(returncode=1)) + def test_invalid_signature(self, _): + with self.assertRaisesRegex(publisher.PublishError, "signature"): + self.bundle() + + @patch.object(publisher.subprocess, "run", return_value=Mock(returncode=0)) + def test_corrupt_checksum_and_unsigned_module(self, _): + checksum = Path(str(self.prefix) + ".jar.sha256") + checksum.write_text("0" * 64) + with self.assertRaisesRegex(publisher.PublishError, "checksum"): + self.bundle() + checksum.unlink() + Path(str(self.prefix) + ".module").write_text("{}") + with self.assertRaisesRegex(publisher.PublishError, "signature"): + self.bundle() + + def test_publish_lifecycle_and_streamed_upload(self): + states = iter(["VALIDATING", "VALIDATED", "PUBLISHING", "PUBLISHED"]) + calls = [] + def request(url, **kw): + calls.append(url) + if "/upload?" in url: + self.assertIn("USER_MANAGED", url) + body = kw["data"].read() + self.assertEqual(kw["length"], len(body)) + self.assertIn(b"ZIP-CONTENT", body) + return DEPLOYMENT.encode() + if "/status?" in url: + return json.dumps({"deploymentState": next(states)}).encode() + return b"" + with patch.object(publisher, "request", side_effect=request), patch.object(publisher, "receipt") as receipt: + publisher.publish(PROXY, io.BytesIO(b"ZIP-CONTENT"), lambda: "fake-token", sleep=lambda _: None) + self.assertEqual(1, sum("/upload?" in c for c in calls)) + self.assertEqual(1, sum("/deployment/" in c for c in calls)) + receipt.assert_any_call("Maven Central deployment ID: " + DEPLOYMENT) + + def test_ambiguous_upload_is_never_retried(self): + with patch.object(publisher, "request", side_effect=publisher.PublishError("HTTP 502")) as request, patch.object(publisher, "receipt"): + with self.assertRaisesRegex(publisher.PublishError, "outcome unknown"): + publisher.publish(PROXY, io.BytesIO(b"fake"), lambda: "fake-token") + self.assertEqual(1, request.call_count) + + def test_failed_validation_never_publishes(self): + with patch.object(publisher, "request", side_effect=[DEPLOYMENT.encode(), b'{"deploymentState":"FAILED"}']) as request, patch.object(publisher, "receipt"): + with self.assertRaisesRegex(publisher.PublishError, "validation failed"): + publisher.publish(PROXY, io.BytesIO(b"fake"), lambda: "fake-token") + self.assertEqual(2, request.call_count) + + def test_poll_timeout_preserves_id_without_second_upload(self): + with patch.object(publisher, "request", return_value=DEPLOYMENT.encode()) as request, patch.object(publisher, "receipt") as receipt: + with self.assertRaisesRegex(publisher.PublishError, "polling timed out"): + publisher.publish(PROXY, io.BytesIO(b"fake"), lambda: "fake-token", clock=Mock(side_effect=[0, 901])) + self.assertEqual(1, request.call_count) + receipt.assert_any_call("Maven Central deployment ID: " + DEPLOYMENT) + + def test_reject_insecure_and_nonorigin_proxy_urls(self): + for url in ("http://proxy.invalid", "https://user:pass@proxy.invalid", PROXY + "/path", PROXY + "?query", PROXY + "#fragment"): + with self.subTest(url=url), patch.object(publisher, "request") as request: + with self.assertRaises(publisher.PublishError): + publisher.publish(url, io.BytesIO(), lambda: "fake") + request.assert_not_called() + + def test_no_redirect_and_sanitized_http_errors(self): + self.assertIsNone(publisher.NoRedirect().redirect_request(None, None, 307, "", {}, PROXY)) + opener = Mock() + opener.open.side_effect = urllib.error.HTTPError(PROXY, 503, "fake-secret", {}, io.BytesIO(b"fake-secret")) + with patch.object(publisher.urllib.request, "build_opener", return_value=opener): + with self.assertRaises(publisher.PublishError) as error: + publisher.request(PROXY, token="fake-secret") + self.assertNotIn("fake-secret", str(error.exception)) + + def test_oidc_exchange_and_refresh(self): + env = {"MAVEN_CENTRAL_AZURE_TENANT_ID": DEPLOYMENT, "MAVEN_CENTRAL_AZURE_CLIENT_ID": DEPLOYMENT, + "MAVEN_CENTRAL_AZURE_RESOURCE": "api://fake-resource", "ACTIONS_ID_TOKEN_REQUEST_URL": "https://pipelines.actions.githubusercontent.com/token?audience=old", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "fake-github-token"} + def response(url, **kw): + if "actions.githubusercontent.com" in url: + self.assertIn("audience=api%3A%2F%2FAzureADTokenExchange", url) + self.assertNotIn("audience=old", url) + return b'{"value":"fake-assertion"}' + self.assertEqual("", kw["token"]) + self.assertIn(b"client_assertion=fake-assertion", kw["data"]) + self.assertIn(b"scope=api%3A%2F%2Ffake-resource%2F.default", kw["data"]) + return b'{"access_token":"fake-entra-token","expires_in":3600}' + with patch.dict(os.environ, env), patch.object(publisher, "request", side_effect=response) as request, patch.object(publisher.time, "monotonic", return_value=100) as clock: + token = publisher.EntraToken() + self.assertEqual("fake-entra-token", token()) + token() + self.assertEqual(2, request.call_count) + clock.return_value = 3700 + token() + self.assertEqual(4, request.call_count) + + def test_workflow_keeps_vendor_secrets_out_of_proxy_steps(self): + workflow = Path(__file__).parents[1].joinpath("workflows/create-releases.yml").read_text() + legacy = workflow.split("- name: Publish to Maven Central\n")[1].split("- name:")[0] + proxy = workflow.split("- name: Stage signed Maven artifacts for auth proxy\n")[1].split("- name: Verify attested Maven artifacts")[0] + self.assertIn("if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL == ''", legacy) + self.assertEqual(2, proxy.count("if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != ''")) + self.assertNotIn("OPENAI_SONATYPE_USERNAME", proxy) + self.assertNotIn("OPENAI_SONATYPE_PASSWORD", proxy) + self.assertIn('"${publish_exclusions[@]}"', proxy) + self.assertIn('sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256"', proxy) + self.assertLess(workflow.index('install -m 700 .github/scripts/publish-maven-central.py'), workflow.index('ref: ${{ needs.release.outputs.source_sha }}', workflow.index('- name: Check out workflow scripts'))) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcdec26d5..e757f7986 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,9 @@ jobs: cache-provider: basic cache-read-only: true + - name: Test Maven publishing helper + run: python3 -m unittest discover -s .github/scripts -p 'test_publish_maven_central.py' + - name: Test build logic run: ./scripts/gradle :buildSrc:test diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index ca32e54d2..7ccb49887 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -271,6 +271,9 @@ jobs: - name: Preserve GraalVM test script for older release retries run: install -m 700 .github/scripts/run-graalvm-tests.sh "$RUNNER_TEMP/run-graalvm-tests.sh" + - name: Preserve publishing helper from the workflow revision + run: install -m 700 .github/scripts/publish-maven-central.py "$RUNNER_TEMP/publish-maven-central.py" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false @@ -433,6 +436,7 @@ jobs: predicate-path: ${{ steps.maven-artifacts.outputs.predicate_path }} - name: Publish to Maven Central + if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL == '' env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OPENAI_SONATYPE_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OPENAI_SONATYPE_PASSWORD }} @@ -477,6 +481,62 @@ jobs: --no-parallel \ --no-configuration-cache + - name: Stage signed Maven artifacts for auth proxy + if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' + env: + GPG_SIGNING_KEY: ${{ secrets.OPENAI_SONATYPE_GPG_SIGNING_KEY }} + GPG_SIGNING_PASSWORD: ${{ secrets.OPENAI_SONATYPE_GPG_SIGNING_PASSWORD }} + run: | + set -euo pipefail + + for variable in \ + GPG_SIGNING_KEY \ + GPG_SIGNING_PASSWORD; do + if [[ -z "${!variable}" ]]; then + echo "::error::$variable is empty" + exit 1 + fi + done + + printf '%s' "$GPG_SIGNING_KEY" | + gpg --batch --passphrase-fd 3 --import 3<<< "$GPG_SIGNING_PASSWORD" + GPG_SIGNING_KEY_ID="$( + gpg --with-colons --list-secret-keys | + awk -F : '$1 == "sec" { primary = 1; next } + primary && $1 == "fpr" { print "0x" substr($10, length($10) - 7); exit }' + )" + if [[ ! "$GPG_SIGNING_KEY_ID" =~ ^0x[0-9A-Fa-f]{8}$ ]]; then + echo "::error::Could not determine the signing key ID" + exit 1 + fi + export GPG_SIGNING_KEY_ID + + publish_exclusions=() + for artifact in $MAVEN_ARTIFACTS; do + publish_exclusions+=("--exclude-task" ":$artifact:jar") + done + + sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" + if [[ -e build/auth-proxy-staging || -L build/auth-proxy-staging ]]; then + echo "::error::Proxy staging directory must be fresh" + exit 1 + fi + ./gradlew publishAllPublicationsToAuthProxyStagingRepository -PstageForAuthProxy=true \ + "${publish_exclusions[@]}" \ + --stacktrace \ + --no-parallel \ + --no-configuration-cache + + - name: Publish through Maven Central auth proxy + if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' + env: + MAVEN_CENTRAL_AUTH_PROXY_URL: ${{ vars.MAVEN_CENTRAL_AUTH_PROXY_URL }} + MAVEN_CENTRAL_AZURE_TENANT_ID: ${{ vars.MAVEN_CENTRAL_AZURE_TENANT_ID }} + MAVEN_CENTRAL_AZURE_CLIENT_ID: ${{ vars.MAVEN_CENTRAL_AZURE_CLIENT_ID }} + MAVEN_CENTRAL_AZURE_RESOURCE: ${{ vars.MAVEN_CENTRAL_AZURE_RESOURCE }} + RELEASE_TAG: ${{ needs.release.outputs.release_tag }} + run: python3 "$RUNNER_TEMP/publish-maven-central.py" + - name: Verify attested Maven artifacts run: sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e505b3c6..0e013091e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -323,6 +323,39 @@ these commands with shell tracing enabled. After the rotated secrets work, revoke the old Central Portal token and remove any old repository-level copies of the `OPENAI_SONATYPE_*` secrets. +### Publish through an authentication proxy + +The release workflow can upload through an HTTPS credential proxy instead of exposing the Central +Portal token to the runner. Leave `MAVEN_CENTRAL_AUTH_PROXY_URL` unset to keep direct publishing. +After the proxy owner has reviewed and provisioned the integration, configure these variables in +the protected `publish` environment: + +- `MAVEN_CENTRAL_AUTH_PROXY_URL`: the proxy's HTTPS origin, without a path or query. +- `MAVEN_CENTRAL_AZURE_TENANT_ID`: the Microsoft Entra tenant UUID. +- `MAVEN_CENTRAL_AZURE_CLIENT_ID`: the dedicated federated application UUID. +- `MAVEN_CENTRAL_AZURE_RESOURCE`: the proxy's Entra resource identifier, without `/.default`. + +The application must trust GitHub OIDC for this repository's `publish` environment. Preserve its +`main`-only deployment restriction: the default environment subject does not also constrain the +branch or workflow. Review stronger workflow-specific federation with the proxy owner. The proxy +must validate the Entra application identity and inject the Central Portal bearer credential only +for its fixed Sonatype destination. Grant only upload, status and publish operations. Do not inspect +the ZIP body or assume a generic proxy's upload limits/timeouts fit a full SDK release. + +Before enabling the variable, complete an owner-approved, nonpublishing `USER_MANAGED` canary and +verify the real bundle size, token exchange, signatures and gateway behavior. The enabled release +workflow **does publish automatically after validation**. It stages signed artifacts without vendor +credentials, verifies their signatures and attested JAR hashes, then uploads once and records the +bundle digest and deployment ID in the job log and summary. The helper requires Python 3.11+. + +Failures never fall back to direct publishing or retry an upload automatically. An upload timeout +can mean Central accepted the bundle: inspect Portal before retrying, even if no deployment ID was +returned. For a known ID, inspect that deployment rather than starting another upload. Proxy mode +requires a release source containing the signed-staging Gradle support; older release retries must +use the direct path after confirming no existing deployment. Keep GPG signing secrets configured. +Remove/revoke the GitHub Sonatype token only after a successful approved rollout. Once it is revoked, +clearing the proxy URL alone is not sufficient to restore direct publishing. + ### Publish manually The GitHub workflow is preferred because it validates the immutable release identity and requires a Central Portal check diff --git a/buildSrc/src/main/kotlin/openai.publish.gradle.kts b/buildSrc/src/main/kotlin/openai.publish.gradle.kts index 036274115..c586e51c4 100644 --- a/buildSrc/src/main/kotlin/openai.publish.gradle.kts +++ b/buildSrc/src/main/kotlin/openai.publish.gradle.kts @@ -7,12 +7,18 @@ plugins { id("com.vanniktech.maven.publish") } +val stageForAuthProxy = providers.gradleProperty("stageForAuthProxy").map(String::toBoolean).orElse(false).get() +require(!(stageForAuthProxy && project.hasProperty("publishLocal"))) { + "Signed proxy staging cannot be combined with unsigned publishLocal" +} + publishing { repositories { - if (project.hasProperty("publishLocal")) { + if (project.hasProperty("publishLocal") || stageForAuthProxy) { maven { - name = "LocalFileSystem" - url = uri("${rootProject.layout.buildDirectory.get()}/local-maven-repo") + name = if (stageForAuthProxy) "AuthProxyStaging" else "LocalFileSystem" + val directory = if (stageForAuthProxy) "auth-proxy-staging" else "local-maven-repo" + url = uri("${rootProject.layout.buildDirectory.get()}/$directory") } } } @@ -33,7 +39,9 @@ configure { System.getenv("GPG_SIGNING_PASSWORD"), ) } - publishToMavenCentral() + if (!stageForAuthProxy) { + publishToMavenCentral() + } } coordinates(project.group.toString(), project.name, project.version.toString()) From 17088595cbac55ca1cd7c3c3d097f3f629544d5f Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 9 Oct 2026 10:50:07 -0700 Subject: [PATCH 2/7] build: move proxy publishing into Kotlin Gradle tooling --- .github/scripts/publish-maven-central.py | 226 ------------ .github/scripts/test_publish_maven_central.py | 185 ---------- .github/workflows/ci.yml | 3 - .github/workflows/create-releases.yml | 71 +--- CONTRIBUTING.md | 4 +- build.gradle.kts | 7 + .../gradle/MavenCentralProxyPublisher.kt | 336 ++++++++++++++++++ .../gradle/GradleCacheTrustPolicyTest.kt | 38 +- .../gradle/MavenCentralProxyPublisherTest.kt | 291 +++++++++++++++ 9 files changed, 687 insertions(+), 474 deletions(-) delete mode 100644 .github/scripts/publish-maven-central.py delete mode 100644 .github/scripts/test_publish_maven_central.py create mode 100644 buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt create mode 100644 buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt diff --git a/.github/scripts/publish-maven-central.py b/.github/scripts/publish-maven-central.py deleted file mode 100644 index a5bc48fde..000000000 --- a/.github/scripts/publish-maven-central.py +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env python3 -"""Publish signed staging output through an OIDC-authenticated credential proxy.""" - -import hashlib -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -import time -import urllib.error -import urllib.parse -import urllib.request -import uuid -import zipfile - - -class PublishError(Exception): - pass - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - return None - - -def https_url(url): - parsed = urllib.parse.urlsplit(url) - if (parsed.scheme != "https" or not parsed.hostname or parsed.username - or parsed.password or parsed.fragment): - raise PublishError("Expected an HTTPS URL without userinfo or fragment") - return parsed - - -def request(url, *, token, data=None, content_type=None, length=None): - https_url(url) - headers = {"Authorization": "Bearer " + token} if token else {} - if content_type: - headers["Content-Type"] = content_type - if length is not None: - headers["Content-Length"] = str(length) - req = urllib.request.Request(url, data=data, headers=headers) - # A credential-bearing redirect must never be followed. Do not expose raw - # upstream error bodies: vendor responses can contain sensitive diagnostics. - opener = urllib.request.build_opener(NoRedirect()) - try: - with opener.open(req, timeout=120) as response: - return response.read() - except urllib.error.HTTPError as error: - raise PublishError(f"Publishing request returned HTTP {error.code}; body suppressed") from None - except (OSError, urllib.error.URLError): - raise PublishError("Publishing transport failed; reconcile before retrying") from None - - -class EntraToken: - def __init__(self): - self.tenant = str(uuid.UUID(os.environ["MAVEN_CENTRAL_AZURE_TENANT_ID"])) - self.client = str(uuid.UUID(os.environ["MAVEN_CENTRAL_AZURE_CLIENT_ID"])) - self.resource = os.environ["MAVEN_CENTRAL_AZURE_RESOURCE"] - if not self.resource or self.resource.endswith("/.default"): - raise PublishError("Set the Azure resource identifier, without /.default") - self.value, self.expires = "", 0 - - def __call__(self): - if self.expires > time.monotonic() + 60: - return self.value - endpoint = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"] - parsed = https_url(endpoint) - if not parsed.hostname.endswith(".actions.githubusercontent.com"): - raise PublishError("Unexpected GitHub OIDC endpoint") - query = urllib.parse.parse_qsl(parsed.query) - query = [(k, v) for k, v in query if k != "audience"] - query.append(("audience", "api://AzureADTokenExchange")) - endpoint = urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query))) - assertion = json.loads(request(endpoint, token=os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]))["value"] - form = urllib.parse.urlencode({ - "client_id": self.client, - "scope": self.resource.rstrip("/") + "/.default", - "grant_type": "client_credentials", - "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - "client_assertion": assertion, - }).encode() - # Entra authenticates the assertion in the form, not an Authorization header. - value = json.loads(request( - f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token", - token="", data=form, content_type="application/x-www-form-urlencoded")) - self.value = value["access_token"] - lifetime = int(value["expires_in"]) - if not self.value or lifetime <= 0: - raise PublishError("Entra returned an unusable access token") - self.expires = time.monotonic() + lifetime - return self.value - - -def bundle(staging, output, version, artifacts, provenance): - """Whitelist release entries and check staged JARs against attested JAR hashes.""" - if staging.is_symlink() or not staging.is_dir(): - raise PublishError("Expected a real staging directory") - if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version) or version.endswith("-SNAPSHOT"): - raise PublishError("Expected a non-SNAPSHOT release version") - if not artifacts or len(set(artifacts)) != len(artifacts): - raise PublishError("Expected distinct release artifacts") - digests = {} - for line in provenance.read_text().splitlines(): - digest, path = line.split(" ", 1) - if not re.fullmatch(r"[0-9a-f]{64}", digest) or path in digests: - raise PublishError("Invalid provenance digest manifest") - digests[path] = digest - required, allowed, jars = set(), set(), {} - for artifact in artifacts: - if not re.fullmatch(r"openai-java(?:-[a-z0-9-]+)?", artifact): - raise PublishError("Unexpected Maven artifact name") - prefix = f"com/openai/{artifact}/{version}/{artifact}-{version}" - for suffix in (".jar", ".pom", "-sources.jar", "-javadoc.jar", ".module"): - name = prefix + suffix - if suffix != ".module": - required.update((name, name + ".asc")) - for signature in ("", ".asc"): - allowed.add(name + signature) - allowed.update(name + signature + "." + alg for alg in ("md5", "sha1", "sha256", "sha512")) - jars[prefix + ".jar"] = f"{artifact}/build/libs/{artifact}-{version}.jar" - if set(digests) != set(jars.values()): - raise PublishError("Provenance manifest does not match release artifact inventory") - files = {} - for path in staging.rglob("*"): - if path.is_symlink(): - raise PublishError("Symlinks are not allowed in release staging") - if path.is_file(): - name = path.relative_to(staging).as_posix() - if path.name.startswith("maven-metadata.xml"): - continue - if name not in allowed or path.stat().st_size == 0: - raise PublishError("Unexpected or empty staged artifact") - files[name] = path - if not required.issubset(files): - raise PublishError("Missing signed release artifact") - for name, path in files.items(): - algorithm = path.suffix.removeprefix(".") - if algorithm in {"md5", "sha1", "sha256", "sha512"}: - original = path.with_suffix("") - if not original.is_file(): - raise PublishError("Checksum has no corresponding artifact") - with original.open("rb") as stream: - if hashlib.file_digest(stream, algorithm).hexdigest() != path.read_text().strip().lower(): - raise PublishError("Staged artifact checksum verification failed") - elif not name.endswith(".asc") and name + ".asc" not in files: - raise PublishError("Missing artifact signature") - if name in jars: - with path.open("rb") as stream: - if hashlib.file_digest(stream, "sha256").hexdigest() != digests[jars[name]]: - raise PublishError("Staged JAR differs from attested JAR") - if name.endswith(".asc"): - result = subprocess.run(["gpg", "--batch", "--verify", str(path), str(path.with_suffix(""))], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - if result.returncode: - raise PublishError("Staged artifact signature verification failed") - with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive: - for name, path in sorted(files.items()): - archive.write(path, name) - - -def receipt(message): - print(message, flush=True) - if os.environ.get("GITHUB_STEP_SUMMARY"): - with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: - summary.write(message + "\n\n") - - -def publish(origin, archive, token, *, clock=time.monotonic, sleep=time.sleep): - parsed = https_url(origin) - if parsed.path not in ("", "/") or parsed.query: - raise PublishError("Proxy URL must be an HTTPS origin") - base = origin.rstrip("/") + "/api/v1/publisher" - # Streaming multipart from a temporary file avoids keeping release ZIPs in RAM. - with tempfile.TemporaryFile() as body: - boundary = uuid.uuid4().hex - body.write((f'--{boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="bundle.zip"\r\n' - 'Content-Type: application/octet-stream\r\n\r\n').encode()) - archive.seek(0) - shutil.copyfileobj(archive, body) - body.write(f"\r\n--{boundary}--\r\n".encode()) - length = body.tell() - body.seek(0) - credential = token() - receipt("Submitting Maven bundle. If no deployment ID follows, inspect Central Portal before retrying.") - try: - raw = request(base + "/upload?publishingType=USER_MANAGED", token=credential, - data=body, length=length, content_type="multipart/form-data; boundary=" + boundary) - deployment = str(uuid.UUID(raw.decode().strip())) - except (PublishError, ValueError, UnicodeError): - raise PublishError("Upload outcome unknown. Inspect Central Portal; do not automatically resubmit.") from None - receipt("Maven Central deployment ID: " + deployment) - deadline, released = clock() + 15 * 60, False - while clock() < deadline: - state = json.loads(request(base + "/status?id=" + deployment, token=token(), data=b""))["deploymentState"] - if state == "PUBLISHED": - return - if state == "VALIDATED" and not released: - request(base + "/deployment/" + deployment, token=token(), data=b"") - released = True - elif state not in {"PENDING", "VALIDATING", "VALIDATED", "PUBLISHING"}: - raise PublishError("Central validation failed or returned an unknown state; inspect the deployment ID") - sleep(5) - raise PublishError("Central polling timed out; inspect the recorded deployment ID before retrying") - - -def main(): - token = EntraToken() - with tempfile.TemporaryFile() as archive: - bundle(Path("build/auth-proxy-staging"), archive, os.environ["RELEASE_TAG"].removeprefix("v"), - os.environ["MAVEN_ARTIFACTS"].split(), Path(os.environ["RUNNER_TEMP"]) / "maven-artifact-provenance.sha256") - archive.seek(0) - receipt("Maven bundle SHA-256: " + hashlib.file_digest(archive, "sha256").hexdigest()) - publish(os.environ["MAVEN_CENTRAL_AUTH_PROXY_URL"], archive, token) - - -if __name__ == "__main__": - try: - main() - except PublishError as error: - raise SystemExit("::error::" + str(error)) from None - except Exception: - # Never print raw token responses, request objects or exception tracebacks. - raise SystemExit("::error::Publishing failed. Inspect the deployment receipt and configuration before retrying.") from None diff --git a/.github/scripts/test_publish_maven_central.py b/.github/scripts/test_publish_maven_central.py deleted file mode 100644 index 2019831e8..000000000 --- a/.github/scripts/test_publish_maven_central.py +++ /dev/null @@ -1,185 +0,0 @@ -import hashlib -import importlib.util -import io -import json -import os -from pathlib import Path -import tempfile -import unittest -from unittest.mock import Mock, patch -import urllib.error -import zipfile - -spec = importlib.util.spec_from_file_location("publisher", Path(__file__).with_name("publish-maven-central.py")) -publisher = importlib.util.module_from_spec(spec) -spec.loader.exec_module(publisher) -DEPLOYMENT = "11111111-1111-4111-8111-111111111111" -PROXY = "https://proxy.example.invalid" - - -class PublishingTest(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) - self.staging = self.root / "staging" - self.base = self.staging / "com/openai/openai-java/1.2.3" - self.base.mkdir(parents=True) - self.prefix = self.base / "openai-java-1.2.3" - for suffix in (".jar", ".pom", "-sources.jar", "-javadoc.jar"): - Path(str(self.prefix) + suffix).write_bytes(b"synthetic") - Path(str(self.prefix) + suffix + ".asc").write_bytes(b"fake-signature") - self.manifest = self.root / "digests" - self.manifest.write_text(hashlib.sha256(b"synthetic").hexdigest() + " openai-java/build/libs/openai-java-1.2.3.jar\n") - - def bundle(self, **kwargs): - output = io.BytesIO() - publisher.bundle(self.staging, output, kwargs.get("version", "1.2.3"), - kwargs.get("artifacts", ["openai-java"]), self.manifest) - return output - - @patch.object(publisher.subprocess, "run", return_value=Mock(returncode=0)) - def test_bundle_inventory_and_signature_checks(self, verify): - (self.base.parent / "maven-metadata.xml").write_text("repository metadata") - archive = self.bundle() - with zipfile.ZipFile(archive) as z: - self.assertEqual(8, len(z.namelist())) - self.assertFalse(any("metadata" in name for name in z.namelist())) - self.assertEqual(4, verify.call_count) - self.assertTrue(all(call.args[0][:3] == ["gpg", "--batch", "--verify"] for call in verify.call_args_list)) - - @patch.object(publisher.subprocess, "run", return_value=Mock(returncode=0)) - def test_tampered_attested_jar_is_rejected(self, _): - Path(str(self.prefix) + ".jar").write_bytes(b"different") - with self.assertRaisesRegex(publisher.PublishError, "attested"): - self.bundle() - - def test_missing_signature_wrong_version_extra_file_and_symlink(self): - for case in ("missing", "extra", "symlink", "snapshot", "inventory"): - with self.subTest(case=case), patch.object(publisher.subprocess, "run", return_value=Mock(returncode=0)): - changed = self.base / "unexpected" - if case == "missing": - changed = Path(str(self.prefix) + ".pom.asc") - changed.unlink() - elif case == "extra": - changed.write_text("unexpected") - elif case == "symlink": - changed.symlink_to(self.manifest) - try: - with self.assertRaises(publisher.PublishError): - self.bundle(version="1.2.3-SNAPSHOT" if case == "snapshot" else "1.2.3", - artifacts=["openai-java", "openai-java-core"] if case == "inventory" else ["openai-java"]) - finally: - if case == "missing": - changed.write_bytes(b"fake-signature") - elif changed.exists() or changed.is_symlink(): - changed.unlink() - - @patch.object(publisher.subprocess, "run", return_value=Mock(returncode=1)) - def test_invalid_signature(self, _): - with self.assertRaisesRegex(publisher.PublishError, "signature"): - self.bundle() - - @patch.object(publisher.subprocess, "run", return_value=Mock(returncode=0)) - def test_corrupt_checksum_and_unsigned_module(self, _): - checksum = Path(str(self.prefix) + ".jar.sha256") - checksum.write_text("0" * 64) - with self.assertRaisesRegex(publisher.PublishError, "checksum"): - self.bundle() - checksum.unlink() - Path(str(self.prefix) + ".module").write_text("{}") - with self.assertRaisesRegex(publisher.PublishError, "signature"): - self.bundle() - - def test_publish_lifecycle_and_streamed_upload(self): - states = iter(["VALIDATING", "VALIDATED", "PUBLISHING", "PUBLISHED"]) - calls = [] - def request(url, **kw): - calls.append(url) - if "/upload?" in url: - self.assertIn("USER_MANAGED", url) - body = kw["data"].read() - self.assertEqual(kw["length"], len(body)) - self.assertIn(b"ZIP-CONTENT", body) - return DEPLOYMENT.encode() - if "/status?" in url: - return json.dumps({"deploymentState": next(states)}).encode() - return b"" - with patch.object(publisher, "request", side_effect=request), patch.object(publisher, "receipt") as receipt: - publisher.publish(PROXY, io.BytesIO(b"ZIP-CONTENT"), lambda: "fake-token", sleep=lambda _: None) - self.assertEqual(1, sum("/upload?" in c for c in calls)) - self.assertEqual(1, sum("/deployment/" in c for c in calls)) - receipt.assert_any_call("Maven Central deployment ID: " + DEPLOYMENT) - - def test_ambiguous_upload_is_never_retried(self): - with patch.object(publisher, "request", side_effect=publisher.PublishError("HTTP 502")) as request, patch.object(publisher, "receipt"): - with self.assertRaisesRegex(publisher.PublishError, "outcome unknown"): - publisher.publish(PROXY, io.BytesIO(b"fake"), lambda: "fake-token") - self.assertEqual(1, request.call_count) - - def test_failed_validation_never_publishes(self): - with patch.object(publisher, "request", side_effect=[DEPLOYMENT.encode(), b'{"deploymentState":"FAILED"}']) as request, patch.object(publisher, "receipt"): - with self.assertRaisesRegex(publisher.PublishError, "validation failed"): - publisher.publish(PROXY, io.BytesIO(b"fake"), lambda: "fake-token") - self.assertEqual(2, request.call_count) - - def test_poll_timeout_preserves_id_without_second_upload(self): - with patch.object(publisher, "request", return_value=DEPLOYMENT.encode()) as request, patch.object(publisher, "receipt") as receipt: - with self.assertRaisesRegex(publisher.PublishError, "polling timed out"): - publisher.publish(PROXY, io.BytesIO(b"fake"), lambda: "fake-token", clock=Mock(side_effect=[0, 901])) - self.assertEqual(1, request.call_count) - receipt.assert_any_call("Maven Central deployment ID: " + DEPLOYMENT) - - def test_reject_insecure_and_nonorigin_proxy_urls(self): - for url in ("http://proxy.invalid", "https://user:pass@proxy.invalid", PROXY + "/path", PROXY + "?query", PROXY + "#fragment"): - with self.subTest(url=url), patch.object(publisher, "request") as request: - with self.assertRaises(publisher.PublishError): - publisher.publish(url, io.BytesIO(), lambda: "fake") - request.assert_not_called() - - def test_no_redirect_and_sanitized_http_errors(self): - self.assertIsNone(publisher.NoRedirect().redirect_request(None, None, 307, "", {}, PROXY)) - opener = Mock() - opener.open.side_effect = urllib.error.HTTPError(PROXY, 503, "fake-secret", {}, io.BytesIO(b"fake-secret")) - with patch.object(publisher.urllib.request, "build_opener", return_value=opener): - with self.assertRaises(publisher.PublishError) as error: - publisher.request(PROXY, token="fake-secret") - self.assertNotIn("fake-secret", str(error.exception)) - - def test_oidc_exchange_and_refresh(self): - env = {"MAVEN_CENTRAL_AZURE_TENANT_ID": DEPLOYMENT, "MAVEN_CENTRAL_AZURE_CLIENT_ID": DEPLOYMENT, - "MAVEN_CENTRAL_AZURE_RESOURCE": "api://fake-resource", "ACTIONS_ID_TOKEN_REQUEST_URL": "https://pipelines.actions.githubusercontent.com/token?audience=old", - "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "fake-github-token"} - def response(url, **kw): - if "actions.githubusercontent.com" in url: - self.assertIn("audience=api%3A%2F%2FAzureADTokenExchange", url) - self.assertNotIn("audience=old", url) - return b'{"value":"fake-assertion"}' - self.assertEqual("", kw["token"]) - self.assertIn(b"client_assertion=fake-assertion", kw["data"]) - self.assertIn(b"scope=api%3A%2F%2Ffake-resource%2F.default", kw["data"]) - return b'{"access_token":"fake-entra-token","expires_in":3600}' - with patch.dict(os.environ, env), patch.object(publisher, "request", side_effect=response) as request, patch.object(publisher.time, "monotonic", return_value=100) as clock: - token = publisher.EntraToken() - self.assertEqual("fake-entra-token", token()) - token() - self.assertEqual(2, request.call_count) - clock.return_value = 3700 - token() - self.assertEqual(4, request.call_count) - - def test_workflow_keeps_vendor_secrets_out_of_proxy_steps(self): - workflow = Path(__file__).parents[1].joinpath("workflows/create-releases.yml").read_text() - legacy = workflow.split("- name: Publish to Maven Central\n")[1].split("- name:")[0] - proxy = workflow.split("- name: Stage signed Maven artifacts for auth proxy\n")[1].split("- name: Verify attested Maven artifacts")[0] - self.assertIn("if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL == ''", legacy) - self.assertEqual(2, proxy.count("if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != ''")) - self.assertNotIn("OPENAI_SONATYPE_USERNAME", proxy) - self.assertNotIn("OPENAI_SONATYPE_PASSWORD", proxy) - self.assertIn('"${publish_exclusions[@]}"', proxy) - self.assertIn('sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256"', proxy) - self.assertLess(workflow.index('install -m 700 .github/scripts/publish-maven-central.py'), workflow.index('ref: ${{ needs.release.outputs.source_sha }}', workflow.index('- name: Check out workflow scripts'))) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e757f7986..bcdec26d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,9 +111,6 @@ jobs: cache-provider: basic cache-read-only: true - - name: Test Maven publishing helper - run: python3 -m unittest discover -s .github/scripts -p 'test_publish_maven_central.py' - - name: Test build logic run: ./scripts/gradle :buildSrc:test diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index 7ccb49887..8ee126244 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -271,9 +271,6 @@ jobs: - name: Preserve GraalVM test script for older release retries run: install -m 700 .github/scripts/run-graalvm-tests.sh "$RUNNER_TEMP/run-graalvm-tests.sh" - - name: Preserve publishing helper from the workflow revision - run: install -m 700 .github/scripts/publish-maven-central.py "$RUNNER_TEMP/publish-maven-central.py" - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 with: persist-credentials: false @@ -436,20 +433,20 @@ jobs: predicate-path: ${{ steps.maven-artifacts.outputs.predicate_path }} - name: Publish to Maven Central - if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL == '' env: - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OPENAI_SONATYPE_USERNAME }} - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OPENAI_SONATYPE_PASSWORD }} + MAVEN_CENTRAL_AUTH_PROXY_URL: ${{ vars.MAVEN_CENTRAL_AUTH_PROXY_URL }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ vars.MAVEN_CENTRAL_AUTH_PROXY_URL == '' && secrets.OPENAI_SONATYPE_USERNAME || '' }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ vars.MAVEN_CENTRAL_AUTH_PROXY_URL == '' && secrets.OPENAI_SONATYPE_PASSWORD || '' }} GPG_SIGNING_KEY: ${{ secrets.OPENAI_SONATYPE_GPG_SIGNING_KEY }} GPG_SIGNING_PASSWORD: ${{ secrets.OPENAI_SONATYPE_GPG_SIGNING_PASSWORD }} run: | set -euo pipefail - for variable in \ - ORG_GRADLE_PROJECT_mavenCentralUsername \ - ORG_GRADLE_PROJECT_mavenCentralPassword \ - GPG_SIGNING_KEY \ - GPG_SIGNING_PASSWORD; do + required_variables=(GPG_SIGNING_KEY GPG_SIGNING_PASSWORD) + if [[ -z "$MAVEN_CENTRAL_AUTH_PROXY_URL" ]]; then + required_variables+=(ORG_GRADLE_PROJECT_mavenCentralUsername ORG_GRADLE_PROJECT_mavenCentralPassword) + fi + for variable in "${required_variables[@]}"; do if [[ -z "${!variable}" ]]; then echo "::error::$variable is empty" exit 1 @@ -474,54 +471,17 @@ jobs: publish_exclusions+=("--exclude-task" ":$artifact:jar") done - sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" - ./gradlew publishAndReleaseToMavenCentral \ - "${publish_exclusions[@]}" \ - --stacktrace \ - --no-parallel \ - --no-configuration-cache - - - name: Stage signed Maven artifacts for auth proxy - if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' - env: - GPG_SIGNING_KEY: ${{ secrets.OPENAI_SONATYPE_GPG_SIGNING_KEY }} - GPG_SIGNING_PASSWORD: ${{ secrets.OPENAI_SONATYPE_GPG_SIGNING_PASSWORD }} - run: | - set -euo pipefail - - for variable in \ - GPG_SIGNING_KEY \ - GPG_SIGNING_PASSWORD; do - if [[ -z "${!variable}" ]]; then - echo "::error::$variable is empty" + publish_tasks=(publishAndReleaseToMavenCentral) + if [[ -n "${MAVEN_CENTRAL_AUTH_PROXY_URL:-}" ]]; then + if [[ -e build/auth-proxy-staging || -L build/auth-proxy-staging ]]; then + echo "::error::Proxy staging directory must be fresh" exit 1 fi - done - - printf '%s' "$GPG_SIGNING_KEY" | - gpg --batch --passphrase-fd 3 --import 3<<< "$GPG_SIGNING_PASSWORD" - GPG_SIGNING_KEY_ID="$( - gpg --with-colons --list-secret-keys | - awk -F : '$1 == "sec" { primary = 1; next } - primary && $1 == "fpr" { print "0x" substr($10, length($10) - 7); exit }' - )" - if [[ ! "$GPG_SIGNING_KEY_ID" =~ ^0x[0-9A-Fa-f]{8}$ ]]; then - echo "::error::Could not determine the signing key ID" - exit 1 + publish_tasks=(publishAllPublicationsToAuthProxyStagingRepository -PstageForAuthProxy=true) fi - export GPG_SIGNING_KEY_ID - - publish_exclusions=() - for artifact in $MAVEN_ARTIFACTS; do - publish_exclusions+=("--exclude-task" ":$artifact:jar") - done sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" - if [[ -e build/auth-proxy-staging || -L build/auth-proxy-staging ]]; then - echo "::error::Proxy staging directory must be fresh" - exit 1 - fi - ./gradlew publishAllPublicationsToAuthProxyStagingRepository -PstageForAuthProxy=true \ + ./gradlew "${publish_tasks[@]}" \ "${publish_exclusions[@]}" \ --stacktrace \ --no-parallel \ @@ -530,12 +490,13 @@ jobs: - name: Publish through Maven Central auth proxy if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' env: + JAVA_TOOL_OPTIONS: -Djdk.httpclient.redirects.retrylimit=1 MAVEN_CENTRAL_AUTH_PROXY_URL: ${{ vars.MAVEN_CENTRAL_AUTH_PROXY_URL }} MAVEN_CENTRAL_AZURE_TENANT_ID: ${{ vars.MAVEN_CENTRAL_AZURE_TENANT_ID }} MAVEN_CENTRAL_AZURE_CLIENT_ID: ${{ vars.MAVEN_CENTRAL_AZURE_CLIENT_ID }} MAVEN_CENTRAL_AZURE_RESOURCE: ${{ vars.MAVEN_CENTRAL_AZURE_RESOURCE }} RELEASE_TAG: ${{ needs.release.outputs.release_tag }} - run: python3 "$RUNNER_TEMP/publish-maven-central.py" + run: ./gradlew publishViaAuthProxy -PstageForAuthProxy=true --no-daemon --no-configuration-cache - name: Verify attested Maven artifacts run: sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e013091e..cafc48fbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -346,7 +346,9 @@ Before enabling the variable, complete an owner-approved, nonpublishing `USER_MA verify the real bundle size, token exchange, signatures and gateway behavior. The enabled release workflow **does publish automatically after validation**. It stages signed artifacts without vendor credentials, verifies their signatures and attested JAR hashes, then uploads once and records the -bundle digest and deployment ID in the job log and summary. The helper requires Python 3.11+. +bundle digest and deployment ID in the job log and summary. The uploader is the `publishViaAuthProxy` Kotlin Gradle task in `buildSrc`; it uses the build JDK. +The workflow sets `JAVA_TOOL_OPTIONS=-Djdk.httpclient.redirects.retrylimit=1` before starting +that JVM to disable HTTP-client retries as well as application-level retries. Failures never fall back to direct publishing or retry an upload automatically. An upload timeout can mean Central accepted the bundle: inspect Portal before retrying, even if no deployment ID was diff --git a/build.gradle.kts b/build.gradle.kts index 5132a3bb6..a44d51d2b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,4 @@ +import com.openai.gradle.MavenCentralProxyPublisher import com.openai.gradle.CoreCompilationShards import com.openai.gradle.GenerateVersionSupportMatrixTask import com.openai.gradle.VersionSupportPolicy @@ -184,3 +185,9 @@ tasks.named("dokkaJavadocCollector").configure { } .forEach { mustRunAfter(it) } } + +// Run separately after signed staging so no Sonatype credentials enter this task. +tasks.register("publishViaAuthProxy") { + notCompatibleWithConfigurationCache("Release credentials are read only during execution") + doLast { MavenCentralProxyPublisher.run(rootDir.toPath()) } +} diff --git a/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt new file mode 100644 index 000000000..5e621f139 --- /dev/null +++ b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt @@ -0,0 +1,336 @@ +package com.openai.gradle + +import groovy.json.JsonSlurper +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpRequest.BodyPublishers +import java.net.http.HttpResponse.BodyHandlers +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.time.Duration +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import org.gradle.api.GradleException + +/** Release-only transport. Credentials stay in memory and uploads are never retried. */ +class MavenCentralProxyPublisher( + private val environment: Map = System.getenv(), + private val send: (URI, String?, HttpRequest.BodyPublisher?, String?) -> String = ::sendHttp, + private val receipt: (String) -> Unit = ::record, + private val clock: () -> Long = { System.nanoTime() / 1_000_000_000 }, + private val sleep: (Long) -> Unit = Thread::sleep, +) { + private var accessToken = "" + private var expiresAt = 0L + + internal fun token(): String { + if (expiresAt > clock() + 60) return accessToken + val tenant = UUID.fromString(environment.getValue("MAVEN_CENTRAL_AZURE_TENANT_ID")) + val client = UUID.fromString(environment.getValue("MAVEN_CENTRAL_AZURE_CLIENT_ID")) + val resource = environment.getValue("MAVEN_CENTRAL_AZURE_RESOURCE") + check(resource.isNotBlank() && !resource.endsWith("/.default")) + val endpoint = httpsUri(environment.getValue("ACTIONS_ID_TOKEN_REQUEST_URL")) + check(endpoint.host.endsWith(".actions.githubusercontent.com")) + val query = + endpoint.rawQuery.orEmpty().split('&').filter { + it.isNotEmpty() && URLDecoder.decode(it.substringBefore('='), "UTF-8") != "audience" + } + "audience=${encode("api://AzureADTokenExchange") }" + val oidcUri = URI(endpoint.toString().substringBefore('?') + "?" + query.joinToString("&")) + val assertion = + json(send(oidcUri, environment.getValue("ACTIONS_ID_TOKEN_REQUEST_TOKEN"), null, null))[ + "value"] + as String + val form = + mapOf( + "client_id" to client.toString(), + "scope" to resource.trimEnd('/') + "/.default", + "grant_type" to "client_credentials", + "client_assertion_type" to + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion" to assertion, + ) + .entries + .joinToString("&") { "${encode(it.key)}=${encode(it.value)}" } + val response = + json( + send( + URI("https://login.microsoftonline.com/$tenant/oauth2/v2.0/token"), + null, + BodyPublishers.ofString(form), + "application/x-www-form-urlencoded", + ) + ) + accessToken = response["access_token"] as String + val lifetime = response["expires_in"].toString().toLong() + check(accessToken.isNotEmpty() && lifetime > 0) + expiresAt = clock() + lifetime + return accessToken + } + + internal fun publish(archive: Path) { + val origin = httpsUri(environment.getValue("MAVEN_CENTRAL_AUTH_PROXY_URL")) + check(origin.path in setOf("", "/") && origin.rawQuery == null) + val base = origin.toString().trimEnd('/') + "/api/v1/publisher" + val boundary = UUID.randomUUID().toString() + val body = + BodyPublishers.concat( + BodyPublishers.ofString( + "--$boundary\r\nContent-Disposition: form-data; name=\"bundle\"; filename=\"bundle.zip\"\r\nContent-Type: application/octet-stream\r\n\r\n" + ), + BodyPublishers.ofFile(archive), + BodyPublishers.ofString("\r\n--$boundary--\r\n"), + ) + val credential = token() + receipt( + "Submitting Maven bundle. If no deployment ID follows, inspect Central Portal before retrying." + ) + val deployment = + try { + UUID.fromString( + send( + URI("$base/upload?publishingType=USER_MANAGED"), + credential, + body, + "multipart/form-data; boundary=$boundary", + ) + .trim() + ) + } catch (_: Exception) { + throw GradleException( + "Upload outcome unknown. Inspect Central Portal; do not automatically resubmit." + ) + } + receipt("Maven Central deployment ID: $deployment") + val deadline = clock() + 15 * 60 + var released = false + while (clock() < deadline) { + val state = + json( + send(URI("$base/status?id=$deployment"), token(), BodyPublishers.noBody(), null) + )["deploymentState"] + when (state) { + "PUBLISHED" -> return + "VALIDATED" -> + if (!released) { + send( + URI("$base/deployment/$deployment"), + token(), + BodyPublishers.noBody(), + null, + ) + released = true + } + "PENDING", + "VALIDATING", + "PUBLISHING" -> Unit + else -> + throw GradleException( + "Central validation failed or returned an unknown state; inspect the deployment ID" + ) + } + sleep(5000) + } + throw GradleException( + "Central polling timed out; inspect the recorded deployment ID before retrying" + ) + } + + companion object { + private val httpClient = + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(30)) + .build() + + private fun encode(value: String) = URLEncoder.encode(value, "UTF-8") + + private fun json(value: String) = JsonSlurper().parseText(value) as Map<*, *> + + internal fun httpsUri(value: String): URI = + URI(value).also { + check( + it.scheme == "https" && + !it.host.isNullOrEmpty() && + it.rawUserInfo == null && + it.rawFragment == null + ) + } + + internal fun sendHttp( + uri: URI, + token: String?, + body: HttpRequest.BodyPublisher?, + contentType: String?, + ): String { + httpsUri(uri.toString()) + try { + val request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(120)) + if (token != null) request.header("Authorization", "Bearer $token") + if (contentType != null) request.header("Content-Type", contentType) + if (body != null) request.POST(body) + // A separate --no-daemon Gradle invocation owns this short-lived client. + val response = httpClient.send(request.build(), BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) { + throw GradleException( + "Publishing request returned HTTP ${response.statusCode()}; body suppressed" + ) + } + return response.body() + } catch (error: GradleException) { + throw error + } catch (_: Exception) { + // Do not attach transport exceptions: they can include request credentials. + throw GradleException("Publishing transport failed; reconcile before retrying") + } + } + + internal fun digest(path: Path, algorithm: String = "SHA-256"): String { + val digest = MessageDigest.getInstance(algorithm) + path.toFile().inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + internal fun bundle( + staging: Path, + output: Path, + version: String, + artifacts: List, + provenance: Path, + verifySignature: (Path) -> Boolean = { signature -> + ProcessBuilder( + "gpg", + "--batch", + "--verify", + signature.toString(), + signature.toString().removeSuffix(".asc"), + ) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + .waitFor() == 0 + }, + ) { + check(!Files.isSymbolicLink(staging) && Files.isDirectory(staging)) + check( + version.matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?")) && + !version.endsWith("-SNAPSHOT") + ) + check(artifacts.isNotEmpty() && artifacts.distinct() == artifacts) + val digests = linkedMapOf() + Files.readAllLines(provenance).forEach { line -> + val parts = line.split(" ", limit = 2) + check(parts.size == 2 && parts[0].matches(Regex("[0-9a-f]{64}"))) + check(digests.put(parts[1], parts[0]) == null) + } + val algorithms = + mapOf( + "md5" to "MD5", + "sha1" to "SHA-1", + "sha256" to "SHA-256", + "sha512" to "SHA-512", + ) + val required = mutableSetOf() + val allowed = mutableSetOf() + val jars = linkedMapOf() + artifacts.forEach { artifact -> + check(artifact.matches(Regex("openai-java(?:-[a-z0-9-]+)?"))) + val prefix = "com/openai/$artifact/$version/$artifact-$version" + listOf(".jar", ".pom", "-sources.jar", "-javadoc.jar", ".module").forEach { suffix + -> + val name = prefix + suffix + if (suffix != ".module") required.addAll(listOf(name, "$name.asc")) + listOf(name, "$name.asc").forEach { file -> + allowed.add(file) + allowed.addAll(algorithms.keys.map { "$file.$it" }) + } + } + jars["$prefix.jar"] = "$artifact/build/libs/$artifact-$version.jar" + } + check(digests.keys == jars.values.toSet()) + val files = linkedMapOf() + Files.walk(staging).use { paths -> + paths.forEach { path -> + check(!Files.isSymbolicLink(path)) + if ( + Files.isRegularFile(path) && + !path.fileName.toString().startsWith("maven-metadata.xml") + ) { + val name = staging.relativize(path).toString().replace('\\', '/') + check(name in allowed && Files.size(path) > 0) + files[name] = path + } + } + } + check(files.keys.containsAll(required)) + files.forEach { (name, path) -> + val algorithm = algorithms[name.substringAfterLast('.')] + if (algorithm != null) { + check( + digest( + path.resolveSibling(path.fileName.toString().substringBeforeLast('.')), + algorithm, + ) == Files.readString(path).trim().lowercase() + ) + } else if (!name.endsWith(".asc")) { + check("$name.asc" in files) + } + jars[name]?.let { check(digest(path) == digests[it]) } + if (name.endsWith(".asc")) check(verifySignature(path)) + } + ZipOutputStream(Files.newOutputStream(output)).use { zip -> + files.toSortedMap().forEach { (name, path) -> + zip.putNextEntry(ZipEntry(name)) + Files.copy(path, zip) + zip.closeEntry() + } + } + } + + private fun record(message: String) { + println(message) + System.getenv("GITHUB_STEP_SUMMARY")?.let { + Path.of(it).toFile().appendText("$message\n\n") + } + } + + fun run(root: Path) { + val archive = Files.createTempFile("maven-release-", ".zip") + try { + check(System.getProperty("jdk.httpclient.redirects.retrylimit") == "1") { + "Start the uploader JVM with -Djdk.httpclient.redirects.retrylimit=1" + } + val environment = System.getenv() + bundle( + root.resolve("build/auth-proxy-staging"), + archive, + environment.getValue("RELEASE_TAG").removePrefix("v"), + environment.getValue("MAVEN_ARTIFACTS").split(" "), + Path.of(environment.getValue("RUNNER_TEMP"), "maven-artifact-provenance.sha256"), + ) + record("Maven bundle SHA-256: ${digest(archive)}") + MavenCentralProxyPublisher(environment).publish(archive) + } catch (error: GradleException) { + throw error + } catch (_: Exception) { + // Configuration, parser and subprocess exceptions can contain sensitive data. + throw GradleException( + "Publishing failed. Inspect the deployment receipt and configuration before retrying." + ) + } finally { + Files.deleteIfExists(archive) + } + } + } +} diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index 209b62d16..0ad23743a 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt @@ -368,6 +368,37 @@ class GradleCacheTrustPolicyTest { } } + @Test + fun `publishing shares signing setup without giving vendor credentials to proxy mode`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val steps = parseWorkflow(workflow).job("publish").steps + val signing = steps.single { "GPG_SIGNING_KEY" in it.environment } + val upload = steps.single { it.run?.contains("publishViaAuthProxy") == true } + assertEquals(1, steps.count { "GPG_SIGNING_KEY" in it.environment }) + for (kind in listOf("Username", "Password")) { + val secret = "OPENAI_SONATYPE_${kind.uppercase(Locale.ROOT)}" + assertEquals( + "\${{ vars.MAVEN_CENTRAL_AUTH_PROXY_URL == '' && secrets.$secret || '' }}", + signing.environment["ORG_GRADLE_PROJECT_mavenCentral$kind"], + ) + } + val script = requireNotNull(signing.run) + assertContains(script, "publish_tasks=(publishAndReleaseToMavenCentral)") + assertContains( + script, + "publish_tasks=(publishAllPublicationsToAuthProxyStagingRepository -PstageForAuthProxy=true)", + ) + assertContains(script, "if [[ -n \"\${MAVEN_CENTRAL_AUTH_PROXY_URL:-}\" ]]") + assertEquals("vars.MAVEN_CENTRAL_AUTH_PROXY_URL != ''", upload.condition) + assertTrue(upload.environment.values.none { "secrets." in it }) + assertEquals( + "-Djdk.httpclient.redirects.retrylimit=1", + upload.environment["JAVA_TOOL_OPTIONS"], + ) + assertContains(requireNotNull(upload.run), "publishViaAuthProxy") + assertContains(requireNotNull(upload.run), "--no-daemon --no-configuration-cache") + } + @Test fun `publishing attests every released artifact before exposing signing secrets`() { val workflow = Path.of("../.github/workflows/create-releases.yml").readText() @@ -384,7 +415,7 @@ class GradleCacheTrustPolicyTest { workflow.replaceFirst(verification, ""), workflow.replaceFirst( verification, - " ./gradlew publishAndReleaseToMavenCentral\n$verification", + " ./gradlew \"\${publish_tasks[@]}\"\n$verification", ), ) .forEach { poisonedWorkflow -> @@ -1175,7 +1206,7 @@ class GradleCacheTrustPolicyTest { val publication = requireNotNull(publishSteps.single { it.name == "Publish to Maven Central" }.run) val serializedInvocation = - "./gradlew publishAndReleaseToMavenCentral \\\n" + + "./gradlew \"\${publish_tasks[@]}\" \\\n" + " \"\${publish_exclusions[@]}\" \\\n" + " --stacktrace \\\n" + " --no-parallel \\\n" + @@ -1268,8 +1299,7 @@ class GradleCacheTrustPolicyTest { val publicationScript = requireNotNull(publishJob.steps[signingIndex].run) val verification = "sha256sum --check \"\$RUNNER_TEMP/maven-artifact-provenance.sha256\"" val verificationStart = publicationScript.indexOf(verification) - val publicationStart = - publicationScript.indexOf("./gradlew publishAndReleaseToMavenCentral") + val publicationStart = publicationScript.indexOf("./gradlew \"\${publish_tasks[@]}\"") assertTrue( verificationStart >= 0 && publicationStart > verificationStart, "Verify attested artifact digests before irreversible Maven Central publication.", diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt new file mode 100644 index 000000000..53a35969b --- /dev/null +++ b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt @@ -0,0 +1,291 @@ +package com.openai.gradle + +import java.net.URI +import java.net.http.HttpRequest +import java.net.http.HttpResponse.BodySubscribers +import java.nio.ByteBuffer +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.Flow +import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import org.gradle.api.GradleException +import org.junit.jupiter.api.io.TempDir + +class MavenCentralProxyPublisherTest { + @TempDir lateinit var temporary: Path + private val deployment = "11111111-1111-4111-8111-111111111111" + private val environment = + mapOf( + "MAVEN_CENTRAL_AUTH_PROXY_URL" to "https://proxy.example.invalid", + "MAVEN_CENTRAL_AZURE_TENANT_ID" to deployment, + "MAVEN_CENTRAL_AZURE_CLIENT_ID" to deployment, + "MAVEN_CENTRAL_AZURE_RESOURCE" to "api://fake-resource", + "ACTIONS_ID_TOKEN_REQUEST_URL" to + "https://pipelines.actions.githubusercontent.com/token?audience=old", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN" to "fake-github-token", + ) + + private fun body(publisher: HttpRequest.BodyPublisher): String { + val subscriber = BodySubscribers.ofByteArray() + publisher.subscribe( + object : Flow.Subscriber { + override fun onSubscribe(subscription: Flow.Subscription) = + subscriber.onSubscribe(subscription) + + override fun onNext(item: ByteBuffer) = subscriber.onNext(listOf(item)) + + override fun onError(error: Throwable) = subscriber.onError(error) + + override fun onComplete() = subscriber.onComplete() + } + ) + return String(subscriber.body.toCompletableFuture().get(5, TimeUnit.SECONDS)) + } + + private fun client( + receipts: MutableList = mutableListOf(), + clock: () -> Long = { 0 }, + central: (URI, HttpRequest.BodyPublisher?) -> String, + ) = + MavenCentralProxyPublisher( + environment, + { uri, token, data, _ -> + when (uri.host) { + "pipelines.actions.githubusercontent.com" -> """{"value":"fake-assertion"}""" + "login.microsoftonline.com" -> + """{"access_token":"fake-entra","expires_in":3600}""" + else -> { + assertEquals("fake-entra", token) + central(uri, data) + } + } + }, + receipts::add, + clock, + {}, + ) + + private fun archive() = + temporary.resolve("bundle.zip").also { Files.writeString(it, "ZIP-CONTENT") } + + @Test + fun `upload is streamed once and validated before publishing`() { + val states = listOf("VALIDATING", "VALIDATED", "PUBLISHING", "PUBLISHED").iterator() + val calls = mutableListOf() + val receipts = mutableListOf() + client(receipts) { uri, data -> + calls.add(uri.path) + when { + uri.path.endsWith("/upload") -> { + assertEquals("publishingType=USER_MANAGED", uri.query) + val content = body(requireNotNull(data)) + assertEquals(content.toByteArray().size.toLong(), data.contentLength()) + assertContains(content, "ZIP-CONTENT") + deployment + } + uri.path.endsWith("/status") -> """{"deploymentState":"${states.next()}"}""" + else -> "" + } + } + .publish(archive()) + assertEquals(1, calls.count { it.endsWith("/upload") }) + assertEquals(1, calls.count { "/deployment/" in it }) + assertContains(receipts, "Maven Central deployment ID: $deployment") + } + + @Test + fun `unknown upload outcome is sanitized and never retried`() { + var calls = 0 + val publisher = client { _, _ -> + calls++ + error("fake-secret") + } + val error = assertFailsWith { publisher.publish(archive()) } + assertContains(error.message.orEmpty(), "outcome unknown") + assertFalse(error.toString().contains("fake-secret")) + assertEquals(null, error.cause) + assertEquals(1, calls) + } + + @Test + fun `failed validation does not publish`() { + val calls = mutableListOf() + val publisher = client { uri, _ -> + calls.add(uri.path) + if (uri.path.endsWith("/upload")) deployment else """{"deploymentState":"FAILED"}""" + } + assertFailsWith { publisher.publish(archive()) } + assertEquals(2, calls.size) + assertFalse(calls.any { "/deployment/" in it }) + } + + @Test + fun `poll timeout retains receipt without reuploading`() { + var now = 0L + val receipts = mutableListOf() + var uploads = 0 + val publisher = + client(receipts, { now.also { now += 1000 } }) { _, _ -> + uploads++ + deployment + } + assertFailsWith { publisher.publish(archive()) } + assertEquals(1, uploads) + assertContains(receipts, "Maven Central deployment ID: $deployment") + } + + @Test + fun `proxy origin must be HTTPS without path query userinfo or fragment`() { + listOf( + "http://proxy.invalid", + "https://user:pass@proxy.invalid", + "https://proxy.invalid/path", + "https://proxy.invalid?query", + "https://proxy.invalid#fragment", + ) + .forEach { url -> + val publisher = + MavenCentralProxyPublisher( + environment + ("MAVEN_CENTRAL_AUTH_PROXY_URL" to url), + { _, _, _, _ -> error("must not request") }, + {}, + ) + assertFails { publisher.publish(archive()) } + } + } + + @Test + fun `OIDC exchange replaces audience and refreshes expiring credentials`() { + var now = 100L + var exchanges = 0 + val publisher = + MavenCentralProxyPublisher( + environment, + { uri, token, data, _ -> + if (uri.host.endsWith(".actions.githubusercontent.com")) { + assertContains(uri.rawQuery, "audience=api%3A%2F%2FAzureADTokenExchange") + assertFalse(uri.rawQuery.contains("audience=old")) + assertEquals("fake-github-token", token) + """{"value":"fake-assertion"}""" + } else { + exchanges++ + assertEquals(null, token) + val form = body(requireNotNull(data)) + assertContains(form, "client_assertion=fake-assertion") + assertContains(form, "scope=api%3A%2F%2Ffake-resource%2F.default") + """{"access_token":"fake-entra","expires_in":3600}""" + } + }, + {}, + { now }, + ) + assertEquals("fake-entra", publisher.token()) + publisher.token() + assertEquals(1, exchanges) + now = 3700 + publisher.token() + assertEquals(2, exchanges) + } + + @Test + fun `unexpected OIDC endpoint is rejected before sending credentials`() { + var requested = false + val publisher = + MavenCentralProxyPublisher( + environment + ("ACTIONS_ID_TOKEN_REQUEST_URL" to "https://evil.invalid/token"), + { _, _, _, _ -> + requested = true + "" + }, + {}, + ) + assertFails { publisher.token() } + assertFalse(requested) + } + + private fun staged(): Path { + val staging = temporary.resolve("staging") + val prefix = staging.resolve("com/openai/openai-java/1.2.3/openai-java-1.2.3") + Files.createDirectories(prefix.parent) + listOf(".jar", ".pom", "-sources.jar", "-javadoc.jar").forEach { + Files.writeString(Path.of("$prefix$it"), "synthetic") + Files.writeString(Path.of("$prefix$it.asc"), "fake-signature") + } + val digest = MavenCentralProxyPublisher.digest(Path.of("$prefix.jar")) + Files.writeString( + temporary.resolve("manifest"), + "$digest openai-java/build/libs/openai-java-1.2.3.jar\n", + ) + return staging + } + + private fun bundle(staging: Path, verify: (Path) -> Boolean = { true }): Path { + val output = temporary.resolve("output.zip") + MavenCentralProxyPublisher.bundle( + staging, + output, + "1.2.3", + listOf("openai-java"), + temporary.resolve("manifest"), + verify, + ) + return output + } + + @Test + fun `bundle verifies all signatures and excludes Maven metadata`() { + val staging = staged() + Files.writeString(staging.resolve("maven-metadata.xml"), "metadata") + var verified = 0 + ZipFile( + bundle(staging) { + verified++ + true + } + .toFile() + ) + .use { + assertEquals(8, it.size()) + assertFalse(it.entries().asSequence().any { entry -> "metadata" in entry.name }) + } + assertEquals(4, verified) + } + + @Test + fun `tampered attested jar and invalid signatures are rejected`() { + val staging = staged() + assertFails { bundle(staging) { false } } + Files.writeString( + staging.resolve("com/openai/openai-java/1.2.3/openai-java-1.2.3.jar"), + "tampered", + ) + assertFails { bundle(staging) } + } + + @Test + fun `missing signature corrupt checksum unsigned module and symlink are rejected`() { + val staging = staged() + val base = staging.resolve("com/openai/openai-java/1.2.3") + val signature = base.resolve("openai-java-1.2.3.jar.asc") + Files.delete(signature) + assertFails { bundle(staging) } + Files.writeString(signature, "fake-signature") + val checksum = base.resolve("openai-java-1.2.3.jar.sha256") + Files.writeString(checksum, "0".repeat(64)) + assertFails { bundle(staging) } + Files.delete(checksum) + val module = base.resolve("openai-java-1.2.3.module") + Files.writeString(module, "{}") + assertFails { bundle(staging) } + Files.delete(module) + Files.createSymbolicLink(base.resolve("link"), temporary.resolve("manifest")) + assertFails { bundle(staging) } + } +} From c80624347f72e521d6870a8ac2f43b0253336bde Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 9 Oct 2026 10:55:53 -0700 Subject: [PATCH 3/7] test: validate conditional Maven credential mappings --- .github/workflows/ci.yml | 6 ++- .../gradle/GradleCacheTrustPolicyTest.kt | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcdec26d5..abaf6c72e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,8 +65,12 @@ jobs: secret="${mapping#*:}" if ! awk -v property="$property" -v secret="$secret" ' + BEGIN { empty = sprintf("%c%c", 39, 39) } $1 == property ":" { - if ($2 == ("$" "{{") && $3 == "secrets." secret && $4 == "}}" && NF == 4) { + if ($2 == ("$" "{{") && $3 == "vars.MAVEN_CENTRAL_AUTH_PROXY_URL" && + $4 == "==" && $5 == empty && $6 == "&&" && + $7 == "secrets." secret && $8 == "||" && $9 == empty && + $10 == "}}" && NF == 10) { valid++ } else { invalid = 1 diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index 0ad23743a..0287cb9db 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt @@ -368,6 +368,43 @@ class GradleCacheTrustPolicyTest { } } + @Test + fun `publishing credential lint rejects unconditional or inverted secret mappings`() { + val workflow = Path.of("../.github/workflows/create-releases.yml").readText() + val lint = parseWorkflow(Path.of("../.github/workflows/ci.yml").readText()).job("lint") + val guard = + requireNotNull( + lint.steps + .single { + it.run?.contains("workflow=.github/workflows/create-releases.yml") == true + } + .run + ) + val target = temporaryDirectory.resolve(".github/workflows/create-releases.yml") + Files.createDirectories(target.parent) + val conditional = + "vars.MAVEN_CENTRAL_AUTH_PROXY_URL == '' && secrets.OPENAI_SONATYPE_USERNAME || ''" + listOf( + workflow to 0, + workflow.replace(conditional, "secrets.OPENAI_SONATYPE_USERNAME") to 1, + workflow.replace( + "vars.MAVEN_CENTRAL_AUTH_PROXY_URL == '' &&", + "vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' &&", + ) to 1, + workflow.replace("secrets.OPENAI_SONATYPE_PASSWORD", "secrets.WRONG_SECRET") to 1, + ) + .forEach { (candidate, expected) -> + target.writeText(candidate) + val process = + ProcessBuilder("bash", "-c", guard) + .directory(temporaryDirectory.toFile()) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + assertEquals(expected, process.waitFor(), output) + } + } + @Test fun `publishing shares signing setup without giving vendor credentials to proxy mode`() { val workflow = Path.of("../.github/workflows/create-releases.yml").readText() From 8c3b1b8b8f157d7229c8ae64865cbba0f712602f Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 9 Oct 2026 11:44:35 -0700 Subject: [PATCH 4/7] build: use Microsoft tooling for publishing identity --- .github/workflows/create-releases.yml | 30 +++++- CONTRIBUTING.md | 7 ++ .../gradle/MavenCentralProxyPublisher.kt | 65 ++++--------- .../gradle/GradleCacheTrustPolicyTest.kt | 91 +++++++++++++++++++ .../gradle/MavenCentralProxyPublisherTest.kt | 63 ++++--------- 5 files changed, 160 insertions(+), 96 deletions(-) diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index 8ee126244..e31bf3232 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -260,6 +260,9 @@ jobs: runs-on: ${{ vars.SDK_GHA_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 90 environment: publish + env: + AZURE_CONFIG_DIR: ${{ runner.temp }}/maven-azure-${{ github.run_id }}-${{ github.run_attempt }} + AZURE_LOGIN_POST_CLEANUP: 'true' steps: - name: Check out workflow scripts @@ -487,16 +490,37 @@ jobs: --no-parallel \ --no-configuration-cache + - name: Prepare isolated Azure login cache + if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' + run: mkdir -m 700 "$AZURE_CONFIG_DIR" + + - name: Authenticate publishing identity with Azure + if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ vars.MAVEN_CENTRAL_AZURE_CLIENT_ID }} + tenant-id: ${{ vars.MAVEN_CENTRAL_AZURE_TENANT_ID }} + allow-no-subscriptions: true + - name: Publish through Maven Central auth proxy if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' env: JAVA_TOOL_OPTIONS: -Djdk.httpclient.redirects.retrylimit=1 MAVEN_CENTRAL_AUTH_PROXY_URL: ${{ vars.MAVEN_CENTRAL_AUTH_PROXY_URL }} - MAVEN_CENTRAL_AZURE_TENANT_ID: ${{ vars.MAVEN_CENTRAL_AZURE_TENANT_ID }} - MAVEN_CENTRAL_AZURE_CLIENT_ID: ${{ vars.MAVEN_CENTRAL_AZURE_CLIENT_ID }} MAVEN_CENTRAL_AZURE_RESOURCE: ${{ vars.MAVEN_CENTRAL_AZURE_RESOURCE }} RELEASE_TAG: ${{ needs.release.outputs.release_tag }} - run: ./gradlew publishViaAuthProxy -PstageForAuthProxy=true --no-daemon --no-configuration-cache + run: | + set +x + set -euo pipefail + : "${MAVEN_CENTRAL_AZURE_RESOURCE:?Set the proxy resource identifier}" + response="$(az account get-access-token --resource "$MAVEN_CENTRAL_AZURE_RESOURCE" --output json --only-show-errors)" + token="$(jq -er '.accessToken | strings | select(length > 0)' <<< "$response")" + printf '::add-mask::%s\n' "$token" + export MAVEN_CENTRAL_AUTH_PROXY_TOKEN="$token" + expiry="$(jq -er '.expires_on | tonumber' <<< "$response")" + export MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT="$expiry" + unset response token expiry + ./gradlew publishViaAuthProxy -PstageForAuthProxy=true --no-daemon --no-configuration-cache - name: Verify attested Maven artifacts run: sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cafc48fbe..98867aa93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -335,6 +335,13 @@ the protected `publish` environment: - `MAVEN_CENTRAL_AZURE_CLIENT_ID`: the dedicated federated application UUID. - `MAVEN_CENTRAL_AZURE_RESOURCE`: the proxy's Entra resource identifier, without `/.default`. +The SHA-pinned Microsoft `azure/login` action and Azure CLI obtain the short-lived proxy token. +Proxy-mode runners need Azure CLI 2.54+ (for `expires_on`) and `jq`; the standard Ubuntu runner +provides both. Azure login uses a private per-run cache and its post-job credential cleanup. +The token is masked and passed only through the upload process environment, never a command argument +or workflow output. The uploader requires at least 20 minutes of token lifetime before upload and +stops if it expires during polling; it does not reupload or silently fall back. + The application must trust GitHub OIDC for this repository's `publish` environment. Preserve its `main`-only deployment restriction: the default environment subject does not also constrain the branch or workflow. Review stronger workflow-specific federation with the proxy owner. The proxy diff --git a/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt index 5e621f139..43ab810ae 100644 --- a/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt +++ b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt @@ -2,8 +2,6 @@ package com.openai.gradle import groovy.json.JsonSlurper import java.net.URI -import java.net.URLDecoder -import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpRequest.BodyPublishers @@ -12,6 +10,7 @@ import java.nio.file.Files import java.nio.file.Path import java.security.MessageDigest import java.time.Duration +import java.time.Instant import java.util.UUID import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -24,52 +23,21 @@ class MavenCentralProxyPublisher( private val receipt: (String) -> Unit = ::record, private val clock: () -> Long = { System.nanoTime() / 1_000_000_000 }, private val sleep: (Long) -> Unit = Thread::sleep, + private val epochSeconds: () -> Long = { Instant.now().epochSecond }, ) { - private var accessToken = "" - private var expiresAt = 0L - - internal fun token(): String { - if (expiresAt > clock() + 60) return accessToken - val tenant = UUID.fromString(environment.getValue("MAVEN_CENTRAL_AZURE_TENANT_ID")) - val client = UUID.fromString(environment.getValue("MAVEN_CENTRAL_AZURE_CLIENT_ID")) - val resource = environment.getValue("MAVEN_CENTRAL_AZURE_RESOURCE") - check(resource.isNotBlank() && !resource.endsWith("/.default")) - val endpoint = httpsUri(environment.getValue("ACTIONS_ID_TOKEN_REQUEST_URL")) - check(endpoint.host.endsWith(".actions.githubusercontent.com")) - val query = - endpoint.rawQuery.orEmpty().split('&').filter { - it.isNotEmpty() && URLDecoder.decode(it.substringBefore('='), "UTF-8") != "audience" - } + "audience=${encode("api://AzureADTokenExchange") }" - val oidcUri = URI(endpoint.toString().substringBefore('?') + "?" + query.joinToString("&")) - val assertion = - json(send(oidcUri, environment.getValue("ACTIONS_ID_TOKEN_REQUEST_TOKEN"), null, null))[ - "value"] - as String - val form = - mapOf( - "client_id" to client.toString(), - "scope" to resource.trimEnd('/') + "/.default", - "grant_type" to "client_credentials", - "client_assertion_type" to - "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - "client_assertion" to assertion, - ) - .entries - .joinToString("&") { "${encode(it.key)}=${encode(it.value)}" } - val response = - json( - send( - URI("https://login.microsoftonline.com/$tenant/oauth2/v2.0/token"), - null, - BodyPublishers.ofString(form), - "application/x-www-form-urlencoded", - ) + internal fun token(minimumLifetimeSeconds: Long = 60): String { + val value = environment.getValue("MAVEN_CENTRAL_AUTH_PROXY_TOKEN") + val expiry = environment.getValue("MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT").toLong() + if ( + value.isBlank() || + value.any(Char::isWhitespace) || + expiry <= epochSeconds() + minimumLifetimeSeconds + ) { + throw GradleException( + "Proxy token is missing or expires too soon. Obtain a fresh Azure token; reconcile any recorded deployment before retrying." ) - accessToken = response["access_token"] as String - val lifetime = response["expires_in"].toString().toLong() - check(accessToken.isNotEmpty() && lifetime > 0) - expiresAt = clock() + lifetime - return accessToken + } + return value } internal fun publish(archive: Path) { @@ -85,7 +53,8 @@ class MavenCentralProxyPublisher( BodyPublishers.ofFile(archive), BodyPublishers.ofString("\r\n--$boundary--\r\n"), ) - val credential = token() + // Cover the 15-minute poll deadline plus upload and request timeouts. + val credential = token(20 * 60) receipt( "Submitting Maven bundle. If no deployment ID follows, inspect Central Portal before retrying." ) @@ -147,8 +116,6 @@ class MavenCentralProxyPublisher( .connectTimeout(Duration.ofSeconds(30)) .build() - private fun encode(value: String) = URLEncoder.encode(value, "UTF-8") - private fun json(value: String) = JsonSlurper().parseText(value) as Map<*, *> internal fun httpsUri(value: String): URI = diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index 0287cb9db..7666d9849 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt @@ -405,6 +405,94 @@ class GradleCacheTrustPolicyTest { } } + @Test + fun `publishing uses pinned Microsoft OIDC login with isolated cache and environment token`() { + val job = + parseWorkflow(Path.of("../.github/workflows/create-releases.yml").readText()) + .job("publish") + val login = job.steps.single { it.action?.repository == "azure/login" } + assertEquals( + "azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906", + login.action!!.reference, + ) + assertEquals( + mapOf( + "client-id" to "\${{ vars.MAVEN_CENTRAL_AZURE_CLIENT_ID }}", + "tenant-id" to "\${{ vars.MAVEN_CENTRAL_AZURE_TENANT_ID }}", + "allow-no-subscriptions" to "true", + ), + login.action.inputs, + ) + assertEquals("vars.MAVEN_CENTRAL_AUTH_PROXY_URL != ''", login.condition) + assertEquals( + "\${{ runner.temp }}/maven-azure-\${{ github.run_id }}-\${{ github.run_attempt }}", + job.environment["AZURE_CONFIG_DIR"], + ) + val prepare = job.steps.single { it.run == "mkdir -m 700 \"\$AZURE_CONFIG_DIR\"" } + val upload = job.steps.single { it.run?.contains("publishViaAuthProxy") == true } + assertEquals(login.condition, prepare.condition) + assertEquals(login.condition, upload.condition) + assertTrue(job.steps.indexOf(prepare) < job.steps.indexOf(login)) + assertTrue(job.steps.indexOf(login) < job.steps.indexOf(upload)) + assertTrue( + login.environment.isEmpty(), + "Keep the action's default post-job cleanup enabled", + ) + assertFalse(requireNotNull(upload.run).contains("GITHUB_ENV")) + assertFalse(requireNotNull(upload.run).contains("GITHUB_OUTPUT")) + } + + @Test + fun `publishing passes the Azure token only in the child environment and fails on CLI errors`() { + val job = + parseWorkflow(Path.of("../.github/workflows/create-releases.yml").readText()) + .job("publish") + val script = + requireNotNull(job.steps.single { it.run?.contains("publishViaAuthProxy") == true }.run) + val bin = Files.createDirectory(temporaryDirectory.resolve("bin")) + val az = bin.resolve("az") + az.writeText( + "#!/usr/bin/env bash\n[[ \"\$1 \$2\" == 'account get-access-token' ]] || exit 91\nprintf '%s' '{\"accessToken\":\"fake-azure-token\",\"expires_on\":2000000000}'\n" + ) + assertTrue(az.toFile().setExecutable(true)) + val wrapper = temporaryDirectory.resolve("gradlew") + wrapper.writeText( + "#!/usr/bin/env bash\nprintf '%s\\n' \"\$MAVEN_CENTRAL_AUTH_PROXY_TOKEN\" \"\$MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT\" \"\$@\" > child-env\n" + ) + assertTrue(wrapper.toFile().setExecutable(true)) + fun execute(): Pair { + val builder = + ProcessBuilder("bash", "-c", script) + .directory(temporaryDirectory.toFile()) + .redirectErrorStream(true) + builder.environment()["PATH"] = "$bin:${System.getenv("PATH")}" + builder.environment()["MAVEN_CENTRAL_AZURE_RESOURCE"] = "api://fake-resource" + val process = builder.start() + val output = process.inputStream.bufferedReader().use { it.readText() } + return process.waitFor() to output + } + val (status, output) = execute() + assertEquals(0, status, output) + assertEquals("::add-mask::fake-azure-token", output.trim()) + val received = temporaryDirectory.resolve("child-env").readText().lines() + assertEquals( + listOf( + "fake-azure-token", + "2000000000", + "publishViaAuthProxy", + "-PstageForAuthProxy=true", + "--no-daemon", + "--no-configuration-cache", + "", + ), + received, + ) + Files.delete(temporaryDirectory.resolve("child-env")) + az.writeText("#!/usr/bin/env bash\nexit 42\n") + assertEquals(42, execute().first) + assertFalse(Files.exists(temporaryDirectory.resolve("child-env"))) + } + @Test fun `publishing shares signing setup without giving vendor credentials to proxy mode`() { val workflow = Path.of("../.github/workflows/create-releases.yml").readText() @@ -1125,6 +1213,7 @@ class GradleCacheTrustPolicyTest { "gradle/actions/setup-gradle" to setOf("cache-disabled", "cache-provider"), "graalvm/setup-graalvm" to setOf("distribution", "java-version"), "actions/attest" to setOf("subject-path", "predicate-type", "predicate-path"), + "azure/login" to setOf("client-id", "tenant-id", "allow-no-subscriptions"), ) publishJob.actions.forEach { action -> @@ -1440,6 +1529,7 @@ class GradleCacheTrustPolicyTest { jobName, job["outputs"].workflowScalars("job outputs"), job["permissions"].workflowScalars("job permissions"), + job["env"].workflowScalars("job environment"), steps, ) }, @@ -1494,6 +1584,7 @@ class GradleCacheTrustPolicyTest { val name: String, val outputs: Map, val permissions: Map, + val environment: Map, val steps: List, ) { val actions: List diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt index 53a35969b..d16804221 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt @@ -24,12 +24,8 @@ class MavenCentralProxyPublisherTest { private val environment = mapOf( "MAVEN_CENTRAL_AUTH_PROXY_URL" to "https://proxy.example.invalid", - "MAVEN_CENTRAL_AZURE_TENANT_ID" to deployment, - "MAVEN_CENTRAL_AZURE_CLIENT_ID" to deployment, - "MAVEN_CENTRAL_AZURE_RESOURCE" to "api://fake-resource", - "ACTIONS_ID_TOKEN_REQUEST_URL" to - "https://pipelines.actions.githubusercontent.com/token?audience=old", - "ACTIONS_ID_TOKEN_REQUEST_TOKEN" to "fake-github-token", + "MAVEN_CENTRAL_AUTH_PROXY_TOKEN" to "fake-entra", + "MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT" to Long.MAX_VALUE.toString(), ) private fun body(publisher: HttpRequest.BodyPublisher): String { @@ -57,15 +53,8 @@ class MavenCentralProxyPublisherTest { MavenCentralProxyPublisher( environment, { uri, token, data, _ -> - when (uri.host) { - "pipelines.actions.githubusercontent.com" -> """{"value":"fake-assertion"}""" - "login.microsoftonline.com" -> - """{"access_token":"fake-entra","expires_in":3600}""" - else -> { - assertEquals("fake-entra", token) - central(uri, data) - } - } + assertEquals("fake-entra", token) + central(uri, data) }, receipts::add, clock, @@ -162,51 +151,37 @@ class MavenCentralProxyPublisherTest { } @Test - fun `OIDC exchange replaces audience and refreshes expiring credentials`() { - var now = 100L - var exchanges = 0 + fun `Azure token must have enough lifetime and no header whitespace`() { val publisher = MavenCentralProxyPublisher( - environment, - { uri, token, data, _ -> - if (uri.host.endsWith(".actions.githubusercontent.com")) { - assertContains(uri.rawQuery, "audience=api%3A%2F%2FAzureADTokenExchange") - assertFalse(uri.rawQuery.contains("audience=old")) - assertEquals("fake-github-token", token) - """{"value":"fake-assertion"}""" - } else { - exchanges++ - assertEquals(null, token) - val form = body(requireNotNull(data)) - assertContains(form, "client_assertion=fake-assertion") - assertContains(form, "scope=api%3A%2F%2Ffake-resource%2F.default") - """{"access_token":"fake-entra","expires_in":3600}""" - } - }, - {}, - { now }, + environment + ("MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT" to "1100"), + epochSeconds = { 1000 }, ) assertEquals("fake-entra", publisher.token()) - publisher.token() - assertEquals(1, exchanges) - now = 3700 - publisher.token() - assertEquals(2, exchanges) + assertFailsWith { publisher.token(1200) } + for (value in listOf("", "fake\nheader")) { + val invalid = + MavenCentralProxyPublisher( + environment + ("MAVEN_CENTRAL_AUTH_PROXY_TOKEN" to value) + ) + assertFailsWith { invalid.token() } + } } @Test - fun `unexpected OIDC endpoint is rejected before sending credentials`() { + fun `short lived token fails before sending upload`() { var requested = false val publisher = MavenCentralProxyPublisher( - environment + ("ACTIONS_ID_TOKEN_REQUEST_URL" to "https://evil.invalid/token"), + environment + ("MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT" to "1100"), { _, _, _, _ -> requested = true "" }, {}, + epochSeconds = { 1000 }, ) - assertFails { publisher.token() } + assertFailsWith { publisher.publish(archive()) } assertFalse(requested) } From c9f0a8a8cd250ced95c1263bd8c6ce47d1ef5d4b Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 9 Oct 2026 11:49:52 -0700 Subject: [PATCH 5/7] fix: prepare Azure cache in runner step --- .github/workflows/create-releases.yml | 7 +++++-- .../com/openai/gradle/GradleCacheTrustPolicyTest.kt | 10 ++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index e31bf3232..8d81a0df3 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -261,7 +261,6 @@ jobs: timeout-minutes: 90 environment: publish env: - AZURE_CONFIG_DIR: ${{ runner.temp }}/maven-azure-${{ github.run_id }}-${{ github.run_attempt }} AZURE_LOGIN_POST_CLEANUP: 'true' steps: @@ -492,7 +491,11 @@ jobs: - name: Prepare isolated Azure login cache if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' - run: mkdir -m 700 "$AZURE_CONFIG_DIR" + run: | + set -euo pipefail + azure_config_dir="$RUNNER_TEMP/maven-azure-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + mkdir -m 700 "$azure_config_dir" + printf 'AZURE_CONFIG_DIR=%s\n' "$azure_config_dir" >> "$GITHUB_ENV" - name: Authenticate publishing identity with Azure if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index 7666d9849..9619fadf5 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt @@ -424,11 +424,13 @@ class GradleCacheTrustPolicyTest { login.action.inputs, ) assertEquals("vars.MAVEN_CENTRAL_AUTH_PROXY_URL != ''", login.condition) - assertEquals( - "\${{ runner.temp }}/maven-azure-\${{ github.run_id }}-\${{ github.run_attempt }}", - job.environment["AZURE_CONFIG_DIR"], + assertFalse(job.environment.values.any { "runner." in it }) + val prepare = job.steps.single { it.run?.contains("AZURE_CONFIG_DIR=%s") == true } + assertContains(requireNotNull(prepare.run), "mkdir -m 700 \"\$azure_config_dir\"") + assertContains( + requireNotNull(prepare.run), + "\$RUNNER_TEMP/maven-azure-\$GITHUB_RUN_ID-\$GITHUB_RUN_ATTEMPT", ) - val prepare = job.steps.single { it.run == "mkdir -m 700 \"\$AZURE_CONFIG_DIR\"" } val upload = job.steps.single { it.run?.contains("publishViaAuthProxy") == true } assertEquals(login.condition, prepare.condition) assertEquals(login.condition, upload.condition) From 6d2ae85f7334b788fb5840f9f54c2d76dbadb049 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 9 Oct 2026 12:15:20 -0700 Subject: [PATCH 6/7] revert: keep publishing authentication in repo --- .github/workflows/create-releases.yml | 33 +------ CONTRIBUTING.md | 7 -- .../gradle/MavenCentralProxyPublisher.kt | 65 +++++++++---- .../gradle/GradleCacheTrustPolicyTest.kt | 93 ------------------- .../gradle/MavenCentralProxyPublisherTest.kt | 63 +++++++++---- 5 files changed, 96 insertions(+), 165 deletions(-) diff --git a/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index 8d81a0df3..8ee126244 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -260,8 +260,6 @@ jobs: runs-on: ${{ vars.SDK_GHA_RUNNER || 'ubuntu-24.04' }} timeout-minutes: 90 environment: publish - env: - AZURE_LOGIN_POST_CLEANUP: 'true' steps: - name: Check out workflow scripts @@ -489,41 +487,16 @@ jobs: --no-parallel \ --no-configuration-cache - - name: Prepare isolated Azure login cache - if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' - run: | - set -euo pipefail - azure_config_dir="$RUNNER_TEMP/maven-azure-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" - mkdir -m 700 "$azure_config_dir" - printf 'AZURE_CONFIG_DIR=%s\n' "$azure_config_dir" >> "$GITHUB_ENV" - - - name: Authenticate publishing identity with Azure - if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 - with: - client-id: ${{ vars.MAVEN_CENTRAL_AZURE_CLIENT_ID }} - tenant-id: ${{ vars.MAVEN_CENTRAL_AZURE_TENANT_ID }} - allow-no-subscriptions: true - - name: Publish through Maven Central auth proxy if: vars.MAVEN_CENTRAL_AUTH_PROXY_URL != '' env: JAVA_TOOL_OPTIONS: -Djdk.httpclient.redirects.retrylimit=1 MAVEN_CENTRAL_AUTH_PROXY_URL: ${{ vars.MAVEN_CENTRAL_AUTH_PROXY_URL }} + MAVEN_CENTRAL_AZURE_TENANT_ID: ${{ vars.MAVEN_CENTRAL_AZURE_TENANT_ID }} + MAVEN_CENTRAL_AZURE_CLIENT_ID: ${{ vars.MAVEN_CENTRAL_AZURE_CLIENT_ID }} MAVEN_CENTRAL_AZURE_RESOURCE: ${{ vars.MAVEN_CENTRAL_AZURE_RESOURCE }} RELEASE_TAG: ${{ needs.release.outputs.release_tag }} - run: | - set +x - set -euo pipefail - : "${MAVEN_CENTRAL_AZURE_RESOURCE:?Set the proxy resource identifier}" - response="$(az account get-access-token --resource "$MAVEN_CENTRAL_AZURE_RESOURCE" --output json --only-show-errors)" - token="$(jq -er '.accessToken | strings | select(length > 0)' <<< "$response")" - printf '::add-mask::%s\n' "$token" - export MAVEN_CENTRAL_AUTH_PROXY_TOKEN="$token" - expiry="$(jq -er '.expires_on | tonumber' <<< "$response")" - export MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT="$expiry" - unset response token expiry - ./gradlew publishViaAuthProxy -PstageForAuthProxy=true --no-daemon --no-configuration-cache + run: ./gradlew publishViaAuthProxy -PstageForAuthProxy=true --no-daemon --no-configuration-cache - name: Verify attested Maven artifacts run: sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98867aa93..cafc48fbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -335,13 +335,6 @@ the protected `publish` environment: - `MAVEN_CENTRAL_AZURE_CLIENT_ID`: the dedicated federated application UUID. - `MAVEN_CENTRAL_AZURE_RESOURCE`: the proxy's Entra resource identifier, without `/.default`. -The SHA-pinned Microsoft `azure/login` action and Azure CLI obtain the short-lived proxy token. -Proxy-mode runners need Azure CLI 2.54+ (for `expires_on`) and `jq`; the standard Ubuntu runner -provides both. Azure login uses a private per-run cache and its post-job credential cleanup. -The token is masked and passed only through the upload process environment, never a command argument -or workflow output. The uploader requires at least 20 minutes of token lifetime before upload and -stops if it expires during polling; it does not reupload or silently fall back. - The application must trust GitHub OIDC for this repository's `publish` environment. Preserve its `main`-only deployment restriction: the default environment subject does not also constrain the branch or workflow. Review stronger workflow-specific federation with the proxy owner. The proxy diff --git a/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt index 43ab810ae..5e621f139 100644 --- a/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt +++ b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt @@ -2,6 +2,8 @@ package com.openai.gradle import groovy.json.JsonSlurper import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpRequest.BodyPublishers @@ -10,7 +12,6 @@ import java.nio.file.Files import java.nio.file.Path import java.security.MessageDigest import java.time.Duration -import java.time.Instant import java.util.UUID import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -23,21 +24,52 @@ class MavenCentralProxyPublisher( private val receipt: (String) -> Unit = ::record, private val clock: () -> Long = { System.nanoTime() / 1_000_000_000 }, private val sleep: (Long) -> Unit = Thread::sleep, - private val epochSeconds: () -> Long = { Instant.now().epochSecond }, ) { - internal fun token(minimumLifetimeSeconds: Long = 60): String { - val value = environment.getValue("MAVEN_CENTRAL_AUTH_PROXY_TOKEN") - val expiry = environment.getValue("MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT").toLong() - if ( - value.isBlank() || - value.any(Char::isWhitespace) || - expiry <= epochSeconds() + minimumLifetimeSeconds - ) { - throw GradleException( - "Proxy token is missing or expires too soon. Obtain a fresh Azure token; reconcile any recorded deployment before retrying." + private var accessToken = "" + private var expiresAt = 0L + + internal fun token(): String { + if (expiresAt > clock() + 60) return accessToken + val tenant = UUID.fromString(environment.getValue("MAVEN_CENTRAL_AZURE_TENANT_ID")) + val client = UUID.fromString(environment.getValue("MAVEN_CENTRAL_AZURE_CLIENT_ID")) + val resource = environment.getValue("MAVEN_CENTRAL_AZURE_RESOURCE") + check(resource.isNotBlank() && !resource.endsWith("/.default")) + val endpoint = httpsUri(environment.getValue("ACTIONS_ID_TOKEN_REQUEST_URL")) + check(endpoint.host.endsWith(".actions.githubusercontent.com")) + val query = + endpoint.rawQuery.orEmpty().split('&').filter { + it.isNotEmpty() && URLDecoder.decode(it.substringBefore('='), "UTF-8") != "audience" + } + "audience=${encode("api://AzureADTokenExchange") }" + val oidcUri = URI(endpoint.toString().substringBefore('?') + "?" + query.joinToString("&")) + val assertion = + json(send(oidcUri, environment.getValue("ACTIONS_ID_TOKEN_REQUEST_TOKEN"), null, null))[ + "value"] + as String + val form = + mapOf( + "client_id" to client.toString(), + "scope" to resource.trimEnd('/') + "/.default", + "grant_type" to "client_credentials", + "client_assertion_type" to + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion" to assertion, + ) + .entries + .joinToString("&") { "${encode(it.key)}=${encode(it.value)}" } + val response = + json( + send( + URI("https://login.microsoftonline.com/$tenant/oauth2/v2.0/token"), + null, + BodyPublishers.ofString(form), + "application/x-www-form-urlencoded", + ) ) - } - return value + accessToken = response["access_token"] as String + val lifetime = response["expires_in"].toString().toLong() + check(accessToken.isNotEmpty() && lifetime > 0) + expiresAt = clock() + lifetime + return accessToken } internal fun publish(archive: Path) { @@ -53,8 +85,7 @@ class MavenCentralProxyPublisher( BodyPublishers.ofFile(archive), BodyPublishers.ofString("\r\n--$boundary--\r\n"), ) - // Cover the 15-minute poll deadline plus upload and request timeouts. - val credential = token(20 * 60) + val credential = token() receipt( "Submitting Maven bundle. If no deployment ID follows, inspect Central Portal before retrying." ) @@ -116,6 +147,8 @@ class MavenCentralProxyPublisher( .connectTimeout(Duration.ofSeconds(30)) .build() + private fun encode(value: String) = URLEncoder.encode(value, "UTF-8") + private fun json(value: String) = JsonSlurper().parseText(value) as Map<*, *> internal fun httpsUri(value: String): URI = diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index 9619fadf5..0287cb9db 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt @@ -405,96 +405,6 @@ class GradleCacheTrustPolicyTest { } } - @Test - fun `publishing uses pinned Microsoft OIDC login with isolated cache and environment token`() { - val job = - parseWorkflow(Path.of("../.github/workflows/create-releases.yml").readText()) - .job("publish") - val login = job.steps.single { it.action?.repository == "azure/login" } - assertEquals( - "azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906", - login.action!!.reference, - ) - assertEquals( - mapOf( - "client-id" to "\${{ vars.MAVEN_CENTRAL_AZURE_CLIENT_ID }}", - "tenant-id" to "\${{ vars.MAVEN_CENTRAL_AZURE_TENANT_ID }}", - "allow-no-subscriptions" to "true", - ), - login.action.inputs, - ) - assertEquals("vars.MAVEN_CENTRAL_AUTH_PROXY_URL != ''", login.condition) - assertFalse(job.environment.values.any { "runner." in it }) - val prepare = job.steps.single { it.run?.contains("AZURE_CONFIG_DIR=%s") == true } - assertContains(requireNotNull(prepare.run), "mkdir -m 700 \"\$azure_config_dir\"") - assertContains( - requireNotNull(prepare.run), - "\$RUNNER_TEMP/maven-azure-\$GITHUB_RUN_ID-\$GITHUB_RUN_ATTEMPT", - ) - val upload = job.steps.single { it.run?.contains("publishViaAuthProxy") == true } - assertEquals(login.condition, prepare.condition) - assertEquals(login.condition, upload.condition) - assertTrue(job.steps.indexOf(prepare) < job.steps.indexOf(login)) - assertTrue(job.steps.indexOf(login) < job.steps.indexOf(upload)) - assertTrue( - login.environment.isEmpty(), - "Keep the action's default post-job cleanup enabled", - ) - assertFalse(requireNotNull(upload.run).contains("GITHUB_ENV")) - assertFalse(requireNotNull(upload.run).contains("GITHUB_OUTPUT")) - } - - @Test - fun `publishing passes the Azure token only in the child environment and fails on CLI errors`() { - val job = - parseWorkflow(Path.of("../.github/workflows/create-releases.yml").readText()) - .job("publish") - val script = - requireNotNull(job.steps.single { it.run?.contains("publishViaAuthProxy") == true }.run) - val bin = Files.createDirectory(temporaryDirectory.resolve("bin")) - val az = bin.resolve("az") - az.writeText( - "#!/usr/bin/env bash\n[[ \"\$1 \$2\" == 'account get-access-token' ]] || exit 91\nprintf '%s' '{\"accessToken\":\"fake-azure-token\",\"expires_on\":2000000000}'\n" - ) - assertTrue(az.toFile().setExecutable(true)) - val wrapper = temporaryDirectory.resolve("gradlew") - wrapper.writeText( - "#!/usr/bin/env bash\nprintf '%s\\n' \"\$MAVEN_CENTRAL_AUTH_PROXY_TOKEN\" \"\$MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT\" \"\$@\" > child-env\n" - ) - assertTrue(wrapper.toFile().setExecutable(true)) - fun execute(): Pair { - val builder = - ProcessBuilder("bash", "-c", script) - .directory(temporaryDirectory.toFile()) - .redirectErrorStream(true) - builder.environment()["PATH"] = "$bin:${System.getenv("PATH")}" - builder.environment()["MAVEN_CENTRAL_AZURE_RESOURCE"] = "api://fake-resource" - val process = builder.start() - val output = process.inputStream.bufferedReader().use { it.readText() } - return process.waitFor() to output - } - val (status, output) = execute() - assertEquals(0, status, output) - assertEquals("::add-mask::fake-azure-token", output.trim()) - val received = temporaryDirectory.resolve("child-env").readText().lines() - assertEquals( - listOf( - "fake-azure-token", - "2000000000", - "publishViaAuthProxy", - "-PstageForAuthProxy=true", - "--no-daemon", - "--no-configuration-cache", - "", - ), - received, - ) - Files.delete(temporaryDirectory.resolve("child-env")) - az.writeText("#!/usr/bin/env bash\nexit 42\n") - assertEquals(42, execute().first) - assertFalse(Files.exists(temporaryDirectory.resolve("child-env"))) - } - @Test fun `publishing shares signing setup without giving vendor credentials to proxy mode`() { val workflow = Path.of("../.github/workflows/create-releases.yml").readText() @@ -1215,7 +1125,6 @@ class GradleCacheTrustPolicyTest { "gradle/actions/setup-gradle" to setOf("cache-disabled", "cache-provider"), "graalvm/setup-graalvm" to setOf("distribution", "java-version"), "actions/attest" to setOf("subject-path", "predicate-type", "predicate-path"), - "azure/login" to setOf("client-id", "tenant-id", "allow-no-subscriptions"), ) publishJob.actions.forEach { action -> @@ -1531,7 +1440,6 @@ class GradleCacheTrustPolicyTest { jobName, job["outputs"].workflowScalars("job outputs"), job["permissions"].workflowScalars("job permissions"), - job["env"].workflowScalars("job environment"), steps, ) }, @@ -1586,7 +1494,6 @@ class GradleCacheTrustPolicyTest { val name: String, val outputs: Map, val permissions: Map, - val environment: Map, val steps: List, ) { val actions: List diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt index d16804221..53a35969b 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt @@ -24,8 +24,12 @@ class MavenCentralProxyPublisherTest { private val environment = mapOf( "MAVEN_CENTRAL_AUTH_PROXY_URL" to "https://proxy.example.invalid", - "MAVEN_CENTRAL_AUTH_PROXY_TOKEN" to "fake-entra", - "MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT" to Long.MAX_VALUE.toString(), + "MAVEN_CENTRAL_AZURE_TENANT_ID" to deployment, + "MAVEN_CENTRAL_AZURE_CLIENT_ID" to deployment, + "MAVEN_CENTRAL_AZURE_RESOURCE" to "api://fake-resource", + "ACTIONS_ID_TOKEN_REQUEST_URL" to + "https://pipelines.actions.githubusercontent.com/token?audience=old", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN" to "fake-github-token", ) private fun body(publisher: HttpRequest.BodyPublisher): String { @@ -53,8 +57,15 @@ class MavenCentralProxyPublisherTest { MavenCentralProxyPublisher( environment, { uri, token, data, _ -> - assertEquals("fake-entra", token) - central(uri, data) + when (uri.host) { + "pipelines.actions.githubusercontent.com" -> """{"value":"fake-assertion"}""" + "login.microsoftonline.com" -> + """{"access_token":"fake-entra","expires_in":3600}""" + else -> { + assertEquals("fake-entra", token) + central(uri, data) + } + } }, receipts::add, clock, @@ -151,37 +162,51 @@ class MavenCentralProxyPublisherTest { } @Test - fun `Azure token must have enough lifetime and no header whitespace`() { + fun `OIDC exchange replaces audience and refreshes expiring credentials`() { + var now = 100L + var exchanges = 0 val publisher = MavenCentralProxyPublisher( - environment + ("MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT" to "1100"), - epochSeconds = { 1000 }, + environment, + { uri, token, data, _ -> + if (uri.host.endsWith(".actions.githubusercontent.com")) { + assertContains(uri.rawQuery, "audience=api%3A%2F%2FAzureADTokenExchange") + assertFalse(uri.rawQuery.contains("audience=old")) + assertEquals("fake-github-token", token) + """{"value":"fake-assertion"}""" + } else { + exchanges++ + assertEquals(null, token) + val form = body(requireNotNull(data)) + assertContains(form, "client_assertion=fake-assertion") + assertContains(form, "scope=api%3A%2F%2Ffake-resource%2F.default") + """{"access_token":"fake-entra","expires_in":3600}""" + } + }, + {}, + { now }, ) assertEquals("fake-entra", publisher.token()) - assertFailsWith { publisher.token(1200) } - for (value in listOf("", "fake\nheader")) { - val invalid = - MavenCentralProxyPublisher( - environment + ("MAVEN_CENTRAL_AUTH_PROXY_TOKEN" to value) - ) - assertFailsWith { invalid.token() } - } + publisher.token() + assertEquals(1, exchanges) + now = 3700 + publisher.token() + assertEquals(2, exchanges) } @Test - fun `short lived token fails before sending upload`() { + fun `unexpected OIDC endpoint is rejected before sending credentials`() { var requested = false val publisher = MavenCentralProxyPublisher( - environment + ("MAVEN_CENTRAL_AUTH_PROXY_TOKEN_EXPIRES_AT" to "1100"), + environment + ("ACTIONS_ID_TOKEN_REQUEST_URL" to "https://evil.invalid/token"), { _, _, _, _ -> requested = true "" }, {}, - epochSeconds = { 1000 }, ) - assertFailsWith { publisher.publish(archive()) } + assertFails { publisher.token() } assertFalse(requested) } From ec8b428b0c516ce622e512af893e0dc5b9f34c5e Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 9 Oct 2026 12:39:04 -0700 Subject: [PATCH 7/7] fix: derive proxy inventory from Maven publications --- CONTRIBUTING.md | 2 +- build.gradle.kts | 2 +- .../gradle/MavenCentralProxyPublisher.kt | 92 ++++++++++++++----- .../gradle/MavenCentralProxyPublisherTest.kt | 59 +++++++++++- 4 files changed, 128 insertions(+), 27 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cafc48fbe..b24522884 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -345,7 +345,7 @@ the ZIP body or assume a generic proxy's upload limits/timeouts fit a full SDK r Before enabling the variable, complete an owner-approved, nonpublishing `USER_MANAGED` canary and verify the real bundle size, token exchange, signatures and gateway behavior. The enabled release workflow **does publish automatically after validation**. It stages signed artifacts without vendor -credentials, verifies their signatures and attested JAR hashes, then uploads once and records the +credentials, derives the expected inventory from Gradle Maven publications, verifies signatures and attested JAR hashes, then uploads once and records the bundle digest and deployment ID in the job log and summary. The uploader is the `publishViaAuthProxy` Kotlin Gradle task in `buildSrc`; it uses the build JDK. The workflow sets `JAVA_TOOL_OPTIONS=-Djdk.httpclient.redirects.retrylimit=1` before starting that JVM to disable HTTP-client retries as well as application-level retries. diff --git a/build.gradle.kts b/build.gradle.kts index a44d51d2b..7d086106e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -189,5 +189,5 @@ tasks.named("dokkaJavadocCollector").configure { // Run separately after signed staging so no Sonatype credentials enter this task. tasks.register("publishViaAuthProxy") { notCompatibleWithConfigurationCache("Release credentials are read only during execution") - doLast { MavenCentralProxyPublisher.run(rootDir.toPath()) } + doLast { MavenCentralProxyPublisher.run(rootProject) } } diff --git a/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt index 5e621f139..e309bbf9c 100644 --- a/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt +++ b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt @@ -16,6 +16,9 @@ import java.util.UUID import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication /** Release-only transport. Credentials stay in memory and uploads are never retried. */ class MavenCentralProxyPublisher( @@ -140,6 +143,12 @@ class MavenCentralProxyPublisher( ) } + internal data class Inventory( + val files: Set, + val optionalMetadata: Set, + val attestedJars: Map, + ) + companion object { private val httpClient = HttpClient.newBuilder() @@ -202,11 +211,57 @@ class MavenCentralProxyPublisher( return digest.digest().joinToString("") { "%02x".format(it) } } + // Read the finalized publication model at task execution, not during configuration. + internal fun inventory(projects: Iterable, version: String): Inventory { + check(version.isNotBlank() && !version.endsWith("-SNAPSHOT")) + val files = linkedSetOf() + val metadata = linkedSetOf() + val jars = linkedMapOf() + projects.forEach { project -> + project.extensions + .findByType(PublishingExtension::class.java) + ?.publications + ?.withType(MavenPublication::class.java) + ?.forEach { publication -> + check(publication.version == version) + val prefix = + "${publication.groupId.replace('.', '/')}/${publication.artifactId}/$version/${publication.artifactId}-$version" + check(files.add("$prefix.pom")) + metadata.add("$prefix.module") + publication.artifacts.forEach { artifact -> + val classifier = + artifact.classifier + ?.takeIf { it.isNotEmpty() } + ?.let { "-$it" } + .orEmpty() + val name = "$prefix$classifier.${artifact.extension}" + check(files.add(name)) + if (artifact.extension == "jar" && classifier.isEmpty()) { + jars[name] = + project.rootDir.canonicalFile + .toPath() + .relativize(artifact.file.canonicalFile.toPath()) + .toString() + .replace('\\', '/') + } + } + } + } + check(files.isNotEmpty()) + (files + metadata).forEach { name -> + check( + !Path.of(name).isAbsolute && + name.split('/').none { it.isEmpty() || it == "." || it == ".." } && + '\\' !in name + ) + } + return Inventory(files, metadata, jars) + } + internal fun bundle( staging: Path, output: Path, - version: String, - artifacts: List, + inventory: Inventory, provenance: Path, verifySignature: (Path) -> Boolean = { signature -> ProcessBuilder( @@ -223,11 +278,6 @@ class MavenCentralProxyPublisher( }, ) { check(!Files.isSymbolicLink(staging) && Files.isDirectory(staging)) - check( - version.matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?")) && - !version.endsWith("-SNAPSHOT") - ) - check(artifacts.isNotEmpty() && artifacts.distinct() == artifacts) val digests = linkedMapOf() Files.readAllLines(provenance).forEach { line -> val parts = line.split(" ", limit = 2) @@ -244,20 +294,14 @@ class MavenCentralProxyPublisher( val required = mutableSetOf() val allowed = mutableSetOf() val jars = linkedMapOf() - artifacts.forEach { artifact -> - check(artifact.matches(Regex("openai-java(?:-[a-z0-9-]+)?"))) - val prefix = "com/openai/$artifact/$version/$artifact-$version" - listOf(".jar", ".pom", "-sources.jar", "-javadoc.jar", ".module").forEach { suffix - -> - val name = prefix + suffix - if (suffix != ".module") required.addAll(listOf(name, "$name.asc")) - listOf(name, "$name.asc").forEach { file -> - allowed.add(file) - allowed.addAll(algorithms.keys.map { "$file.$it" }) - } + inventory.files.forEach { required.addAll(listOf(it, "$it.asc")) } + (inventory.files + inventory.optionalMetadata).forEach { name -> + listOf(name, "$name.asc").forEach { file -> + allowed.add(file) + allowed.addAll(algorithms.keys.map { "$file.$it" }) } - jars["$prefix.jar"] = "$artifact/build/libs/$artifact-$version.jar" } + jars.putAll(inventory.attestedJars) check(digests.keys == jars.values.toSet()) val files = linkedMapOf() Files.walk(staging).use { paths -> @@ -305,7 +349,7 @@ class MavenCentralProxyPublisher( } } - fun run(root: Path) { + fun run(root: Project) { val archive = Files.createTempFile("maven-release-", ".zip") try { check(System.getProperty("jdk.httpclient.redirects.retrylimit") == "1") { @@ -313,10 +357,12 @@ class MavenCentralProxyPublisher( } val environment = System.getenv() bundle( - root.resolve("build/auth-proxy-staging"), + root.layout.buildDirectory.dir("auth-proxy-staging").get().asFile.toPath(), archive, - environment.getValue("RELEASE_TAG").removePrefix("v"), - environment.getValue("MAVEN_ARTIFACTS").split(" "), + inventory( + root.allprojects, + environment.getValue("RELEASE_TAG").removePrefix("v"), + ), Path.of(environment.getValue("RUNNER_TEMP"), "maven-artifact-provenance.sha256"), ) record("Maven bundle SHA-256: ${digest(archive)}") diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt index 53a35969b..cb16ea229 100644 --- a/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt +++ b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt @@ -16,6 +16,9 @@ import kotlin.test.assertFails import kotlin.test.assertFailsWith import kotlin.test.assertFalse import org.gradle.api.GradleException +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.testfixtures.ProjectBuilder import org.junit.jupiter.api.io.TempDir class MavenCentralProxyPublisherTest { @@ -231,14 +234,66 @@ class MavenCentralProxyPublisherTest { MavenCentralProxyPublisher.bundle( staging, output, - "1.2.3", - listOf("openai-java"), + MavenCentralProxyPublisher.Inventory( + setOf(".jar", ".pom", "-sources.jar", "-javadoc.jar") + .map { "com/openai/openai-java/1.2.3/openai-java-1.2.3$it" } + .toSet(), + setOf("com/openai/openai-java/1.2.3/openai-java-1.2.3.module"), + mapOf( + "com/openai/openai-java/1.2.3/openai-java-1.2.3.jar" to + "openai-java/build/libs/openai-java-1.2.3.jar" + ), + ), temporary.resolve("manifest"), verify, ) return output } + @Test + fun `inventory follows Gradle coordinates classifiers and artifact source paths`() { + val project = ProjectBuilder.builder().withProjectDir(temporary.toFile()).build() + project.pluginManager.apply("maven-publish") + val publication = + project.extensions + .getByType(PublishingExtension::class.java) + .publications + .create("release", MavenPublication::class.java) + publication.groupId = "example.custom" + publication.artifactId = "renamed-library" + publication.version = "2026.10-rc1" + val jar = temporary.resolve("custom-output/main.jar") + publication.artifact(jar.toFile()) + publication.artifact(temporary.resolve("extra.zip").toFile()) { classifier = "docs-html" } + val inventory = MavenCentralProxyPublisher.inventory(listOf(project), publication.version) + val prefix = "example/custom/renamed-library/2026.10-rc1/renamed-library-2026.10-rc1" + assertEquals(setOf("$prefix.jar", "$prefix.pom", "$prefix-docs-html.zip"), inventory.files) + assertEquals(mapOf("$prefix.jar" to "custom-output/main.jar"), inventory.attestedJars) + assertEquals(setOf("$prefix.module"), inventory.optionalMetadata) + val staging = temporary.resolve("custom-staging") + inventory.files.forEach { name -> + val file = staging.resolve(name) + Files.createDirectories(file.parent) + Files.writeString(file, "synthetic") + Files.writeString(staging.resolve("$name.asc"), "fake-signature") + } + val provenance = temporary.resolve("custom-provenance") + Files.writeString( + provenance, + "${MavenCentralProxyPublisher.digest(staging.resolve("$prefix.jar"))} custom-output/main.jar\n", + ) + val output = temporary.resolve("custom.zip") + MavenCentralProxyPublisher.bundle(staging, output, inventory, provenance) { true } + ZipFile(output.toFile()).use { assertEquals(6, it.size()) } + Files.delete(staging.resolve("$prefix-docs-html.zip")) + assertFails { + MavenCentralProxyPublisher.bundle(staging, output, inventory, provenance) { true } + } + assertFails { MavenCentralProxyPublisher.inventory(listOf(project), "different-version") } + publication.artifactId = "../escape" + assertFails { MavenCentralProxyPublisher.inventory(listOf(project), publication.version) } + } + @Test fun `bundle verifies all signatures and excludes Maven metadata`() { val staging = staged()