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/.github/workflows/create-releases.yml b/.github/workflows/create-releases.yml index ca32e54d2..8ee126244 100644 --- a/.github/workflows/create-releases.yml +++ b/.github/workflows/create-releases.yml @@ -434,18 +434,19 @@ jobs: - name: Publish to Maven Central 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 @@ -470,13 +471,33 @@ jobs: publish_exclusions+=("--exclude-task" ":$artifact:jar") done + 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 + publish_tasks=(publishAllPublicationsToAuthProxyStagingRepository -PstageForAuthProxy=true) + fi + sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" - ./gradlew publishAndReleaseToMavenCentral \ + ./gradlew "${publish_tasks[@]}" \ "${publish_exclusions[@]}" \ --stacktrace \ --no-parallel \ --no-configuration-cache + - 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 + - name: Verify attested Maven artifacts run: sha256sum --check "$RUNNER_TEMP/maven-artifact-provenance.sha256" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e505b3c6..b24522884 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -323,6 +323,41 @@ 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, 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. + +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/build.gradle.kts b/build.gradle.kts index 5132a3bb6..7d086106e 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(rootProject) } +} 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..e309bbf9c --- /dev/null +++ b/buildSrc/src/main/kotlin/com/openai/gradle/MavenCentralProxyPublisher.kt @@ -0,0 +1,382 @@ +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 +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( + 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" + ) + } + + internal data class Inventory( + val files: Set, + val optionalMetadata: Set, + val attestedJars: Map, + ) + + 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) } + } + + // 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, + inventory: Inventory, + 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)) + 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() + 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.putAll(inventory.attestedJars) + 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: Project) { + 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.layout.buildDirectory.dir("auth-proxy-staging").get().asFile.toPath(), + archive, + 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)}") + 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/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()) diff --git a/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt b/buildSrc/src/test/kotlin/com/openai/gradle/GradleCacheTrustPolicyTest.kt index 209b62d16..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,74 @@ 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() + 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 +452,7 @@ class GradleCacheTrustPolicyTest { workflow.replaceFirst(verification, ""), workflow.replaceFirst( verification, - " ./gradlew publishAndReleaseToMavenCentral\n$verification", + " ./gradlew \"\${publish_tasks[@]}\"\n$verification", ), ) .forEach { poisonedWorkflow -> @@ -1175,7 +1243,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 +1336,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..cb16ea229 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/openai/gradle/MavenCentralProxyPublisherTest.kt @@ -0,0 +1,346 @@ +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.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 { + @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, + 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() + 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) } + } +}