diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d27c967..e14b1f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,23 +23,45 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - name: Build and test - run: ./gradlew build + run: ./gradlew build -PrequireShellHarness=true + + - name: Retain build test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-test-results + path: build/test-results/ compatibility-test: runs-on: ubuntu-latest strategy: matrix: gradle-version: ['8.13', '9.4.0'] - name: Compatibility tests (Gradle ${{ matrix.gradle-version }}) + java-version: ['17', '21'] + name: Compatibility tests (Gradle ${{ matrix.gradle-version }}, JDK ${{ matrix.java-version }}) steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 + id: compile_jdk with: distribution: temurin java-version: 17 + - uses: actions/setup-java@v4 + id: runtime_jdk + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + - uses: gradle/actions/setup-gradle@v4 - name: Compatibility tests with Gradle ${{ matrix.gradle-version }} - run: ./gradlew test --tests "se.alipsa.gradle.plugin.release.GradleCompatibilityMatrixTest" -PtestGradleVersion=${{ matrix.gradle-version }} + run: ./gradlew test --tests "*WsClientTimeoutTest" --tests "*CentralUploadApiTest" --tests "*GradleCompatibilityMatrixTest" --tests "*BundleMetadataTest" -PtestGradleVersion=${{ matrix.gradle-version }} -PtestJavaVersion=${{ matrix.java-version }} -PrequireShellHarness=true -Dorg.gradle.java.installations.paths="${{ steps.compile_jdk.outputs.path }},${{ steps.runtime_jdk.outputs.path }}" + + - name: Retain runtime proof reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: runtime-proof-gradle-${{ matrix.gradle-version }}-jdk-${{ matrix.java-version }} + path: build/test-results/test/ diff --git a/README.md b/README.md index 2bebc50..70e47e5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # nexus-release-plugin [![CI](https://github.com/Alipsa/nexus-release-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/Alipsa/nexus-release-plugin/actions/workflows/ci.yml) -[![Java](https://img.shields.io/badge/Java-17+-blue)](https://docs.oracle.com/en/java/javase/17/) -[![Groovy](https://img.shields.io/badge/Groovy-4.0.30-blue)](https://groovy-lang.org/) +[![Java](https://img.shields.io/badge/Java-17%20%26%2021-blue)](https://docs.oracle.com/en/java/javase/17/) +[![Groovy](https://img.shields.io/badge/Groovy-4.0.31-blue)](https://groovy-lang.org/) [![Javadoc](https://javadoc.io/badge2/se.alipsa.nexus-release-plugin/se.alipsa.nexus-release-plugin.gradle.plugin/javadoc.svg)](https://javadoc.io/doc/se.alipsa.nexus-release-plugin/se.alipsa.nexus-release-plugin.gradle.plugin) Automates the release process to Sonatype Central. @@ -90,7 +90,7 @@ nexusReleasePlugin { ``` The `release` task waits for Central Portal to process the uploaded deployment before it checks status. -The defaults are 10 retries, a 10 second interval between checks, and a 10 second initial delay. +The defaults are one initial check plus 10 additional checks, a 10 second interval between checks, and a 10 second initial delay. You can tune those values if Central Portal is taking longer to validate or publish your bundle: ```groovy @@ -98,6 +98,8 @@ nexusReleasePlugin { statusCheckRetries = 30 statusCheckIntervalSeconds = 20 initialStatusCheckDelaySeconds = 20 + httpConnectTimeoutSeconds = 30 + httpRequestTimeoutSeconds = 1800 } ``` ## The latestMavenVersions task @@ -222,3 +224,17 @@ The opt-in `se.alipsa.nexus-release-all` support plugin adds manifest planning, ## License MIT License - see [LICENSE](LICENSE) for details. + +### Release deadlines and bundle contents in 2.3.0 + +`httpConnectTimeoutSeconds` defaults to 30 and `httpRequestTimeoutSeconds` to 1800. Both accept 1–86400 seconds. Set them in `nexusReleasePlugin`; overrides apply to `release`. Uploads use the full request deadline. Deployment status checks and other small calls through `CentralPortalClient` use the shorter of 60 seconds and the configured deadline. The caller stops waiting even if an upload or response body stalls. Cancellation closed stalled connections in the verified runtime matrix; no stronger guarantee is made for arbitrary network implementations. + +`releaseAll` uses the default client timeouts. Its separate `PublicationStatus` and HEAD availability checks do not receive this overall deadline. Runtime testing covers JDK 17 and 21 with Gradle 8.13 and 9.4.0; compilation targets Java 17. Other daemon JDKs are outside the verified support matrix. + +`statusCheckRetries` is the number of additional checks after the first one. Transient errors consume a check and are retried within that budget. Authentication and permission errors fail immediately. Exhaustion reports an unknown or pending deployment that may still publish, with a Portal link. + +Bundles use Maven coordinate names and include all artifacts of the selected publication with a main JAR, including classified ZIPs and generated Gradle Module Metadata. Every primary entry needs a nonempty signature and MD5/SHA-1 checksums. JAR-producing packaging such as `bundle`, `maven-plugin`, and `ejb` remains supported. Publications without a main JAR are rejected by `release`; use `releaseAll` for pom-packaged publications. `bundle` installs only the selected publication to Maven local. Ordinary `publish` and `publishToMavenLocal` builds work without setting `mavenPublication`; cleanup attaches only when a publication is selected. + +The selected publication's module metadata and adjacent signature are cleared before metadata generation, including plain `publishToMavenLocal`, publishing to other repositories, and snapshots. Metadata regenerates or restores from cache. Deleting its signature forces the publication signing task to re-sign every artifact; gpg-agent/pinentry may prompt on every publish even when signing previously stayed up to date. Disable generation with `enabled = false` to omit metadata. Skipping it with `onlyIf` while the POM advertises metadata fails before upload. + +The publishing-script harness runs as part of `check` when Bash is available. Local builds without Bash skip it with a reason; CI requires it with `-PrequireShellHarness=true`. diff --git a/build.gradle b/build.gradle index b80c324..93af942 100644 --- a/build.gradle +++ b/build.gradle @@ -137,10 +137,24 @@ afterEvaluate { } } +abstract class PolicyScriptArg implements CommandLineArgumentProvider { + @InputFile @PathSensitive(PathSensitivity.NONE) + abstract RegularFileProperty getScript() + @Override Iterable asArguments() { + ["-DshellHarnessPolicyScript=${script.get().asFile.absolutePath}".toString()] + } +} + +def selectedTestJavaVersion = providers.gradleProperty('testJavaVersion').getOrElse('17') test { - useJUnitPlatform() + useJUnitPlatform { excludeTags 'non-utf8-charset' } + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(selectedTestJavaVersion) } + systemProperty 'testJavaVersion', selectedTestJavaVersion + def policyArg = objects.newInstance(PolicyScriptArg) + policyArg.script.set(layout.projectDirectory.file('gradle/shell-harness.gradle')) + jvmArgumentProviders.add(policyArg) // Parallelize at JVM-fork level to keep TestKit tests isolated per process. - maxParallelForks = Math.max(1, (Runtime.runtime.availableProcessors().intdiv(2))) + maxParallelForks = 2 // Pass system properties from command line to tests systemProperty 'testGradleVersion', findProperty('testGradleVersion') ?: '' } @@ -178,3 +192,19 @@ tasks.named("dependencyUpdates").configure { } } } + + +tasks.register('charsetTest', Test) { + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(17) } + useJUnitPlatform { includeTags 'non-utf8-charset' } + jvmArgs '-Dfile.encoding=ISO-8859-1' +} +tasks.register('shellHarness', Exec) { + inputs.files('publish.sh', 'src/test/shell/publish-harness.sh', 'gradle/shell-harness.gradle') + args layout.projectDirectory.file('src/test/shell/publish-harness.sh').asFile.absolutePath, + layout.projectDirectory.file('publish.sh').asFile.absolutePath +} +apply from: 'gradle/shell-harness.gradle' +tasks.named('check') { dependsOn 'charsetTest', 'shellHarness' } diff --git a/combined-releases.md b/combined-releases.md index 14b5364..456cda0 100644 --- a/combined-releases.md +++ b/combined-releases.md @@ -71,7 +71,7 @@ An unselected module can retain an already-released BOM pin with a warning. Normal JAR components require main, sources and Javadoc JARs plus POM. POM components require only a POM. An explicitly configured `sourcesAndJavadocRequired: false` policy preserves an aggregate JAR's existing main-JAR/POM publication set. All expected files need nonempty -signatures. No Gradle metadata, repository bookkeeping or unrelated components are copied. +signatures. Explicitly listed `.module` metadata and its signature are accepted. Repository bookkeeping and unrelated components are not copied. Source guards hash publication POMs and require a clean, unchanged Git revision with no assume-unchanged or skip-worktree index flags. Maven preparation requires a POSIX filesystem and bash, groovy and mvn; Windows users must use WSL. Repository and Portal URLs default to @@ -90,9 +90,15 @@ The record is written as `SUBMITTING` before HTTP and stores the ID immediately Definite HTTP 401/403/422 rejections are archived beside the active record as `*.rejected-.json`, allowing a corrected new invocation. Other upload exceptions persist `UPLOAD_ERROR` with diagnostics; invalid ID responses retain the raw response in `SUBMITTING`. +Connect failures also leave an active `UPLOAD_ERROR` record that must be manually reconciled +before another upload. An initial connection failure usually means no upload was sent, but a +307/308 redirect can send the first POST before the connection to its target times out. +The client does not distinguish these cases, so it conservatively retains the record. Never automatically retry an unknown upload outcome. Resume only the saved ID, without source checks, builds or signing. Polling defaults to 60 attempts at 10-second intervals; Transient I/O errors are recorded and polling continues within that bound. Availability probes use HEAD. Central propagation remains separate from Portal publication. A failed record reports all component coordinates. Archive an `AVAILABLE` record before a new deployment; manually reconcile every unresolved record rather than deleting it to retry. + +CentralPortalClient uploads and deployment status checks use default connect (30 s) and caller deadlines (1800 s uploads, 60 s small calls). The single-publication extension timeout overrides do not apply here. Separate PublicationStatus and HEAD availability checks retain their existing behavior without this overall deadline. diff --git a/gradle/shell-harness.gradle b/gradle/shell-harness.gradle new file mode 100644 index 0000000..daa4398 --- /dev/null +++ b/gradle/shell-harness.gradle @@ -0,0 +1,30 @@ +// Shared policy; also applied by isolated TestKit fixtures. +def bashExecutable = providers.gradleProperty('shellHarnessBash').getOrElse('bash') +def required = providers.gradleProperty('requireShellHarness').getOrElse('false').toBoolean() +abstract class BashAvailable implements ValueSource { + interface Parameters extends ValueSourceParameters { + Property getExecutable() + } + Boolean obtain() { + try { + def process = new ProcessBuilder(parameters.executable.get(), '-c', 'exit 0').start() + boolean available = process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS) && process.exitValue() == 0 + if (process.isAlive()) process.destroyForcibly() + return available + } catch (IOException ignored) { return false } + } +} +def bashAvailable = providers.of(BashAvailable) { parameters.executable.set(bashExecutable) } +tasks.named('shellHarness') { + if (it instanceof Exec) executable bashExecutable + def policyLogger = logger + onlyIf('Bash is available, or required by CI') { + boolean available = bashAvailable.get() + if (!available && required) { + String message = "Bash is unavailable but requireShellHarness=true: ${bashExecutable}" + policyLogger.error(message) + throw new GradleException(message) + } + available + } +} diff --git a/publish.sh b/publish.sh index e370195..a995036 100755 --- a/publish.sh +++ b/publish.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -eo pipefail if [ -n "$(git status --porcelain)" ]; then echo "Error: Git working tree is not clean. Commit or stash changes before publishing." exit 1 @@ -9,5 +10,9 @@ if command -v jdk17 &> /dev/null; then . jdk17 export PATH="$JAVA_HOME/bin:$PATH" fi +set -u java -version -./gradlew clean build publishPlugins release +./gradlew clean build +./gradlew publishPlugins --validate-only +./gradlew release +./gradlew publishPlugins diff --git a/release.md b/release.md index 4c04feb..f3dd28d 100644 --- a/release.md +++ b/release.md @@ -1,14 +1,42 @@ # Release history ## Ver 2.3.0, in progress -- Dependency updates: - - com.github.ben-manes.versions:com.github.ben-manes.versions.gradle.plugin [0.53.0 -> 0.54.0] - - com.gradle.plugin-publish:com.gradle.plugin-publish.gradle.plugin [2.1.0 -> 2.1.1] - - org.apache.groovy:groovy [4.0.30 -> 4.0.31] - - org.testcontainers:testcontainers [2.0.3 -> 2.0.5] - - org.testcontainers:testcontainers-junit-jupiter [2.0.3 -> 2.0.5] -- Upgrade gradle version [9.4.0 -> 9.5.0] -- Added LatestGithubRelease Task which, similarly to latestMavenVersions, reports the latest release version of the project published to github releases. +- Fix: Central Portal requests through `WsClient`/`CentralPortalClient` now have connect timeouts and an overall + caller deadline, covering uploads and deployment status checks for `release` and `releaseAll`, including responses that stall after headers. + New properties `httpConnectTimeoutSeconds` (default 30) and `httpRequestTimeoutSeconds` (default 1800), each + 1–86400 seconds. Status and other small calls through this client use the shorter of 60 seconds and the configured + request timeout; uploads use the full configured timeout. `releaseAll` uses the default timeouts; the new properties + apply only to `release`. Separate `PublicationStatus` and `ReleaseAllTask` HEAD availability checks are unchanged + and do not receive the new overall deadline. + `WsClient` now uses `java.net.http.HttpClient`; header names in its result map are lower-case, with no null status-line key. +- Fix: `release` status polling now tolerates transient errors (5xx, 429, timeouts, malformed responses) instead of + failing a deployment that was uploaded successfully; 401/403 still fail immediately. The number of status checks is + unchanged: one initial check plus up to `statusCheckRetries` further checks. +- Fix: when status polling times out, the error now says the deployment may still publish and how to check it, + instead of reporting "Failed to release". Replaced the obsolete "staging profile id" error message. +- Fix: a missing signature no longer stops later `.asc` files (including the POM signature) from being added to the bundle. +- Bundle entries are now named from the publication coordinates (`artifactId-version[-classifier].extension`), + so `base.archivesName` no longer has to match `artifactId`. +- The bundle now includes Gradle Module Metadata (`.module` + signature + checksums) when it was generated or restored from cache + in the current build, and all artifacts of the publication, not only `.jar` files. Skipping the + metadata task with `onlyIf` while the POM still advertises it is now an error. The combined release accepts + `.module` files in the manifest. Metadata and its signature are cleared before generation to prevent stale + output from being bundled; generation reruns or restores from cache whenever its task is scheduled. This affects + all publishing builds, including snapshots, and forces the publication signing task to re-sign every artifact; + gpg-agent/pinentry may prompt for a passphrase on every publish. +- `release` retains support for JAR-producing packaging, including `bundle`, `maven-plugin`, and `ejb`. + Other packaging without a main JAR receives a clear error instead of misleading "missing .jar" errors. +- `release` validation now requires a signature and checksums for every artifact in the bundle, not only for a fixed + set of JAR/POM files. +- Ordinary `publish` and `publishToMavenLocal` builds do not require `mavenPublication`; metadata cleanup only attaches when a publication is selected. +- Behaviour change: `bundle` now depends only on the configured publication's + `publishPublicationToMavenLocal` task instead of `publishToMavenLocal`, so other publications are no + longer installed to `~/.m2` as a side effect. +- Fix: `latestGithubRelease` now detects `origin` remotes that contain user info or tokens + (e.g. `https://x-access-token:...@github.com/...`), and `url=` entries written without spaces. +- Fix: Central Portal credentials are encoded as UTF-8 regardless of the platform charset. +- `publish.sh` validates Plugin Portal publication before the Central `release`, then publishes to the Portal, + stopping on the first failure. A Portal failure after Central release requires retrying only Portal publication. ## Ver 2.2.0, 2026-05-01 - fixed groovy doc generation diff --git a/req/v2.3.0-fixes.md b/req/v2.3.0-fixes.md new file mode 100644 index 0000000..c19163c --- /dev/null +++ b/req/v2.3.0-fixes.md @@ -0,0 +1,467 @@ +# v2.3.0 review fixes: implementation plan + +Implementation and local verification are complete; see [the verification record](verification/v2.3.0-fixes.md). Merge, real Portal smoke testing and tagging remain rollout steps. + +This plan fixes the findings from the repository review. The work is split into three phases by severity. Ship each phase as its own commit or PR so it can be reviewed and reverted separately. + +| # | Finding | Severity | Phase | +|---|---------|----------|-------| +| 1 | No HTTP timeouts in `WsClient` | High | 1 | +| 2 | A single `release` status-poll error fails an upload that succeeded; misleading messages | Medium | 1 | +| 3 | The `\|\|` short-circuit in `BundleTask` drops later `.asc` files | Medium | 1 | +| 4 | Bundle drops Gradle Module Metadata and additional non-`.jar` artifacts; signatures are only validated for fixed entries; non-jar packaging gives misleading errors | Medium | 2 | +| 5 | Bundle entry names come from the file name, not from Maven coordinates | Low/Medium | 2 | +| 6 | `bundle` depends on the aggregate `publishToMavenLocal` | Low | 2 | +| 7 | GitHub remote detection misses `user@` and token URLs, and `url=` | Low | 3 | +| 8 | `publish.sh` publishes to the Plugin Portal before the Central release | Low | 3 | +| 9 | Credential encoding depends on the platform charset | Low | 3 | + +General rules: +- Do not restructure the code beyond what each fix needs. +- Every behavior fix gets a regression test that fails before the change and passes after it. Finding 7 tests the existing private methods through reflection before changing visibility, then calls them directly afterward. Finding 8 uses the shell harness described below. API visibility changes and documentation edits need no standalone failing regression test. +- Run `./gradlew build` plus the compatibility matrix (`-PtestGradleVersion=8.13` and `9.4.0`) at the end of each phase. +- Each phase's commit adds its entries to `release.md` (see "Release notes" below). Document new extension properties in `README.md`. + +--- + +## Phase 1: release reliability + +### 1. Add timeouts and an overall request deadline to `WsClient` + +**Files:** `WsClient.groovy`, new `TimeoutSettings.groovy`, `build.gradle`, `.github/workflows/ci.yml`, `CentralPortalClient.groovy`, `NexusReleasePluginExtension.groovy`, `NexusReleasePlugin.groovy`, `ReleaseTask.groovy`, `ReleaseAllTask.groovy`, `README.md` + +**Background (Java timeout semantics):** +- `URLConnection.setConnectTimeout(0)` and `setReadTimeout(0)` mean *infinite*. Allowing `0` would therefore switch timeouts back off. +- The read timeout only applies to reads from the response stream. It does **not** bound writes of the request body. A server that stops reading during `postMultipart` can block `input.transferTo(out)` indefinitely. Neither timeout bounds the total length of a request either: a server that trickles one byte per interval never trips the read timeout. +- `HttpURLConnection.disconnect()` cannot be relied on to cancel a request from another thread. In OpenJDK 17, once response headers have arrived, `disconnect()` calls `inputStream.close()`. `MeteredStream.close()` then waits for the same `ReentrantLock` that a blocked `read()` holds. So a watchdog that calls `disconnect()` can itself block until the read timeout fires. On a shared scheduler thread it would also delay every other request's watchdog. Sources: `sun/net/www/protocol/http/HttpURLConnection.java` (`disconnect()`) and `sun/net/www/MeteredStream.java` (`read`/`close` take `readLock`) in jdk17u. + +So the deadline must come from a mechanism that does **not** need to unblock the I/O thread before the caller can continue. The earlier `disconnect()`-watchdog design is withdrawn. + +**Design rule:** the *calling thread* is bounded by waiting on a future with a timeout. Releasing the network resources is a separate property, and it must be proven by tests (see the proof gate below). + +**Scope:** this finding bounds requests made through `WsClient`/`CentralPortalClient`, including single-publication upload and deployment polling, and combined upload/resume calls using that client. `releaseAll` receives these deadlines through `CentralPortalClient` with default values only; the new extension properties apply only to `release`. Two combined-flow availability checks remain unchanged and outside the overall deadline: `PublicationStatus.request()` uses a separate HTTP client, and the HEAD availability check in `ReleaseAllTask` uses `HttpURLConnection` with 15 s connect/read timeouts but no overall deadline. Do not claim that every Central Portal call in the combined flow is bounded. Extending the shared deadline mechanism to `PublicationStatus` is deferred. + +**Mechanism A (primary): move `WsClient` to `java.net.http.HttpClient`.** `PublicationStatus` already uses that API, but does not receive this deadline implementation. +1. Send each request with `client.sendAsync(request, BodyHandlers.ofString())`. Wait with `future.get(remainingDeadlineMillis, MILLISECONDS)`. The future completes only after the whole response body has arrived, so the wait covers connect, the upload write, headers and body. +2. On `TimeoutException`: call `future.cancel(true)`, then throw `java.net.SocketTimeoutException("Request exceeded overall deadline of s: ")`. The caller returns at the deadline whatever the I/O layer is doing. Whether `cancel(true)` also aborts the underlying exchange on JDK 17 (expected since JDK 16, JDK-8245462) is **unproven** until test T6 passes. +3. On `ExecutionException`, unwrap and rethrow the `IOException` cause. Restore the interrupt flag on `InterruptedException`. +4. Also set `HttpRequest.timeout(deadline)` as a second safeguard. It is not the primary bound. +5. Keep today's `HttpURLConnection` behaviour through explicit builder settings: + - `.version(HttpClient.Version.HTTP_1_1)`, so there is no h2c upgrade on `http://` test URLs; + - `.followRedirects(HttpClient.Redirect.NORMAL)`; + - `.proxy(ProxySelector.getDefault())`, so `http(s).proxyHost` settings keep working; + - `.connectTimeout(connectTimeout)`. +6. Multipart upload: `BodyPublishers.concat(ofByteArray(prefix), ofFile(bundle.toPath()), ofByteArray(suffix))`. This gives a fixed `Content-Length` and the same bytes on the wire as today. Existing `CentralUploadApiTest` assertions on the request must pass unchanged. +7. Keep the public `WsClient` method signatures, the result map keys (`BODY`, `RESPONSE_CODE`, `HEADERS`), and `WsException(status, errorBody)` for non-2xx responses. `HEADERS` will now come from `response.headers().map()`: lower-case names and no `null` status-line key. No code in the repository reads `HEADERS` (checked), but `WsClient` is public, so record the change in the release notes. +8. Create one `HttpClient` per `WsClient` instance; do not share a static client across builds. + +**Mechanism B (fallback, used only if the proof gate fails for A on a supported JDK):** keep `HttpURLConnection`, but never cancel it through a shared thread. +1. Run each request on its own new daemon worker thread (`nexus-release-http-`). The caller waits with `Future.get(deadline)` and throws the same `SocketTimeoutException` at the deadline. +2. Cleanup after a timeout runs on a separate, new daemon thread that calls `conn.disconnect()`. Neither the caller nor any shared thread waits for it. +3. The worker's read timeout is set to the request deadline. This limits an individual stalled read, but does not establish a total resource-release bound for trickled responses or blocked writes. Any such bound requires T6 to pass. +4. A worker blocked in an upload write is released by the cleanup thread. Before response headers exist, `disconnect()` goes through `http.closeServer()`, which does not take the stream lock. Test T6 must show this. + +**Properties and validation:** +1. Two extension properties. The separate read timeout is dropped: mechanism A has no per-read timeout, and mechanism B derives it from the deadline. + - `httpConnectTimeoutSeconds`, default `30` + - `httpRequestTimeoutSeconds`, default `1800`. This is the overall upload deadline and an upper bound for other requests. For status polls and other small GET/POST calls, use an internal effective deadline of `min(60 seconds, httpRequestTimeoutSeconds)`. Multipart uploads retain the full configured deadline. Apply this distinction inside `WsClient`, so both `ReleaseTask` and `CombinedDeployment.resume` receive the short polling deadline through the existing constructor. Use the effective deadline for future waits, request timeouts, fallback read timeouts and timeout messages; do not expose another extension property. +2. Explicit range for both: **1 to 86 400 seconds (24 h)**. Outside the range, fail with `" must be between 1 and 86400 seconds"`. + - `0` is rejected, because both `URLConnection` and `HttpClient` treat zero or absent timeouts as infinite. + - Convert with `seconds * 1000L`. The upper bound keeps every value within `int` milliseconds (86 400 000 < 2³¹−1), so the same validated value is safe for `int` setters (mechanism B) and for `Duration` (A). Do not silently clamp. + - Put the check in a shared `TimeoutSettings.validate(name, value)` helper, so that `ReleaseTask` and any later caller agree. +3. Wire the properties into `ReleaseTask` as `@Input` properties, then into `CentralPortalClient`. Keep the existing 4-arg constructor, which uses the defaults, so `ReleaseAllTask` and the tests keep compiling. `ReleaseAllTask` keeps the defaults in this release; extension timeout overrides apply only to `release`, not `releaseAll`. + +**Behaviour to confirm (no code change expected; add assertions to existing tests for the `CentralPortalClient` calls, not the separate `PublicationStatus` availability checks):** +- In the combined flow, a deadline timeout during `CombinedDeployment.submit` must leave an `UPLOAD_ERROR` record, because the outcome is unknown and an automatic re-upload is not safe. +- During `CombinedDeployment.resume`, the same exception is recorded as `lastPollingError` and the poll is retried. + +**Tests:** a new `WsClientTimeoutTest`, plus additions to `CentralUploadApiTest`. +- Every test has JUnit `@Timeout(30)` and measures elapsed time on the **calling thread**. +- Use `requestDeadline = 2 s` through a test hook that accepts milliseconds, and margin = 5 s (CI scheduling slack). +- Tests that need exact stall behaviour use a raw `ServerSocket` server. + +| Test | Server behaviour | Assertion | +|---|---|---| +| T1 headers stall | accepts the connection, reads the request, sends nothing | `SocketTimeoutException` mentioning the deadline, in ≤ 7 s | +| **T2 body stall after headers** | sends the status line and headers with `Content-Length: 1000`, then 10 body bytes, then stalls. For mechanism B the internal read timeout is forced to 60 s (much longer than the deadline), which recreates the `MeteredStream` lock case | caller returns in ≤ 7 s | +| T3 upload write stall | accepts and never reads; client uploads a 64 MB file | caller returns in ≤ 7 s | +| T4 trickled body | sends 1 byte every 500 ms, indefinitely | caller returns in ≤ 7 s (no per-read timeout would fire) | +| T5 no collateral delay | (a) right after T2 times out, with its stalled connection still open, a request to a healthy server completes in ≤ 1 s; (b) two T2-style stalled requests run concurrently and both return in ≤ 7 s, which proves no shared-thread serialisation | as stated | +| T6 resource release (**proof gate**) | after T1–T4 time out, the server reads and discards buffered request data until it sees EOF (`-1`) or a connection-close exception; this includes draining the stuck-upload socket only after the caller times out | within 5 s of the timeout | +| T7 thread hygiene | after T1–T5 | no **non-daemon** threads created by the client remain alive; with mechanism B, no `nexus-release-http-*` thread outlives about 2× the deadline | +| T8 parity (A only) | 302 → 200 redirect; existing upload, auth header and error-body tests; client configured with `ProxySelector.getDefault()` (unit test on the builder hook) | unchanged behaviour | +| T9 validation | `0`, `-1`, `86_401` and `Integer.MAX_VALUE` for each property | fail with the range message; `1` and `86_400` are accepted (configure only, no wait) | +| T10 end to end | `release` against the T3 server | `GradleException` in ≤ deadline + margin, not a hang | +| T11 small-call deadline | configure a long upload deadline; stall a status response. Use a test hook that lowers the internal small-call cap from 60 s to 1 s | status call times out in ≤ 6 s while upload retains its configured deadline; assert production effective deadlines are `min(60 s, configured deadline)` for both GET and non-upload POST | + +Run T11 through both single-release polling and combined resume, asserting the timeout is recorded/retried with the existing attempt accounting. This must not make a new upload request. Also test a configured deadline below the internal cap; the cap must never lengthen it. + +Before the change, T2, T3 and T4 must hang (and be killed by `@Timeout`) on the current code. Confirm that once and record it in the PR. + +**Supported runtime JDKs and CI:** this release verifies Gradle daemon runtimes **JDK 17 and JDK 21**, on both Gradle 8.13 and 9.4.0. Other runtime JDKs are outside this release's verified support matrix. Compilation keeps the Java 17 toolchain; that does not determine the runtime executing the plugin. +- Add `java-version: ['17', '21']` to the compatibility job in `.github/workflows/ci.yml`, alongside the Gradle-version axis. Install both JDK 17 (compilation) and the selected runtime JDK; select the latter for the build's `JAVA_HOME`. +- Add `-PtestJavaVersion` wiring in `build.gradle`: set the `test` task's `javaLauncher` lazily with `javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(selectedVersion) }`, and pass the selected version to the tests. Default to 17 locally. Keep `charsetTest` explicitly on JDK 17 for its non-UTF-8 precondition. +- Run `WsClientTimeoutTest`, `CentralUploadApiTest` (including TestKit timeouts) and `GradleCompatibilityMatrixTest` in every Gradle/JDK combination, passing both `-PtestGradleVersion` and `-PtestJavaVersion`. The existing compatibility-only test filter is insufficient. +- TestKit runners must select the matrix Gradle distribution with `withGradleVersion(testGradleVersion)`. **Require an assertion and recorded evidence that the actual TestKit Gradle daemon JVM major version equals `testJavaVersion`**, so a test launcher on JDK 21 cannot silently test a daemon on JDK 17. Also assert/log the actual Gradle version and the `WsClientTimeoutTest` JVM version. The `test.javaLauncher` setting selects the JVM running the tests; do not assume that setting `JAVA_HOME` selects the Tooling API daemon. Passing the test JVM's JDK home as `JAVA_HOME` through `withEnvironment` is optional. If used, merge it into the inherited environment instead of replacing the other variables. A runtime mismatch fails the matrix cell; fix runtime selection before accepting its proof-gate results. + +**Proof gate:** T1–T7 must pass with mechanism A in all four Gradle/runtime-JDK combinations above. Record actual runtime versions and results in the PR. +- If T6 fails with A, meaning the caller is bounded but the exchange is not aborted, switch to mechanism B and re-run T1–T7. +- If T6 also fails with B, test an explicit relaxed bound of **2 × the request deadline measured from request start**, covering stalled headers, stalled bodies, stalled upload writes and trickled responses. Claim that bound only if the revised T6 passes on every supported JDK and the CI matrix. +- If the relaxed T6 fails too, the caller bound may still ship, but document in the PR, README and release notes: "The caller stops waiting at the request deadline. Network cancellation and resource cleanup are best effort, with no guaranteed completion deadline." Record the failing cleanup scenarios; do not describe a read timeout as proof of total resource release. T7 must still demonstrate that no non-daemon thread prevents shutdown, and its worker-lifetime assertion must match the cleanup guarantee actually proven. + +**Done when:** in T1–T5 the calling thread of every `WsClient` request returns, successfully or with a `SocketTimeoutException`, within the effective deadline (full upload deadline or capped small-call deadline) + 5 s, including a body stall after headers with a long read timeout and a stalled upload write. A timed-out request does not delay other requests. Neither timeout can be configured to infinite or overflow. Any resource-release bound is supported by passing T6 results; otherwise the absence of a guaranteed cleanup deadline is explicitly documented. + +### 2. Make `ReleaseTask` polling tolerate transient errors; fix its messages + +**Files:** `ReleaseTask.groovy` + +**Retry accounting (keep current behaviour):** today `ReleaseTask` does one initial status check after `initialStatusCheckDelaySeconds`, then up to `statusCheckRetries` further checks while the state is not terminal (`retries-- > 0`, lines 112-120). That means **at most `1 + statusCheckRetries` status requests**. This fix keeps that definition unchanged: +- A failed check (exception) uses the same slot as a check that returns a non-terminal state. Errors never add extra requests. +- `statusCheckRetries = 0` means exactly one status request. +- Update the README text for `statusCheckRetries` to say "number of additional checks after the first one". + +**Changes:** +1. Move the status fetch into a private helper that catches `IOException`: + - `WsException` with status 401 or 403: wrap immediately in a `GradleException` with the same authentication/permission messages used by `createUploadException`, preserving the cause. Share that mapping between upload and status checks; do not sleep or retry. + - Any other `IOException` (5xx, 429, timeout or deadline from Finding 1, JSON parse failure): log `Status check failed (check n/<1+N>): `, treat the state as unknown (`null`), and continue under the accounting rule above. +2. When all checks are used up and the state is not terminal, replace the generic failure with: + > `Deployment for is still after <1+N> status checks. It may still publish; check https://central.sonatype.com/publishing/deployments or increase statusCheckRetries / statusCheckIntervalSeconds.` + + If the last check failed, append `Last error: `. Still throw a `GradleException`, so the build does not report success while the outcome is unknown. +3. Keep `"Failed to release ..."` only for the `FAILED` state. +4. Replace `"Failed to find the staging profile id"` (line 103) with `"Central Portal upload returned no deployment id"`. + +**Tests:** in `CentralUploadApiTest`. All tests set `initialStatusCheckDelaySeconds = 0` and `statusCheckIntervalSeconds = 0`, and use a dispatcher that counts requests per endpoint. Every test asserts **exactly one upload request** and the exact number of status requests: + +| Scenario | `statusCheckRetries` | Status responses | Expected outcome | Status requests | +|---|---|---|---|---| +| Zero retries, terminal | 0 | `PUBLISHED` | success | 1 | +| Zero retries, non-terminal | 0 | `PENDING` | failure, `still PENDING after 1 status checks` | 1 | +| Zero retries, transient error | 0 | 502 | failure, message includes `Last error` | 1 | +| Exhaustion | 2 | `PENDING` ×∞ | failure, `still PENDING after 3 status checks` | 3 | +| Exhaustion with errors | 2 | 502, 502, 502 | failure, `still unknown`, `Last error` | 3 | +| Transient then success | 2 | 502, `PUBLISHING` | success | 2 | +| Malformed JSON then success | 2 | `not-json`, `PUBLISHED` | success | 2 | +| Immediate 401 | 5 | 401 | failure, auth message | 1 | +| Immediate 403 | 5 | 403 | failure, permission message | 1 | +| 401 after transient | 5 | 502, 401 | failure, auth message | 2 | +| FAILED state | 5 | `FAILED` | failure, `Failed to release` and the error list | 1 | + +### 3. Fix the short-circuit that drops `.asc` files in `BundleTask` + +**Files:** `BundleTask.groovy` + +**Changes:** At lines 114 and 129, call `addFileToZip` unconditionally and then merge the result: +```groovy +boolean missing = addFileToZip(file, ".asc", mavenPathPrefix, zipOut) +missingFilesDetected = missingFilesDetected || missing +``` +(If Phase 2 reworks this loop, carry the same pattern into the new code.) + +**Tests:** in `ReleasePluginTest`, or a new `BundleTaskTest`: +- Fixture: the signature of the first processed artifact (for example the javadoc jar) is missing, and the other signatures exist. Assert that the zip contains every other `.asc`, and that the `release` validation output lists exactly one missing signature. + +--- + +## Phase 2: bundle completeness + +Findings 4 and 5 both change the same loop in `BundleTask`, so do them together. + +### 5. Name bundle entries from Maven coordinates (do this first) + +**Files:** `NexusReleasePlugin.groovy`, `BundleTask.groovy`, `BundleValidator.groovy` + +**Changes:** +1. Replace `ListProperty artifactFiles` with an entry map built from `MavenArtifact` metadata: + ```groovy + @Internal abstract MapProperty getArtifactEntries() // bundle file name -> source file + + @InputFiles @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getArtifactInputFiles() + ``` +2. In `NexusReleasePlugin`, build the map as follows: + ```groovy + pub.artifacts.collectEntries { MavenArtifact a -> + String classifier = a.classifier ? "-${a.classifier}" : '' + ["${pub.artifactId}-${pub.version}${classifier}.${a.extension}".toString(), a.file] + } + ``` + Fail with a clear `GradleException` if two artifacts map to the same entry name. + Populate `artifactInputFiles.from(artifactEntries.map { it.values() })`. A collection of plain `File` values does not preserve producer task dependencies by itself: retain the explicit per-publication Maven-local dependency, and wire the collection's `builtBy` lazily: `artifactInputFiles.builtBy(extension.mavenPublication.map { it.artifacts })`. `MavenArtifact` is `Buildable`; resolving the provider retains each artifact's build dependencies. Never call `extension.mavenPublication.get()` during plugin application. Verify an extra artifact is built before bundling on both supported Gradle versions. +3. In `BundleTask`, write each entry under `mavenPathPrefix + entryName`. Look up its signature at `new File(file.absolutePath + '.asc')`, because the signing plugin writes the signature next to the source file, and store it as `entryName + '.asc'`. +4. In `BundleValidator.validateFileNameConsistency`, keep the check as a safety net, but replace the `base.archivesName` hint with one that suggests a mismatch between publication and artifact coordinates. + +**Configuration-cache check:** `MapProperty` is serializable. Confirm that the existing `--configuration-cache` reuse test in `ReleasePluginTest` (lines 41-62) still passes. + +**Tests:** +- A fixture where `base.archivesName` differs from `artifactId` (adapt the fixture near `ReleasePluginTest:135`). `bundle` should now produce correctly named entries, and `release` validation should pass. +- The existing bundle-content assertions should still pass. + +### 4. Include Gradle Module Metadata and additional artifacts (publications with a main JAR) + +**Scope:** this release supports **publications with a main JAR**, including `bundle`, `maven-plugin`, `ejb`, and custom JAR-producing packaging. Such a publication may carry additional artifacts of any extension, such as `-dist.zip` or other classified files, plus Gradle Module Metadata. +- Publications without a main JAR (for example WAR-only and POM-only publications) are rejected by `release` with a clear error before upload. +- Packaging-aware requirements (which primary artifact, and whether sources/javadoc are needed, per packaging) are deferred to a later release with its own plan. + +**Verify first.** Build `TestFixtures.createTestProject` with `publishToMavenLocal`, and record the results in the PR description: +- `build/publications/maven/module.json` and `module.json.asc` exist when signing is on. +- Whether `pom-default.xml` contains `` in three cases: + - (a) metadata task enabled and run; + - (b) task `enabled = false`; + - (c) task enabled but skipped with `onlyIf { false }`. + + The expected result is marker present / absent / **present**. The consistency rules below depend on this, so adjust them if the observed results differ. +- After an enabled build, a second build with (b) or (c) and no `clean` leaves `module.json` on disk. This is the stale-file case. + +**Files:** `BundleTask.groovy`, `BundleValidator.groovy`, `NexusReleasePlugin.groovy`, new `ClearModuleMetadataTask.groovy`, `CombinedBundle.groovy`, `README.md`, `combined-releases.md` + +**Changes:** +1. In `BundleTask`, remove the `if (!name.endsWith('.jar')) return` filter. After Finding 5, every artifact of the publication goes into the bundle under its coordinate name, with its `.asc`, `.md5` and `.sha1` files. +2. **Module metadata: include it only when it was produced or confirmed in the current build.** Neither "the file exists" nor `task.enabled` says that: + - a disabled task leaves its old output in place; + - a task skipped by `onlyIf` stays `enabled`, and the POM still carries the marker. + + **Use deterministic cleanup before generation.** Do not use task-completion listeners to decide what to bundle: Gradle documents that `BuildEventsListenerRegistry` delivers events asynchronously, so a dependency's event need not be available when `bundle` runs. Repeated successful tests cannot establish that ordering. + - Register one fixed-name `clearModuleMetadata` task during plugin application; the user configures `extension.mavenPublication` later, so a publication-dependent task name cannot be computed then. Wire only the selected publication's generation task lazily: + ```groovy + def clear = tasks.register('clearModuleMetadata', ClearModuleMetadataTask) + tasks.withType(GenerateModuleMetadata).configureEach { t -> + t.dependsOn(extension.mavenPublication.map { pub -> + t.name == "generateMetadataFileFor${pub.name.capitalize()}Publication" ? [clear] : [] + }.orElse([])) + } + ``` + Do not call `.get()` on the publication property during `apply`, use `afterEvaluate`, or create cleanup dependencies for other publications. + - The cleanup task has an `@Internal RegularFileProperty` for the selected metadata output location. Declare both the metadata file and its adjacent `.asc` signature with `destroyables.register(...)`. Register plain `File`/path values (or lazy providers of those detached values), **without generation-task producer dependencies**; do not pass `generateTask.flatMap { it.outputFile }` directly to destroyables. Resolve the selected task's configured output location lazily during configuration/task graph preparation, preserving custom output locations and configuration-cache compatibility. Do not capture `Project` or tasks in the cleanup action. + - Its action deletes both registered files and fails clearly if deletion fails. Always execute it when scheduled; declare no inputs or outputs that conflict with destroyables, do not mark it up to date and do not cache its destructive action. Destroyables inform Gradle's filesystem tracking and parallel scheduling; the explicit dependency above establishes cleanup-before-generation ordering. + - The dependency is an execution dependency, not just `mustRunAfter`, so cleanup completes before generation, even if generation is disabled or skipped by `onlyIf`. Keep the upstream chain from `bundle` through the selected publication's Maven-local task, and ensure signing and publication consume metadata only after that generation dependency completes. + - `BundleTask` gets: + ```groovy + @Internal abstract RegularFileProperty getModuleMetadataFile() // metadata task.outputFile, not a hard-coded path; unset when no task exists + + @InputFiles @Optional @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getModuleMetadataInput() + ``` + Wire these and the cleanup task's file lazily from the selected publication's metadata task. Populate `moduleMetadataInput` with the metadata output path and its adjacent `.asc` path whenever the selected publication's metadata task exists, enabled or not, **without any file-existence check**. Missing files in an `@InputFiles` collection are valid and tracked as absent. Leave the collection empty only when no metadata task exists. Never save a configuration-time existence decision in the configuration cache; cleanup and generation change file existence during each build. Keep the marker/existence checks below; empty optional inputs must not bypass those checks. This explicitly tracks content at custom output locations outside `publicationDirectory`, including signature changes, instead of relying on the directory input. Do not capture `Project` or task instances in task actions. + - After cleanup, a metadata file available to downstream tasks was generated in this build or restored from the build cache. A skipped task cannot leave a stale file available. Evaluate these rules before writing a new bundle: + + | Metadata file after generation dependency completes | POM marker | Result | + |---|---|---| + | exists | present | include `.module`, its signature and checksums | + | exists | absent | fail: `Module metadata state is inconsistent with the POM` | + | absent | absent | exclude; disabled or unsupported metadata is allowed | + | absent | present | fail: `The POM advertises Gradle metadata, but no metadata was produced in this build. Disable the metadata task (enabled = false) instead of skipping it, or let it run.` | + + Signing or Maven-local publication may fail first when generation is skipped and the advertised file is missing. That earlier failure is acceptable; it must never consume the stale file or reach an upload. + - Trade-off: metadata cannot remain `UP_TO_DATE` across bundle runs because its output is removed before generation. It must be regenerated or restored from the build cache whenever its task is scheduled, including direct signing or Maven-local publication runs. Document this behavior in README and release notes. Cleanup also runs for plain `publishToMavenLocal`, publication to other repositories and snapshot publishing. Deleting `.asc` invalidates the publication signing task, so it re-signs all artifacts on each such build and can trigger a gpg-agent/pinentry passphrase prompt that an up-to-date signing task previously avoided. +3. **Packaging and signature validation** in `BundleValidator`: + - Read the POM first, then `` (absent → `jar`). If it is not `jar` and the coordinate-named main `.jar` is absent: + - report a single error: `Packaging '

' is not supported by the release task in this version; a main JAR is required (additional artifacts are supported)`; + - for `pom`, add: `For pom-packaged publications such as BOMs, use the combined release (releaseAll)`. `CombinedBundle` already handles packaging `pom`. + + Skip the required-entry and signature checks in that case, so the user sees one clear cause rather than a cascade of "missing .jar" errors. The credentials check still runs. + - Independently of task state, if the bundled POM contains `published-with-gradle-metadata`, require the coordinate-named `.module` entry and its non-empty `.module.asc` in the same directory. Validate the archive itself, including hand-built bundles; add unit cases for neither entry, module without signature, and both entries present. + - For every accepted publication, keep the required **primary entries**: `.pom`, the main `.jar`, `-sources.jar` and `-javadoc.jar`. + - Add a generic rule: every **primary entry** in the bundle needs a non-empty `.asc`. A primary entry is any entry whose name does not end in `.asc`, `.md5`, `.sha1`, `.sha256` or `.sha512`. Report each missing one as `Missing signature for bundle entry: `. This covers `.module`, `.zip` and classified artifacts without listing extensions. + - Also require `.md5` and `.sha1` for every primary entry, because the bundle task always generates them. + - Drop the hard-coded `.asc` entries from `requiredEntries`, because the generic rule covers them. + - `release` must be blocked by `BundleValidator` alone, so a bundle built outside the `bundle` task is also checked. Keep `missingFilesDetected` only as an early warning in `bundle`. + - Apply the filename-consistency check to every primary entry, not only `.jar` and `.pom`. +4. `CombinedBundle` line 26: allow `.module` in the filename allowlist, so that combined Gradle candidates can list `${prefix}.module` in `files`. Keep `required` unchanged. `CombinedBundle` already requires a non-empty `.asc` for every listed file (lines 30-34). +5. In `README.md`, document: + - module metadata and additional artifacts are now bundled, and each must be signed; + - JAR-producing packaging remains supported by `release`; + - skipping the metadata task with `onlyIf` is rejected; disable it instead; + - cleanup applies to all publishing builds of the selected publication, including snapshots, forces publication-wide re-signing and may prompt through gpg-agent/pinentry on every publish. + +**Ordering and compatibility checks:** verify cleanup precedes generation, signing and Maven-local publication on Gradle 8.13 and 9.x, with and without `--configuration-cache` (including reuse), and with `--parallel` and `--watch-fs`. Cover successful generation, build-cache restoration, `enabled = false` and `onlyIf { false }`. These checks verify explicit task dependencies; there is no listener-delivery proof gate or fallback selection. + +**Tests:** +- Metadata on: the bundle contains `.module`, `.module.asc`, `.module.md5` and `.module.sha1`, and `release` validation passes. +- **Enabled → disabled (`enabled = false`), no `clean`:** confirm that the prior metadata and signature exist before build 2, then assert cleanup removed both and the new bundle has **no** `.module` entry. Validation passes. +- **Enabled → `onlyIf { false }`, no `clean`:** confirm the prior metadata and signature exist before build 2. Assert cleanup removed both, generation was skipped, and signing, Maven-local publication or bundling fails before upload. If `bundle` runs, expect the "advertises Gradle metadata, but no metadata was produced" message. No new bundle containing stale metadata may be written; the mock server receives zero requests. +- **Disabled → enabled, no `clean`:** the bundle contains a `.module` whose bytes equal the freshly generated file. +- **Repeated build and from-cache:** a second identical build includes fresh or cache-restored `.module` bytes; metadata generation must not be `UP_TO_DATE` after cleanup. A build-cache restore includes `.module` and its valid signature. Assert `FROM_CACHE` where the metadata task supports caching on the tested Gradle version; otherwise assert regeneration and record that limitation. +- **Custom metadata output:** configure generation to write outside `publicationDirectory`. Bundle once, change the generated metadata content, and rebuild without `clean`, including configuration-cache reuse. Assert `bundle` is not `UP_TO_DATE` and its `.module` bytes match the new output. Also change the signature and assert the new signature is bundled. Start the first, configuration-cache-storing run after `clean`, with no metadata or signature on disk, then reuse the cache for the changed-output run. Verify the declared input collection contains both custom paths in every run, including when metadata generation is disabled and the files are absent. Verify an empty collection only for a publication without a metadata task. +- **Filesystem watching:** run identical builds twice with `--watch-fs --no-build-cache`, including configuration-cache reuse. Verify cleanup removes the prior files and generation is `SUCCESS` (executed), never `UP_TO_DATE`; include `--parallel` and both supported Gradle versions. +- **Late publication selection and isolation:** configure `mavenPublication` after plugin application in a fixture with two publications. Verify the fixed cleanup task targets only the selected publication, the other publication retains its metadata/signature and signing can stay up to date, and no cleanup/generation dependency cycle is created. +- **Inconsistency guard:** a test-only task removes the marker from `pom-default.xml` after it is generated. Expect the inconsistency failure. +- **Missing module signature:** with metadata on, `module.json.asc` is deleted after `publishToMavenLocal` (or the bundle is hand-built without `.module.asc`). `release` fails before any upload, with `Missing signature for bundle entry: .../-.module`, and the mock server received zero requests. +- **Missing ZIP signature:** a jar publication with a classified `-dist.zip` whose `.asc` is removed. `release` fails before upload with `Missing signature for bundle entry: .../--dist.zip`; zero requests. +- A jar publication with a signed `-dist.zip` includes the file with its signature and checksums, and validates. +- **WAR-only publication** (`war` plugin, `from components.web`, packaging `war`): `release` fails before upload with the packaging error. The error list does **not** contain `Missing required bundle entry`; zero requests. +- **Pom-packaged publication** (`java-platform`, `from components.javaPlatform`): `release` fails with the packaging error and the `releaseAll` hint; zero requests. +- `BundleValidator` unit tests: missing `.asc`, empty `.asc`, and missing `.md5`/`.sha1` for each primary-entry type; packaging absent → treated as jar. +- `CombinedReleaseTest`: a manifest that lists `${prefix}.module` bundles. `${prefix}.exe` is still rejected. A listed `.module` without `.module.asc` is rejected. + +### 6. Depend only on the configured publication's Maven-local task + +**Files:** `NexusReleasePlugin.groovy` + +**Changes:** Replace `task.dependsOn "publishToMavenLocal"` with a dependency on the per-publication task: +```groovy +task.dependsOn(extension.mavenPublication.map { MavenPublication pub -> + "publish${pub.name.capitalize()}PublicationToMavenLocal".toString() +}) +``` +- Confirm that `dependsOn` resolves a `Provider` task path on Gradle 8.13 and 9.x. If it does not, use `project.provider { project.tasks.named(...) }`. +- If `mavenPublication` is unset, `bundle` should fail with a clear message, such as `nexusReleasePlugin.mavenPublication must be set`, not with a "property has no value" error. + +**Compatibility:** This is a behaviour change, because other publications are no longer installed to `~/.m2`. Record it in `release.md`. + +**Optional follow-up, not in this release:** drop the Maven-local dependency entirely and depend on `generatePomFileForPublication`, `signPublication` and the artifacts' build dependencies. + +**Tests:** +- A fixture with two publications. Run `bundle --dry-run` and assert that only `publishMavenPublicationToMavenLocal` is scheduled. +- A run without `mavenPublication` fails with the new message. + +--- + +## Phase 3: smaller fixes + +### 7. Make GitHub remote detection more robust + +**Files:** `NexusReleasePlugin.groovy`; tests in `ReleasePluginTest`, which already uses `TestFixtures` with `remoteUrl` + +**Changes:** +1. Change the HTTPS regex to allow optional user info: + ```groovy + '^https?://(?:[^@/]+@)?([^/:]+)(?::\\d+)?/(.+?)(?:\\.git)?/?$' + ``` +2. In `parseOriginUrlFromConfig`, match `url` with optional whitespace (`/^url\s*=/`), and match the `remote` section name without regard to case while keeping the `"origin"` subsection case-sensitive. +3. Make sure no log message or error prints the raw remote URL, because it can contain a token. +4. Make `parseGithubCoordinates` and `parseOriginUrlFromConfig` package-private, so they can be unit-tested without TestKit. + +**Tests:** parameterized over: +- `https://user@github.com/o/r.git` +- `https://x-access-token:abc@github.com/o/r.git` +- `https://github.com/o/r/` +- `ssh://git@github.com:22/o/r.git` +- `git@github.com:o/r.git` +- a config written with `url=` and a config with `\turl = ` + +Each should give `o/r`. A mismatched host gives `null`. + +### 8. Reorder `publish.sh` + +**Files:** `publish.sh`, `build.gradle`, new `gradle/shell-harness.gradle`, shell harness and TestKit policy tests, `.github/workflows/ci.yml` + +**Changes:** +```bash +./gradlew clean build +./gradlew publishPlugins --validate-only +./gradlew release +./gradlew publishPlugins +``` +Add `set -eo pipefail` at the top. Keep nounset disabled while sourcing the external `jdk17` helper, whose handling of unset variables is unknown; enable `set -u` after the helper returns. Validation must succeed before Central release, as in `validate.sh`. If `release` fails, nothing goes to the Plugin Portal. Validation reduces the risk but cannot make the two services atomic: if actual Portal publication fails after Central release, report that partial state and retry only the Portal step. + +**Test:** a shell harness copies `publish.sh` into a temporary fixture with stub `git`, `java`, `gradlew` and `jdk17` commands. Record Gradle invocations; assert build → Portal validation → Central release → Portal publication. Inject failure at each step and assert no later invocation occurs. The helper fixture reads an unset variable to verify nounset is not enabled while sourcing it. Run the harness through `check` only when a working `bash` executable is available. Otherwise skip this harness with an explicit reason, keeping local `check` usable on Windows without Bash. Add `-PrequireShellHarness=true`, defaulting to false locally, and pass it on the Ubuntu CI `build`/`check` invocation in `ci.yml`. The harness task's `onlyIf("Bash is available, or required by CI")` predicate must throw a clear `GradleException` when Bash is missing and this flag is true; otherwise it returns false with the skip reason when Bash is missing, or true to run the harness. Add an internal `-PshellHarnessBash=` override, defaulting to `bash`, used consistently for the availability probe and harness invocation. A nonexistent explicit path must be treated as unavailable, without falling back to Bash on PATH. Keep the Bash availability provider lazy and evaluate it only inside `onlyIf`, so unrelated builds and their configuration-cache hits never start Bash. Extract Bash availability probing, `shellHarnessBash`/`requireShellHarness` flag handling and the harness task's `onlyIf` policy into `gradle/shell-harness.gradle`. Keep this script independent of the root build's publishing, signing and toolchain plugins. The root build registers its harness task and applies this script with `apply from:` to configure that task. For the missing-Bash tests, create a minimal TestKit project in a separate temporary directory, register a stub task with the same harness task name, copy the shared policy script unchanged into the fixture and apply it. Read the source script from the `shellHarnessPolicyScript` system property, never from the test working directory. Do not run nested builds in this repository or copy its root `build.gradle`; invoke only the stub harness task, with isolated project/build state. Point the override at a nonexistent file in that fixture: with `-PrequireShellHarness=true`, expect a build failure and the missing-Bash message; with `-PrequireShellHarness=false`, expect the harness task to be `SKIPPED` and its Bash-unavailable skip reason in the output (use `--info` if needed). These tests run on Ubuntu even though Bash is installed. CI can therefore never turn missing Bash into a successful skip. Where Bash is available, the harness must fail with the original script. + +**Test input wiring:** pass the policy script path through a `CommandLineArgumentProvider` whose file input is path-insensitive. Do not also register the absolute path with `Test.systemProperty`, because that would include the checkout location in the build-cache key. The provider replaces both the separate `inputs.file(...)` registration and the `systemProperty` call: +```groovy +abstract class PolicyScriptArg implements CommandLineArgumentProvider { + @InputFile @PathSensitive(PathSensitivity.NONE) + abstract RegularFileProperty getScript() + + @Override + Iterable asArguments() { + ["-DshellHarnessPolicyScript=${script.get().asFile.absolutePath}".toString()] + } +} + +tasks.named('test') { + def policyArg = objects.newInstance(PolicyScriptArg) + policyArg.script.set(layout.projectDirectory.file('gradle/shell-harness.gradle')) + jvmArgumentProviders.add(policyArg) +} +``` +The policy test reads the resulting `shellHarnessPolicyScript` system property and fails clearly if it is absent or the file cannot be read. Apply the same argument-provider wiring to any additional `Test` task that runs these policy tests. Verify that changing only the policy script invalidates `test`'s up-to-date result and prior build-cache entry, so the tests run against the changed script. **Relocation verification:** first try the same commit in two isolated checkout locations with the same Gradle/JDK, test selection and shared build cache, retaining `--info`/cache-key diagnostics. Treat a whole-task `FROM_CACHE` result as useful evidence, not an unconditional acceptance criterion: generated `plugin-under-test-metadata.properties` may contain absolute paths and independently affect the runtime-classpath fingerprint. Inspect the generated metadata and diagnostics before attributing a cache miss to the policy provider. + +The required check is narrower: demonstrate that the argument provider's annotated script input has the same fingerprint for identical script bytes in different checkout locations, and a different fingerprint after changing those bytes. Use cache-key/input diagnostics or an isolated minimal fixture with the same provider, a small test reading the script and no TestKit plugin metadata on its runtime classpath. Such an isolated fixture should restore its `Test` task from cache after relocation; record the content-change miss as well. Do not require the repository's entire `test` task to restore from cache if unrelated inputs differ. + +Record in the PR whether whole-task relocation succeeded or the scoped verification was used, including the identified cause of any whole-task miss. Do not add runtime-classpath normalization solely to make this check pass. If a separate follow-up chooses to ignore `plugin-under-test-metadata.properties`, first prove that its referenced classes and jars are independently tracked classpath inputs and that the ignored metadata carries no other behavior-changing information. The policy script's content must affect the key; its absolute checkout path must not. + +### 9. Encode credentials as UTF-8 + +**Files:** `CentralPortalClient.groovy` + +**Changes:** On line 34, change `getBytes()` to `getBytes(StandardCharsets.UTF_8)`, to match `PublicationStatus`. + +**Test:** the test must fail before the fix. The build uses a Java 17 toolchain, where the default charset comes from the OS locale and is usually already UTF-8 on macOS and Linux CI. A plain unit test would therefore pass without the fix. Run it in a JVM whose default charset is not UTF-8: +1. In `build.gradle`, add a dedicated `Test` task `charsetTest`: + - `useJUnitPlatform { includeTags 'non-utf8-charset' }` + - `jvmArgs '-Dfile.encoding=ISO-8859-1'` + - the same `testClassesDirs` and `classpath` as `test` + - `check.dependsOn charsetTest` + + Exclude the tag from the normal `test` task. +2. The tagged test first asserts its precondition, and fails rather than skips if it does not hold: + ```groovy + assertNotEquals(StandardCharsets.UTF_8, Charset.defaultCharset(), 'charsetTest must run with a non-UTF-8 default charset') + ``` + Then it asserts that `auth('usér', 'pässword')` equals `'Bearer ' + Base64(UTF-8 bytes of 'usér:pässword')`. +3. Confirm once that the test fails on the unfixed code, where it would produce ISO-8859-1 bytes. Record that in the PR. +4. On JDK 18 and later, `file.encoding` values other than `UTF-8` and `COMPAT` are not officially supported (JEP 400). The precondition assertion makes such a change visible if the toolchain is raised later. At that point, switch the JVM argument to `-Dfile.encoding=COMPAT` and run CI with `LANG=C`/`LC_ALL=C`. + +--- + +## Release notes (`release.md`) + +Each phase's commit updates `release.md` in the same commit, adding bullets under the existing `## Ver 2.3.0, in progress` heading. Keep the current style: short bullets, with `Fix:` for bug fixes. Proposed text: + +```markdown +- Fix: Central Portal requests through `WsClient`/`CentralPortalClient` now have connect timeouts and an overall + caller deadline, covering uploads and deployment status checks for `release` and `releaseAll`, including responses that stall after headers. + New properties `httpConnectTimeoutSeconds` (default 30) and `httpRequestTimeoutSeconds` (default 1800), each + 1–86400 seconds. Status and other small calls through this client use the shorter of 60 seconds and the configured + request timeout; uploads use the full configured timeout. `releaseAll` uses the default timeouts; the new properties + apply only to `release`. Separate `PublicationStatus` and `ReleaseAllTask` HEAD availability checks are unchanged + and do not receive the new overall deadline. + (If mechanism A ships: `WsClient` now uses `java.net.http.HttpClient`; header names in the `headers` result + map are lower-case.) +- Fix: `release` status polling now tolerates transient errors (5xx, 429, timeouts, malformed responses) instead of + failing a deployment that was uploaded successfully; 401/403 still fail immediately. The number of status checks is + unchanged: one initial check plus up to `statusCheckRetries` further checks. +- Fix: when status polling times out, the error now says the deployment may still publish and how to check it, + instead of reporting "Failed to release". Replaced the obsolete "staging profile id" error message. +- Fix: a missing signature no longer stops later `.asc` files (including the POM signature) from being added to the bundle. +- Bundle entries are now named from the publication coordinates (`artifactId-version[-classifier].extension`), + so `base.archivesName` no longer has to match `artifactId`. +- The bundle now includes Gradle Module Metadata (`.module` + signature + checksums) when it was generated or restored from cache + in the current build, and all artifacts of the publication, not only `.jar` files. Skipping the + metadata task with `onlyIf` while the POM still advertises it is now an error. The combined release accepts + `.module` files in the manifest. Metadata and its signature are cleared before generation to prevent stale + output from being bundled; generation reruns or restores from cache whenever its task is scheduled. This affects + all publishing builds, including snapshots, and forces the publication signing task to re-sign every artifact; + gpg-agent/pinentry may prompt for a passphrase on every publish. +- `release` preserves JAR-producing packaging, including `bundle`, `maven-plugin`, and `ejb`. + Publications without a main JAR receive a clear packaging error before upload. +- `release` validation now requires a signature and checksums for every artifact in the bundle, not only for a fixed + set of JAR/POM files. +- Behaviour change: `bundle` now depends only on the configured publication's + `publishPublicationToMavenLocal` task instead of `publishToMavenLocal`, so other publications are no + longer installed to `~/.m2` as a side effect. +- Fix: `latestGithubRelease` now detects `origin` remotes that contain user info or tokens + (e.g. `https://x-access-token:...@github.com/...`), and `url=` entries written without spaces. +- Fix: Central Portal credentials are encoded as UTF-8 regardless of the platform charset. +- `publish.sh` validates Plugin Portal publication before the Central `release`, then publishes to the Portal, + stopping on the first failure. A Portal failure after Central release requires retrying only Portal publication. +``` + +Mapping: Phase 1 adds the first four bullets, Phase 2 adds the next five, and Phase 3 adds the last three. Drop the parenthetical in the first bullet if mechanism B ships. Before tagging 2.3.0, replace `in progress` with the release date. + +--- + +## Rollout checklist + +- [ ] Phase 1: timeout proof gate (T1–T7) results and the chosen mechanism (A or B) recorded; merged; matrix green +- [ ] CI: JDK axis (17 and 21) and `-PtestJavaVersion`/`test.javaLauncher` wiring implemented; TestKit runners use the matrix Gradle version; all four Gradle/JDK cells record and assert actual daemon JVM and Gradle versions, plus the direct timeout-test JVM version. +- [ ] Phase 2: Finding 4 verification results (POM marker in each of the 3 cases, stale-file case) and deterministic cleanup ordering checks recorded; merged; matrix green +- [ ] Shell harness: local missing-Bash skip verified; CI passes `-PrequireShellHarness=true` and missing Bash fails instead of skipping. +- [ ] Phase 3 merged +- [ ] `README.md`: new timeout properties (1–86400 s), the internal 60 s cap for status/small calls, supported runtime JDKs (17 and 21), the clarified meaning of `statusCheckRetries`, bundle contents and the signing requirement, jar-only packaging, `onlyIf` on the metadata task rejected, cleanup scope across all publishing builds of the selected publication and its publication-wide re-signing/gpg-agent/pinentry prompt cost, change to the `bundle` dependency +- [ ] `charsetTest` is part of `check`, and CI runs it (it runs through `./gradlew build`) +- [ ] `release.md`: the bullets from "Release notes" added phase by phase, with the behaviour changes (findings 4 and 6) called out; heading dated at release +- [ ] Manual smoke test against the real Central Portal with a throwaway version in a verified namespace, checking that the bundle contains `.module` and that polling survives a slow response + +Follow-up regression: the metadata cleanup dependency provider must use `orElse([])` when `mavenPublication` is unset. Plain `publish` and `publishToMavenLocal` must succeed without selecting a release publication, preserve metadata/signature bytes and retain an up-to-date signing task during configuration-cache reuse, and never schedule cleanup. `bundle` must still fail with the clear missing-publication error. diff --git a/req/verification/v2.3.0-fixes.md b/req/verification/v2.3.0-fixes.md new file mode 100644 index 0000000..ad193bf --- /dev/null +++ b/req/verification/v2.3.0-fixes.md @@ -0,0 +1,86 @@ +# v2.3.0 fix verification — 2026-10-10 + +Mechanism A (`HttpClient.sendAsync` plus a timed future wait and cancellation) passed the caller, connection-release and thread-hygiene proof gate. The fallback was not needed. + +## Local runtime matrix + +The build uses the Gradle 9.5.0 wrapper. `testGradleVersion` selects the distribution running the plugin in TestKit; `testJavaVersion` selects the test launcher and is asserted against the actual TestKit daemon JVM. The direct timeout tests also assert their JVM version. + +| TestKit Gradle | Actual daemon/test JDK | Verification | Result | +|---|---|---|---| +| 8.13 | 17.0.20.1+1-LTS | full `build`, plus final timeout proof | PASS | +| 9.4.0 | 17.0.20.1+1-LTS | full `build`, plus final timeout proof | PASS | +| 8.13 | 21.0.12.1+1-LTS | timeout, Central API/TestKit, compatibility and metadata suites | PASS | +| 9.4.0 | 21.0.12.1+1-LTS | timeout, Central API/TestKit, compatibility and metadata suites | PASS | + +The final full JDK 17 build ran 99 normal tests and the dedicated non-UTF-8 charset test. Each JDK 21 matrix selection ran 37 tests. The final strengthened timeout suite was rerun in all four cells after changing the concurrency probe to two responses that stall after headers. + +Commands: + +```sh +./gradlew build -PrequireShellHarness=true -PtestGradleVersion=8.13 -PtestJavaVersion=17 +./gradlew build -PrequireShellHarness=true -PtestGradleVersion=9.4.0 -PtestJavaVersion=17 +./gradlew test --tests '*WsClientTimeoutTest' --tests '*CentralUploadApiTest' \ + --tests '*GradleCompatibilityMatrixTest' --tests '*BundleMetadataTest' \ + -PtestGradleVersion=<8.13|9.4.0> -PtestJavaVersion=21 +./gradlew test --tests '*WsClientTimeoutTest' \ + -PtestGradleVersion=<8.13|9.4.0> -PtestJavaVersion=<17|21> +``` + +CI repeats the four cells with Temurin, installs compilation/runtime JDKs explicitly, and passes both installation paths to Gradle. Runtime assertions print `TESTKIT_RUNTIME Gradle=... JVM=...`; the timeout tests print the full JDK version. CI's full build requires the Bash harness. Each job uploads its JUnit XML reports, retaining the actual runtime versions and proof results even after the runner is removed. + +## HTTP and polling + +- Raw socket tests cover stalled headers, stalled bodies after headers, a 64 MiB stalled upload, and trickled responses. Caller assertions allow the effective deadline plus 5 s of scheduling slack (7 s for the 2 s deadline, 6 s for the 1 s small-call cap). Connection-release assertions retain the separate 5 s bound. +- After timeout, peers drain buffered upload data and observe EOF/reset within 5 s. No new non-daemon client threads remain. Concurrent body stalls and a subsequent healthy request do not serialize their deadlines. +- Redirects, HTTP/1.1, the default proxy selector, auth, multipart Content-Length and error bodies are covered. +- TestKit verifies a stalled release upload returns promptly. Single-release and combined polling use the small-call cap, record/retry timeouts and never upload again. Combined submission records `UPLOAD_ERROR` when its upload response times out. +- Polling tests assert one upload and exact status counts for zero retries, exhaustion, 502, 429, malformed JSON, recovery, and immediate friendly 401/403 failures. +- Both timeout ranges accept 1 and 86400 and reject 0, negative values, 86401 and Integer.MAX_VALUE. + +## Baseline regressions + +An isolated copy compiled the original HTTP implementation from the pre-change commit. Controlled 5 s stalls made the old caller take 5319 ms (body), 5015 ms (upload) and 5047 ms (trickle), failing the original 3 s test bound. This baseline was recorded before the review widened timing assertions to deadline + 5 s; it is historical evidence of the missing caller deadline, not a claim that a 5 s stall fails the wider assertion. Peers deliberately released these baseline requests after 5 s rather than leaving the verification process hung indefinitely. + +The dedicated JDK 17 ISO-8859-1 test failed with the original `getBytes()` auth encoding and passed with explicit UTF-8. The shell harness failed against the original single `clean build publishPlugins release` invocation and passed against the reordered script, including failure injection at each step and the sourced helper's unset-variable read. + +## Metadata and bundles + +Baseline observation on Gradle 9.5.0/JDK 17 confirmed: + +| Metadata generation | POM marker | Existing metadata/signature after a build without clean | +|---|---|---| +| enabled | present | present | +| disabled | absent | stale files remain | +| `onlyIf { false }` | present | stale files remain | + +The fixed implementation verifies enabled/disabled transitions and rejects stale `onlyIf` output on both supported Gradle versions and JDKs. Strict configuration-cache reuse, `--parallel`, and `--watch-fs --no-build-cache` all pass. Custom metadata paths are declared even when absent on the first run after clean. Changing only custom metadata or its signature makes bundle execute during configuration-cache reuse, including with producer tasks excluded to isolate input tracking. + +With `--build-cache`, metadata generation executes and produces the bundled bytes/signature; both tested Gradle versions report `Not made cacheable, yet`. No metadata-task `FROM_CACHE` claim is made. The task never consumes stale output after cleanup. + +Tests also cover lazy selection among two publications, extra artifact build dependencies, coordinate naming despite a different archivesName, later signatures after a missing first signature, POM/metadata inconsistencies before ZIP creation, missing/empty signatures and missing checksums, signed classified ZIPs, and explicitly listed combined `.module` artifacts. Unsupported WAR/pom packaging and missing module/ZIP signatures fail before upload, with zero server requests. + +## Policy-script cache relocation + +Two isolated copies of the same source snapshot ran the repository's `test` task with the same policy-test selection, Gradle/JDK and shared cache. The second copy was `FROM_CACHE`. + +- Whole-task key in both locations: `923a536ec067249841ed948c95d2eb3e`. +- Identical policy input fingerprint: `2b553efdf7793170bc10c370182bb9db`, reported as `IGNORED_PATH` in both locations. +- Generated plugin-under-test metadata contained different absolute checkout paths, but the runtime-classpath fingerprints were identical. No normalization was added. +- Changing only the policy script re-executed the repository's test task with key `f81b3eabfd44d4d3d27070e9888b80ab`. + +A minimal isolated Test fixture using the same argument provider, without TestKit plugin metadata, independently restored from cache after relocation. Its key changed from `163b487f3551d4a40b02ff9706df0492` to `00fe8efe474fb800a15553301a083360` after changing only script bytes; the provider fingerprint changed to `f62759950b02d02885df4f5f0d93e77c`. + +The policy tests simulate missing Bash with a nonexistent explicit interpreter: optional mode skips with a reason, required mode fails with a clear message. The root build uses a ValueSource for configuration-cache-compatible availability probing. + +## Release scope + +`releaseAll` receives default deadlines for CentralPortalClient uploads and deployment status checks. Its separate PublicationStatus and HEAD availability checks remain outside this deadline. Metadata cleanup affects all publishing builds of the selected publication and can re-sign every artifact/prompt through pinentry; README and release notes document this cost. + +No real Central Portal upload was performed: no throwaway version/verified namespace was allocated for that smoke test. The release heading remains “in progress”; merging, a real publishing smoke test and tagging remain release-owner steps. + +## Follow-up review verification + +`./gradlew build -PrequireShellHarness=true` passed after the review fixes, with 106 normal tests and the dedicated charset test, zero failures, and configuration-cache reuse. Regression coverage includes `bundle`, `maven-plugin`, `ejb` and custom JAR-producing packaging; pending/validating lifecycle messages; connect-timeout classification with preserved causes; case-sensitive `origin` subsection matching; and no Bash probe for unrelated tasks or their configuration-cache hits. The harness runs when scheduled. Dead helpers were removed and caller timing assertions now allow five seconds of CI scheduling slack. + +The unset-publication regression was reproduced on Gradle 8.13/JDK 17: both ordinary publishing tasks failed during metadata dependency resolution because the publication provider had no value. Adding `orElse([])` made both pass, including strict configuration-cache reuse and `--parallel`; cleanup was absent, metadata/signature bytes stayed unchanged, signing was `UP_TO_DATE`, and `bundle` retained its missing-publication diagnostic. Metadata generation itself reported `SUCCESS` on repeat builds, so no new metadata `UP_TO_DATE` guarantee is claimed. The subsequent full local build passed 108 normal tests plus the charset test with zero failures/skips and configuration-cache reuse. diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/BundleTask.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/BundleTask.groovy index 9df4d7c..1f81ba5 100644 --- a/src/main/groovy/se/alipsa/gradle/plugin/release/BundleTask.groovy +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/BundleTask.groovy @@ -5,7 +5,10 @@ import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory @@ -46,9 +49,23 @@ abstract class BundleTask extends DefaultTask { @PathSensitive(PathSensitivity.RELATIVE) abstract DirectoryProperty getPublicationDirectory() + @Internal + abstract MapProperty getArtifactEntries() + + @Input + List getArtifactEntryNames() { artifactEntries.get().keySet().toList().sort() } + @InputFiles - @PathSensitive(PathSensitivity.RELATIVE) - abstract ListProperty getArtifactFiles() + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getArtifactInputFiles() + + @Internal + abstract RegularFileProperty getModuleMetadataFile() + + @InputFiles + @Optional + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getModuleMetadataInput() @OutputFile abstract RegularFileProperty getBundleFile() @@ -67,7 +84,7 @@ abstract class BundleTask extends DefaultTask { String artifactIdValue = artifactId.get() String versionValue = version.get() String publicationNameValue = publicationName.get() - List artifacts = artifactFiles.get() + Map artifacts = artifactEntries.get() File publicationDir = publicationDirectory.get().asFile File pomFile = new File(publicationDir, "pom-default.xml") @@ -75,6 +92,20 @@ abstract class BundleTask extends DefaultTask { throw new GradleException("Expected POM file at ${pomFile.absolutePath} but it was not found. Has 'publishToMavenLocal' completed successfully?") } + File metadata = moduleMetadataFile.orNull?.asFile + boolean marker = pomFile.getText('UTF-8').contains('published-with-gradle-metadata') + if (metadata?.exists() && !marker) { + throw new GradleException('Module metadata state is inconsistent with the POM') + } + if (marker && !metadata?.exists()) { + throw new GradleException('The POM advertises Gradle metadata, but no metadata was produced in this build. Disable the metadata task (enabled = false) instead of skipping it, or let it run.') + } + if (metadata?.exists()) { + artifacts = new LinkedHashMap(artifacts) + String metadataName = "${artifactIdValue}-${versionValue}.module" + if (artifacts.put(metadataName, metadata) != null) throw new GradleException("Duplicate bundle entry: ${metadataName}") + } + // Create the bundle zip createBundleZip(zipFile, groupIdValue, artifactIdValue, versionValue, artifacts, publicationDir) @@ -84,7 +115,7 @@ abstract class BundleTask extends DefaultTask { } private void createBundleZip(File zipFile, String groupIdValue, String artifactIdValue, - String versionValue, List artifacts, File publicationDir) { + String versionValue, Map artifacts, File publicationDir) { String mavenPathPrefix = "${groupIdValue.replace('.', '/')}/${artifactIdValue}/${versionValue}/" if (!publicationDir.exists()) { @@ -96,14 +127,12 @@ abstract class BundleTask extends DefaultTask { try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile))) { // 1. Add all publication artifacts (e.g. JARs and their signatures) - artifacts.each { File file -> + artifacts.each { String name, File file -> if (!file.exists()) { logger.warn("Artifact file does not exist: $file") return } - String name = file.name - if (!name.endsWith('.jar')) return String zipEntryName = mavenPathPrefix + name logger.debug("Adding file: $zipEntryName") @@ -111,9 +140,9 @@ abstract class BundleTask extends DefaultTask { zipOut << file.bytes zipOut.closeEntry() - missingFilesDetected = missingFilesDetected || addFileToZip(file, ".asc", mavenPathPrefix, zipOut) + missingFilesDetected = addFileToZip(new File(file.absolutePath + ".asc"), zipEntryName + ".asc", zipOut) || missingFilesDetected CHECKSUM_ALGOS.each { String algo -> - addChecksumToZip(file, algo, "${mavenPathPrefix}${file.name}.${checksumExtension(algo)}", zipOut) + addChecksumToZip(file, algo, "${zipEntryName}.${checksumExtension(algo)}", zipOut) } } @@ -126,7 +155,7 @@ abstract class BundleTask extends DefaultTask { zipOut.putNextEntry(new ZipEntry(pomEntryName)) zipOut << pomFile.bytes zipOut.closeEntry() - missingFilesDetected = missingFilesDetected || addFileToZip(new File(publicationDir, "pom-default.xml.asc"), pomEntryName + ".asc", zipOut) + missingFilesDetected = addFileToZip(new File(publicationDir, "pom-default.xml.asc"), pomEntryName + ".asc", zipOut) || missingFilesDetected CHECKSUM_ALGOS.each { String algo -> addChecksumToZip(pomFile, algo, "${pomEntryName}.${checksumExtension(algo)}", zipOut) } @@ -153,12 +182,6 @@ abstract class BundleTask extends DefaultTask { } } - private boolean addFileToZip(File baseFile, String suffix, String mavenPathPrefix, ZipOutputStream zipOut) { - File sourceFile = new File(baseFile.absolutePath + suffix) - String targetPath = mavenPathPrefix + baseFile.name + suffix - addFileToZip(sourceFile, targetPath, zipOut) - } - private void addChecksumToZip(File sourceFile, String algo, String targetPath, ZipOutputStream zipOut) throws IOException { logger.debug("Adding generated checksum: ${targetPath}") String hash = checksumFor(sourceFile, algo) diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/BundleValidator.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/BundleValidator.groovy index 2af62f5..9199047 100644 --- a/src/main/groovy/se/alipsa/gradle/plugin/release/BundleValidator.groovy +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/BundleValidator.groovy @@ -40,18 +40,27 @@ class BundleValidator { String basePath = "${groupId.replace('.', '/')}/${artifactId}/${version}/" String artifactPrefix = "${artifactId}-${version}" - List requiredEntries = [ - "${basePath}${artifactPrefix}.pom", - "${basePath}${artifactPrefix}.pom.asc", - "${basePath}${artifactPrefix}.jar", - "${basePath}${artifactPrefix}.jar.asc", - "${basePath}${artifactPrefix}-sources.jar", - "${basePath}${artifactPrefix}-sources.jar.asc", - "${basePath}${artifactPrefix}-javadoc.jar", - "${basePath}${artifactPrefix}-javadoc.jar.asc" - ] + List requiredEntries = ['.pom', '.jar', '-sources.jar', '-javadoc.jar'].collect { + "${basePath}${artifactPrefix}${it}".toString() + } try (ZipFile zip = new ZipFile(zipFile)) { + ZipEntry pomEntry = zip.getEntry("${basePath}${artifactPrefix}.pom") + if (pomEntry != null) { + GPathResult pom + try { + pom = new XmlSlurper(false, false).parseText(zip.getInputStream(pomEntry).getText('UTF-8')) + } catch (Exception failure) { + errors.add("Failed to parse POM ${pomEntry.name}: ${failure.message}") + return + } + String packaging = pom.packaging.text() ?: 'jar' + if (packaging != 'jar' && zip.getEntry("${basePath}${artifactPrefix}.jar") == null) { + errors.add("Packaging '${packaging}' is not supported by the release task in this version; a main JAR is required (additional artifacts are supported)" + + (packaging == 'pom' ? '. For pom-packaged publications such as BOMs, use the combined release (releaseAll)' : '')) + return + } + } Set names = zip.entries().collect { ZipEntry e -> e.name } as Set requiredEntries.each { String requiredEntry -> if (!names.contains(requiredEntry)) { @@ -59,6 +68,16 @@ class BundleValidator { } } + names.findAll { String name -> + !name.endsWith('/') && !['.asc', '.md5', '.sha1', '.sha256', '.sha512'].any { name.endsWith(it) } + }.each { String name -> + ['.asc', '.md5', '.sha1'].each { String suffix -> + ZipEntry companion = zip.getEntry(name + suffix) + String kind = suffix == '.asc' ? 'signature' : 'checksum' + if (companion == null) errors.add("Missing ${kind} for bundle entry: ${name}") + else if (zip.getInputStream(companion).withCloseable { it.read() } == -1) errors.add("Empty ${kind} for bundle entry: ${name}") + } + } validateFileNameConsistency(names, artifactPrefix, artifactId, errors) validatePom(zip, "${basePath}${artifactPrefix}.pom", errors) } catch (IOException e) { @@ -75,7 +94,7 @@ class BundleValidator { if (!fileName.startsWith(artifactPrefix)) { errors.add( """Filename mismatch: '${fileName}' does not start with '${artifactPrefix}' - -> Make sure base.archivesName = '${artifactId}' matches artifactId = '${artifactId}'""".stripIndent() + -> Check that the publication and artifact coordinates match '${artifactId}'""".stripIndent() ) } } @@ -89,6 +108,19 @@ class BundleValidator { } String pomText = zip.getInputStream(pomEntry).getText('UTF-8') + if (pomText.contains('published-with-gradle-metadata')) { + String modulePath = expectedPomPath.substring(0, expectedPomPath.length() - '.pom'.length()) + '.module' + [modulePath, modulePath + '.asc'].each { String path -> + // A present module's signature is already checked by the generic rule. + if (path.endsWith('.asc') && zip.getEntry(modulePath) != null) return + ZipEntry entry = zip.getEntry(path) + if (entry == null || entry.isDirectory()) { + errors.add("Missing required bundle entry advertised by POM Gradle metadata marker: ${path}") + } else if (zip.getInputStream(entry).withCloseable { it.read() } == -1) { + errors.add("Empty required bundle entry advertised by POM Gradle metadata marker: ${path}") + } + } + } validatePomMetadata(pomText, expectedPomPath, errors) } diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/CentralPortalClient.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/CentralPortalClient.groovy index 025ad37..90d15f7 100644 --- a/src/main/groovy/se/alipsa/gradle/plugin/release/CentralPortalClient.groovy +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/CentralPortalClient.groovy @@ -1,5 +1,6 @@ package se.alipsa.gradle.plugin.release +import java.nio.charset.StandardCharsets import groovy.json.JsonSlurper import groovy.transform.CompileStatic import org.gradle.api.GradleException @@ -7,7 +8,7 @@ import org.gradle.api.logging.Logger /** * HTTP client for interacting with the Central Portal API. - * This class is configuration cache compatible - it only stores serializable values. + * Instances are created during task execution, outside configuration-cache state. */ @CompileStatic class CentralPortalClient extends WsClient { @@ -19,6 +20,20 @@ class CentralPortalClient extends WsClient { String password CentralPortalClient(Logger logger, String nexusUrl, String userName, String password) { + this(logger, nexusUrl, userName, password, 30, 1800) + } + + CentralPortalClient(Logger logger, String nexusUrl, String userName, String password, int connectSeconds, int requestSeconds) { + super(connectSeconds, requestSeconds) + this.log = logger + this.publishUrl = nexusUrl ?: CENTRAL_PORTAL_URL + this.userName = userName + this.password = password + } + + protected CentralPortalClient(Logger logger, String nexusUrl, String userName, String password, + java.time.Duration connectTimeout, long requestMillis, long smallCallMillis) { + super(connectTimeout, requestMillis, smallCallMillis) this.log = logger this.publishUrl = nexusUrl ?: CENTRAL_PORTAL_URL this.userName = userName @@ -31,7 +46,7 @@ class CentralPortalClient extends WsClient { } String auth(String username, String password) { - return "Bearer " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes()) + return "Bearer " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)) } /** @@ -52,6 +67,11 @@ class CentralPortalClient extends WsClient { Map result = postMultipart(urlString, file, userName, password) log.debug("Upload result: $result") return (result.get(BODY) as String)?.trim() + } catch (SocketTimeoutException e) { + if (e.cause instanceof java.net.http.HttpConnectTimeoutException) { + throw new GradleException("Could not connect to Central Portal at ${publishUrl} during upload. Check the URL, network connectivity, and proxy settings. ${e.message}", e) + } + throw new GradleException("Upload timed out; the deployment outcome is unknown. The bundle may already have reached Central Portal. Check https://central.sonatype.com/publishing/deployments before retrying to avoid a duplicate deployment. ${e.message}", e) } catch (WsException e) { throw createUploadException(e) } @@ -93,7 +113,7 @@ class CentralPortalClient extends WsClient { return status?.get('deploymentState') as String } - private GradleException createUploadException(WsException e) { + GradleException createUploadException(WsException e) { String body = e.body?.trim() switch (e.statusCode) { case 401: diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/ClearModuleMetadataTask.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/ClearModuleMetadataTask.groovy new file mode 100644 index 0000000..e0597e9 --- /dev/null +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/ClearModuleMetadataTask.groovy @@ -0,0 +1,25 @@ +package se.alipsa.gradle.plugin.release + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +@DisableCachingByDefault(because = 'Deletes stale publication metadata before generation') +abstract class ClearModuleMetadataTask extends DefaultTask { + @Internal abstract RegularFileProperty getMetadataFile() + + ClearModuleMetadataTask() { + destroyables.register(metadataFile.map { it.asFile }, metadataFile.map { new File(it.asFile.path + '.asc') }) + } + + @TaskAction void clear() { + if (!metadataFile.present) return + File metadata = metadataFile.get().asFile + [metadata, new File(metadata.path + '.asc')].each { File file -> + if (file.exists() && !file.delete()) throw new GradleException("Cannot delete stale metadata: ${file}") + } + } +} diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/CombinedBundle.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/CombinedBundle.groovy index 25c3040..7d84747 100644 --- a/src/main/groovy/se/alipsa/gradle/plugin/release/CombinedBundle.groovy +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/CombinedBundle.groovy @@ -23,7 +23,7 @@ class CombinedBundle { } if (!(publication.files as Set).containsAll(required)) throw new IllegalArgumentException('Manifest omits required publication artifacts') publication.files.each { String name -> - if (!(name ==~ /[A-Za-z0-9_.+-]+/) || !name.startsWith(prefix) || !(name.endsWith('.jar') || name.endsWith('.pom'))) { + if (!(name ==~ /[A-Za-z0-9_.+-]+/) || !name.startsWith(prefix) || !(name.endsWith('.jar') || name.endsWith('.pom') || name.endsWith('.module'))) { throw new IllegalArgumentException('Unsafe or unsupported artifact filename') } String path = "${coordinate.path()}/${name}" diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/NexusReleasePlugin.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/NexusReleasePlugin.groovy index 2b014a4..5db071f 100644 --- a/src/main/groovy/se/alipsa/gradle/plugin/release/NexusReleasePlugin.groovy +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/NexusReleasePlugin.groovy @@ -3,6 +3,10 @@ package se.alipsa.gradle.plugin.release import groovy.transform.CompileStatic import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.GradleException +import org.gradle.api.publish.tasks.GenerateModuleMetadata +import org.gradle.api.publish.maven.MavenArtifact +import groovy.transform.PackageScope import org.gradle.api.publish.PublishingExtension import org.gradle.api.publish.maven.MavenPublication import org.gradle.api.tasks.TaskProvider @@ -24,8 +28,26 @@ class NexusReleasePlugin implements Plugin { project.pluginManager.apply('signing') project.pluginManager.apply('maven-publish') + def selectedPublication = extension.mavenPublication.orElse(project.provider { + throw new GradleException('nexusReleasePlugin.mavenPublication must be set') + }) + TaskProvider clear = project.tasks.register('clearModuleMetadata', ClearModuleMetadataTask) + project.tasks.withType(GenerateModuleMetadata).configureEach { GenerateModuleMetadata metadata -> + metadata.dependsOn(extension.mavenPublication.map { MavenPublication pub -> + metadata.name == "generateMetadataFileFor${pub.name.capitalize()}Publication" ? [clear] : [] + }.orElse([])) + } + // Resolve configured output paths as detached Files, avoiding a cleanup -> generation cycle. + def metadataPath = selectedPublication.map { MavenPublication pub -> + GenerateModuleMetadata metadata = project.tasks.findByName("generateMetadataFileFor${pub.name.capitalize()}Publication") as GenerateModuleMetadata + metadata == null ? null : metadata.outputFile.get().asFile + } + clear.configure { it.metadataFile.set(project.layout.file(metadataPath)) } + TaskProvider bundleTask = project.tasks.register('bundle', BundleTask) { BundleTask task -> - task.dependsOn "publishToMavenLocal" + task.dependsOn(selectedPublication.map { MavenPublication pub -> + "publish${pub.name.capitalize()}PublicationToMavenLocal".toString() + }) // Wire providers to extract publication data at configuration time task.groupId.set(extension.mavenPublication.map { it.groupId }) @@ -37,8 +59,20 @@ class NexusReleasePlugin implements Plugin { extension.mavenPublication.map { "publications/${it.name}" } )) - task.artifactFiles.set(extension.mavenPublication.map { pub -> - pub.artifacts.collect { it.file } + task.artifactEntries.set(selectedPublication.map { MavenPublication pub -> + Map entries = [:] + pub.artifacts.each { MavenArtifact artifact -> + String classifier = artifact.classifier ? "-${artifact.classifier}" : '' + String name = "${pub.artifactId}-${pub.version}${classifier}.${artifact.extension}" + if (entries.put(name, artifact.file) != null) throw new GradleException("Duplicate bundle entry: ${name}") + } + entries + }) + task.artifactInputFiles.from(task.artifactEntries.map { entries -> entries.values().collectMany { File file -> [file, new File(file.path + '.asc')] } }) + task.artifactInputFiles.builtBy(selectedPublication.map { it.artifacts }) + task.moduleMetadataFile.set(project.layout.file(metadataPath)) + task.moduleMetadataInput.from(metadataPath.map { File file -> + [file, new File(file.path + '.asc')] }) task.bundleFile.set(project.layout.buildDirectory.file( @@ -62,6 +96,8 @@ class NexusReleasePlugin implements Plugin { task.artifactId.set(extension.mavenPublication.map { it.artifactId }) // Wire credentials from extension + task.httpConnectTimeoutSeconds.set(extension.httpConnectTimeoutSeconds) + task.httpRequestTimeoutSeconds.set(extension.httpRequestTimeoutSeconds) task.nexusUrl.set(extension.nexusUrl) task.userName.set(extension.userName) task.password.set(extension.password) @@ -168,22 +204,24 @@ class NexusReleasePlugin implements Plugin { } } - private static String parseOriginUrlFromConfig(String config) { + @PackageScope + static String parseOriginUrlFromConfig(String config) { boolean inOriginSection = false for (String line : config.split('\n')) { String trimmed = line.trim() - if (trimmed == '[remote "origin"]') { + if (trimmed ==~ /(?i:\[remote) "origin"\]/) { inOriginSection = true } else if (trimmed.startsWith('[')) { inOriginSection = false - } else if (inOriginSection && trimmed.startsWith('url =')) { + } else if (inOriginSection && trimmed ==~ /(?i)url\s*=.*/) { return trimmed.substring(trimmed.indexOf('=') + 1).trim() } } return null } - private static String parseGithubCoordinates(String remoteUrl, String apiBaseUrl) { + @PackageScope + static String parseGithubCoordinates(String remoteUrl, String apiBaseUrl) { if (!remoteUrl) return null String apiHost = URI.create(apiBaseUrl).host ?: 'api.github.com' // For api.github.com the remote host is github.com; for Enterprise the api and git hosts match @@ -194,7 +232,7 @@ class NexusReleasePlugin implements Plugin { Matcher sshUrl = Pattern.compile('^ssh://(?:[^@]+@)?([^/:]+)(?::\\d+)?/(.+?)(?:\\.git)?$').matcher(remoteUrl) if (sshUrl.matches() && sshUrl.group(1) == expectedRemoteHost) return sshUrl.group(2) // Strip port from remote host so http://localhost:8080/x matches API host 'localhost' - Matcher https = Pattern.compile('^https?://([^/:]+)(?::\\d+)?/(.+?)(?:\\.git)?$').matcher(remoteUrl) + Matcher https = Pattern.compile('^https?://(?:[^@/]+@)?([^/:]+)(?::\\d+)?/(.+?)(?:\\.git)?/?$').matcher(remoteUrl) if (https.matches() && https.group(1) == expectedRemoteHost) return https.group(2) return null } diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/NexusReleasePluginExtension.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/NexusReleasePluginExtension.groovy index 7fe3fdd..bc6edcb 100644 --- a/src/main/groovy/se/alipsa/gradle/plugin/release/NexusReleasePluginExtension.groovy +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/NexusReleasePluginExtension.groovy @@ -11,6 +11,8 @@ import javax.inject.Inject @CompileStatic class NexusReleasePluginExtension { + final Property httpConnectTimeoutSeconds + final Property httpRequestTimeoutSeconds final Property nexusUrl final Property metadataBaseUrl final Property userName @@ -31,6 +33,8 @@ class NexusReleasePluginExtension { @Inject NexusReleasePluginExtension(ObjectFactory objects) { + httpConnectTimeoutSeconds = objects.property(Integer).convention(30) + httpRequestTimeoutSeconds = objects.property(Integer).convention(1800) userName = objects.property(String) password = objects.property(String) mavenPublication = objects.property(MavenPublication) @@ -44,6 +48,9 @@ class NexusReleasePluginExtension { githubRepo = objects.property(String) } + void setHttpConnectTimeoutSeconds(Integer seconds) { httpConnectTimeoutSeconds.set(seconds) } + void setHttpRequestTimeoutSeconds(Integer seconds) { httpRequestTimeoutSeconds.set(seconds) } + void setNexusUrl(String url) { nexusUrl.set(url) } diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/ReleaseTask.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/ReleaseTask.groovy index 99bb995..3030715 100644 --- a/src/main/groovy/se/alipsa/gradle/plugin/release/ReleaseTask.groovy +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/ReleaseTask.groovy @@ -58,7 +58,15 @@ abstract class ReleaseTask extends DefaultTask { @Input abstract Property getInitialStatusCheckDelaySeconds() + @Input + abstract Property getHttpConnectTimeoutSeconds() + + @Input + abstract Property getHttpRequestTimeoutSeconds() + ReleaseTask() { + httpConnectTimeoutSeconds.convention(30) + httpRequestTimeoutSeconds.convention(1800) group = 'publishing' description = 'Create and upload a release bundle to Nexus' } @@ -91,16 +99,11 @@ abstract class ReleaseTask extends DefaultTask { } String projectNameValue = projectName.get() - CentralPortalClient releaseClient = new CentralPortalClient( - logger, - nexusUrl.get(), - userNameValue, - passwordValue - ) + CentralPortalClient releaseClient = createClient(userNameValue, passwordValue) String deploymentId = releaseClient.upload(zipFile) if (!deploymentId) { - throw new GradleException("Failed to find the staging profile id") + throw new GradleException("Central Portal upload returned no deployment id") } int initialDelaySeconds = nonNegative(initialStatusCheckDelaySeconds.get(), 'initialStatusCheckDelaySeconds') @@ -109,14 +112,26 @@ abstract class ReleaseTask extends DefaultTask { logger.lifecycle("Project $projectNameValue published with deploymentId = $deploymentId, waiting ${initialDelaySeconds}s before checking...") sleepSeconds(initialDelaySeconds) - Map status = releaseClient.getDeploymentStatus(deploymentId) - String state = releaseClient.getDeploymentState(status) - - while (!['PUBLISHING', 'PUBLISHED', 'FAILED'].contains(state) && retries-- > 0) { - logger.lifecycle("Deploy status is $state") - sleepSeconds(intervalSeconds) - status = releaseClient.getDeploymentStatus(deploymentId) - state = releaseClient.getDeploymentState(status) + Map status = null + String state = null + IOException lastError = null + int checks = 1 + retries + for (int check = 1; check <= checks; check++) { + if (check > 1) sleepSeconds(intervalSeconds) + lastError = null + try { + status = releaseClient.getDeploymentStatus(deploymentId) + state = releaseClient.getDeploymentState(status) + } catch (IOException e) { + if (e instanceof WsException && ((WsException) e).statusCode in [401, 403]) { + throw new GradleException("Status check for deployment ${deploymentId} was rejected (HTTP ${((WsException) e).statusCode}); the upload succeeded. Check https://central.sonatype.com/publishing/deployments for details.", e) + } + state = null + lastError = e + logger.lifecycle("Status check failed (check ${check}/${checks}): ${e.message}") + } + if (state in ['PUBLISHING', 'PUBLISHED', 'FAILED']) break + if (lastError == null) logger.lifecycle("Deploy status is ${state} (check ${check}/${checks})") } if (state == 'PUBLISHING') { logger.lifecycle("Project $projectNameValue uploaded and validated successfully!") @@ -127,10 +142,16 @@ abstract class ReleaseTask extends DefaultTask { logFailedDeployment(status) throw new GradleException("Failed to release $projectNameValue with deploymentId $deploymentId") } else { - throw new GradleException("Failed to release $projectNameValue with deploymentId $deploymentId") + throw new GradleException("Deployment ${deploymentId} for ${projectNameValue} is still ${state ?: 'unknown'} after ${checks} status checks. It may still publish; check https://central.sonatype.com/publishing/deployments or increase statusCheckRetries / statusCheckIntervalSeconds." + (lastError ? " Last error: ${lastError.message}" : '')) } } + protected CentralPortalClient createClient(String user, String pass) { + new CentralPortalClient(logger, nexusUrl.get(), user, pass, + TimeoutSettings.validate('httpConnectTimeoutSeconds', httpConnectTimeoutSeconds.get()), + TimeoutSettings.validate('httpRequestTimeoutSeconds', httpRequestTimeoutSeconds.get())) + } + private static int nonNegative(int value, String propertyName) { if (value < 0) { throw new GradleException("${propertyName} must be greater than or equal to 0") diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/TimeoutSettings.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/TimeoutSettings.groovy new file mode 100644 index 0000000..b6eeba5 --- /dev/null +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/TimeoutSettings.groovy @@ -0,0 +1,12 @@ +package se.alipsa.gradle.plugin.release + +import org.gradle.api.GradleException + +class TimeoutSettings { + static int validate(String name, int value) { + if (value < 1 || value > 86400) { + throw new GradleException("${name} must be between 1 and 86400 seconds") + } + return value + } +} diff --git a/src/main/groovy/se/alipsa/gradle/plugin/release/WsClient.groovy b/src/main/groovy/se/alipsa/gradle/plugin/release/WsClient.groovy index 80451f5..f59ee37 100644 --- a/src/main/groovy/se/alipsa/gradle/plugin/release/WsClient.groovy +++ b/src/main/groovy/se/alipsa/gradle/plugin/release/WsClient.groovy @@ -1,123 +1,118 @@ package se.alipsa.gradle.plugin.release import groovy.transform.CompileStatic -import org.gradle.api.logging.Logger -import org.gradle.api.logging.Logging +import groovy.transform.PackageScope +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException @CompileStatic abstract class WsClient { - static final String APPLICATION_JSON = 'application/json' - static final String BODY = 'body' - static final String RESPONSE_CODE = 'responseCode' - static final String HEADERS = 'headers' - private static final Logger LOGGER = Logging.getLogger(WsClient) + static final String APPLICATION_JSON = 'application/json' + static final String BODY = 'body' + static final String RESPONSE_CODE = 'responseCode' + static final String HEADERS = 'headers' + final HttpClient httpClient + final long requestDeadlineMillis + final long smallCallCapMillis - Map get(String urlString, String username, String password) throws IOException { - URL url = new URL(urlString) - HttpURLConnection conn = (HttpURLConnection) url.openConnection() - try { - conn.setRequestMethod("GET") - conn.setRequestProperty("Accept", APPLICATION_JSON) - conn.setRequestProperty("Authorization", auth(username, password)) - conn.connect() - int responseCode = conn.getResponseCode() - var responseHeaders = conn.getHeaderFields() - ensureSuccess(responseCode, '', conn) - String body = readBody(conn.getInputStream()) - return [(BODY): body, (RESPONSE_CODE): responseCode, (HEADERS): responseHeaders] - } finally { - conn.disconnect() + WsClient(int connectSeconds = 30, int requestSeconds = 1800) { + this(Duration.ofSeconds(TimeoutSettings.validate('httpConnectTimeoutSeconds', connectSeconds)), + TimeoutSettings.validate('httpRequestTimeoutSeconds', requestSeconds) * 1000L, 60000L) } - } - Map post(String urlString, byte[] payload, String username, String password, String contentType=APPLICATION_JSON) throws IOException { - URL url = new URL(urlString) - HttpURLConnection conn = (HttpURLConnection) url.openConnection() - try { - conn.setRequestMethod('POST') - conn.setRequestProperty('Content-Type', contentType) - conn.setRequestProperty("Accept", APPLICATION_JSON) - conn.setRequestProperty("Authorization", auth(username, password)) - conn.setDoOutput(true) - conn.connect() - if (payload) { - OutputStream os = conn.getOutputStream() - os.write(payload) - os.flush() - os.close() - } - int responseCode = conn.getResponseCode() - var responseHeaders = conn.getHeaderFields() - ensureSuccess(responseCode, '', conn) - String body = readBody(conn.getInputStream()) - return [(BODY): body, (RESPONSE_CODE): responseCode, (HEADERS): responseHeaders] - } finally { - conn.disconnect() + // Millisecond deadlines keep the network proof tests fast without changing public settings. + protected WsClient(Duration connectTimeout, long deadlineMillis, long smallCallCapMillis) { + this.requestDeadlineMillis = deadlineMillis + this.smallCallCapMillis = smallCallCapMillis + HttpClient.Builder builder = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL).connectTimeout(connectTimeout) + ProxySelector proxy = ProxySelector.getDefault() + if (proxy != null) builder.proxy(proxy) + httpClient = builder.build() } - } - Map postMultipart(String urlString, File file, String fieldName = "bundle", String username, String password) throws IOException { - def boundary = "----WebKitFormBoundary${UUID.randomUUID().toString().replaceAll('-', '')}" - def lineEnd = "\r\n" - def twoHyphens = "--" - - def conn = new URL(urlString).openConnection() as HttpURLConnection - conn.setRequestMethod("POST") - conn.setDoOutput(true) - conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=${boundary}") - conn.setRequestProperty("Accept", "application/json") - conn.setRequestProperty("Authorization", auth(username, password)) - - byte[] prefix = ("${twoHyphens}${boundary}${lineEnd}" + - "Content-Disposition: form-data; name=\"${fieldName}\"; filename=\"${file.name}\"${lineEnd}" + - "Content-Type: application/zip${lineEnd}${lineEnd}").getBytes('UTF-8') - byte[] suffix = "${lineEnd}${twoHyphens}${boundary}--${lineEnd}".getBytes('UTF-8') - conn.setFixedLengthStreamingMode(file.length() + prefix.length + suffix.length) - try { - conn.connect() - try (OutputStream out = conn.outputStream; InputStream input = new FileInputStream(file)) { - out.write(prefix) - input.transferTo(out) - out.write(suffix) - } - - int responseCode = conn.responseCode - ensureSuccess(responseCode, '', conn) - String body = readBody(conn.inputStream) - return [(RESPONSE_CODE): responseCode, (BODY): body, (HEADERS): conn.headerFields] - } finally { - conn.disconnect() + long effectiveDeadlineMillis(boolean upload) { + upload ? requestDeadlineMillis : Math.min(requestDeadlineMillis, smallCallCapMillis) } - } - private static void ensureSuccess(int responseCode, String body, HttpURLConnection conn) { - if (responseCode >= 200 && responseCode < 300) { - return + Map get(String url, String username, String password) throws IOException { + send(url, HttpRequest.BodyPublishers.noBody(), 'GET', APPLICATION_JSON, username, password, false) } - String errorBody = readBody(conn.getErrorStream()) - if (!errorBody) { - errorBody = body + + Map post(String url, byte[] payload, String username, String password, + String contentType = APPLICATION_JSON) throws IOException { + send(url, HttpRequest.BodyPublishers.ofByteArray(payload ?: new byte[0]), 'POST', contentType, username, password, false) } - LOGGER.lifecycle("HTTP call failed with status ${responseCode}") - if (errorBody) { - LOGGER.lifecycle(errorBody) + + Map postMultipart(String url, File file, String fieldName = 'bundle', + String username, String password) throws IOException { + String boundary = "----WebKitFormBoundary${UUID.randomUUID().toString().replace('-', '')}" + byte[] prefix = ("--${boundary}\r\n" + + "Content-Disposition: form-data; name=\"${fieldName}\"; filename=\"${file.name}\"\r\n" + + "Content-Type: application/zip\r\n\r\n").getBytes(StandardCharsets.UTF_8) + byte[] suffix = "\r\n--${boundary}--\r\n".getBytes(StandardCharsets.UTF_8) + send(url, HttpRequest.BodyPublishers.concat(HttpRequest.BodyPublishers.ofByteArray(prefix), + HttpRequest.BodyPublishers.ofFile(file.toPath()), HttpRequest.BodyPublishers.ofByteArray(suffix)), + 'POST', "multipart/form-data; boundary=${boundary}", username, password, true) } - throw new WsException(responseCode, errorBody) - } - private static String readBody(InputStream stream) { - if (stream == null) { - return '' + private Map send(String url, HttpRequest.BodyPublisher body, String method, + String contentType, String username, String password, boolean upload) throws IOException { + long deadline = effectiveDeadlineMillis(upload) + long start = System.nanoTime() + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofMillis(deadline)) + .header('Accept', APPLICATION_JSON).header('Content-Type', contentType) + .header('Authorization', auth(username, password)).method(method, body).build() + def future = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + HttpResponse response + try { + long remaining = Math.max(1L, deadline - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)) + response = future.get(remaining, TimeUnit.MILLISECONDS) + } catch (TimeoutException e) { + future.cancel(true) + throw new SocketTimeoutException("Request exceeded overall deadline of ${deadline / 1000.0}s: ${url}") + } catch (ExecutionException e) { + Throwable cause = e.cause + if (cause instanceof java.net.http.HttpTimeoutException) { + throw timeoutFailure((java.net.http.HttpTimeoutException) cause, deadline, url) + } + if (cause instanceof IOException) throw (IOException) cause + throw new IOException('HTTP request failed', cause) + } catch (InterruptedException e) { + future.cancel(true) + Thread.currentThread().interrupt() + throw new IOException('HTTP request interrupted', e) + } + if (response.request().method() != method) { + // Report the redirect rather than a target's error (for example a GET's 401). + HttpResponse redirect = response + while (redirect.previousResponse().isPresent()) { + redirect = redirect.previousResponse().get() + if (redirect.request().method() == method) break + } + throw new WsException(redirect.statusCode(), + "${method} ${url} was redirected to ${response.uri()} and converted to ${response.request().method()}") + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new WsException(response.statusCode(), response.body()) + } + [(BODY): response.body(), (RESPONSE_CODE): response.statusCode(), (HEADERS): response.headers().map()] as Map } - try (InputStream is = stream; BufferedReader br = new BufferedReader(new InputStreamReader(is))) { - StringBuilder writer = new StringBuilder() - String line - while ((line = br.readLine()) != null) { - writer.append(line).append('\n') - } - return writer.toString() + + @PackageScope + static SocketTimeoutException timeoutFailure(java.net.http.HttpTimeoutException cause, long deadline, String url) { + String message = cause instanceof java.net.http.HttpConnectTimeoutException ? + "Connect timed out: ${url}" : "Request exceeded overall deadline of ${deadline / 1000.0}s: ${url}" + SocketTimeoutException timeout = new SocketTimeoutException(message) + timeout.initCause(cause) + timeout } - } - abstract String auth(String username, String password) + abstract String auth(String username, String password) } diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/BundleMetadataTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/BundleMetadataTest.groovy new file mode 100644 index 0000000..39e0286 --- /dev/null +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/BundleMetadataTest.groovy @@ -0,0 +1,171 @@ +package se.alipsa.gradle.plugin.release + +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.zip.ZipFile +import static org.junit.jupiter.api.Assertions.* + +@Timeout(180) +class BundleMetadataTest { + static def run(File dir, String... arguments) { + MatrixRunner.create().withProjectDir(dir).withPluginClasspath().withArguments(arguments as List).forwardOutput().build() + } + static def fail(File dir, String... arguments) { + MatrixRunner.create().withProjectDir(dir).withPluginClasspath().withArguments(arguments as List).forwardOutput().buildAndFail() + } + static byte[] entry(File dir, String suffix) { + new ZipFile(new File(dir, 'build/zips/test-project-1.0.0-bundle.zip')).withCloseable { zip -> + zip.getInputStream(zip.getEntry('com/example/test-project/1.0.0/test-project-1.0.0' + suffix)).bytes + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = ['publishToMavenLocal', 'publish']) + void ordinaryPublishingDoesNotRequireSelectedPublication(String publishingTask) { + String script = TestFixtures.createBuildScript().replace('mavenPublication = publishing.publications.maven', '') + ''' + publishing.repositories.maven { name = 'fixture'; url = layout.projectDirectory.dir('repository') } +''' + File dir = TestFixtures.createTestProject(script) + try { + def args = [publishingTask, '--configuration-cache', '--configuration-cache-problems=fail', '--parallel', '--no-build-cache'] as String[] + def first = run(dir, *args) + assertNull(first.task(':clearModuleMetadata')) + assertEquals(TaskOutcome.SUCCESS, first.task(':generateMetadataFileForMavenPublication').outcome) + assertEquals(TaskOutcome.SUCCESS, first.task(':signMavenPublication').outcome) + File metadata = new File(dir, 'build/publications/maven/module.json') + File signature = new File(metadata.path + '.asc') + assertTrue(metadata.isFile()); assertTrue(signature.isFile()) + byte[] original = metadata.bytes + byte[] originalSignature = signature.bytes + def repeated = run(dir, *args) + assertTrue(repeated.output.contains('Reusing configuration cache')) + assertNull(repeated.task(':clearModuleMetadata')) + assertTrue(repeated.task(':generateMetadataFileForMavenPublication').outcome in [TaskOutcome.UP_TO_DATE, TaskOutcome.SUCCESS]) + assertEquals(TaskOutcome.UP_TO_DATE, repeated.task(':signMavenPublication').outcome) + assertArrayEquals(original, metadata.bytes) + assertArrayEquals(originalSignature, signature.bytes) + assertTrue(fail(dir, 'bundle').output.contains('nexusReleasePlugin.mavenPublication must be set')) + } finally { dir.parentFile.deleteDir() } + } + + @Test void metadataRebuiltWithWatchingParallelAndConfigurationCache() { + File dir = TestFixtures.createTestProject() + try { + def args = ['bundle', '--configuration-cache', '--configuration-cache-problems=fail', '--parallel', '--watch-fs', '--no-build-cache'] as String[] + run(dir, *args) + File metadata = new File(dir, 'build/publications/maven/module.json') + File signature = new File(metadata.path + '.asc') + assertTrue(metadata.isFile()); assertTrue(signature.isFile()) + assertTrue(new File(dir, 'build/publications/maven/pom-default.xml').text.contains('published-with-gradle-metadata')) + assertArrayEquals(metadata.bytes, entry(dir, '.module')) + def repeated = run(dir, *args) + assertTrue(repeated.output.contains('Reusing configuration cache')) + assertEquals(TaskOutcome.SUCCESS, repeated.task(':generateMetadataFileForMavenPublication').outcome) + assertArrayEquals(metadata.bytes, entry(dir, '.module')) + def cached = run(dir, 'bundle', '--build-cache', '--info') + assertTrue(cached.task(':generateMetadataFileForMavenPublication').outcome in [TaskOutcome.SUCCESS, TaskOutcome.FROM_CACHE]) + assertArrayEquals(metadata.bytes, entry(dir, '.module')) + def cachedAgain = run(dir, 'bundle', '--build-cache', '--info') + assertTrue(cachedAgain.task(':generateMetadataFileForMavenPublication').outcome in [TaskOutcome.SUCCESS, TaskOutcome.FROM_CACHE]) + assertArrayEquals(metadata.bytes, entry(dir, '.module')) + assertArrayEquals(signature.bytes, entry(dir, '.module.asc')) + println "METADATA_CACHE outcome=${cachedAgain.task(':generateMetadataFileForMavenPublication').outcome}" + new File(dir, 'build.gradle') << "\ntasks.named('generateMetadataFileForMavenPublication') { onlyIf { false } }\n" + def skipped = fail(dir, 'bundle') + assertEquals(TaskOutcome.SKIPPED, skipped.task(':generateMetadataFileForMavenPublication').outcome) + assertFalse(metadata.exists()); assertFalse(signature.exists()) + } finally { dir.parentFile.deleteDir() } + } + + @Test void disabledMetadataRemovesStaleOutputAndMarkerThenCanBeEnabled() { + File dir = TestFixtures.createTestProject() + try { + run(dir, 'bundle') + File build = new File(dir, 'build.gradle') + String original = build.text + build << "\ntasks.named('generateMetadataFileForMavenPublication') { enabled = false }\n" + run(dir, 'bundle') + assertFalse(new File(dir, 'build/publications/maven/module.json').exists()) + assertFalse(new File(dir, 'build/publications/maven/module.json.asc').exists()) + assertFalse(new File(dir, 'build/publications/maven/pom-default.xml').text.contains('published-with-gradle-metadata')) + new ZipFile(new File(dir, 'build/zips/test-project-1.0.0-bundle.zip')).withCloseable { zip -> + assertNull(zip.getEntry('com/example/test-project/1.0.0/test-project-1.0.0.module')) + } + build.text = original + run(dir, 'bundle') + assertArrayEquals(new File(dir, 'build/publications/maven/module.json').bytes, entry(dir, '.module')) + } finally { dir.parentFile.deleteDir() } + } + + @Test void customOutputInputsTrackContentAndSignatureAfterCleanCacheStore() { + String script = TestFixtures.createBuildScript() + ''' + def revision = providers.gradleProperty('metadataRevision').getOrElse('') + tasks.named('generateMetadataFileForMavenPublication') { + outputFile = layout.buildDirectory.file('custom/module.json') + doLast { outputFile.get().asFile.append('\\n' + revision) } + } + tasks.named('bundle') { + doFirst { + assert moduleMetadataInput.files*.name.toSet() == ['module.json', 'module.json.asc'].toSet() + } + } +''' + File dir = TestFixtures.createTestProject(script) + try { + def args = ['bundle', '--configuration-cache', '--configuration-cache-problems=fail', '--no-build-cache'] + run(dir, *(['clean'] + args + ['-PmetadataRevision=one']) as String[]) + byte[] first = entry(dir, '.module') + byte[] signature = entry(dir, '.module.asc') + def changed = run(dir, *(args + ['-PmetadataRevision=two']) as String[]) + assertNotEquals(TaskOutcome.UP_TO_DATE, changed.task(':bundle').outcome) + assertFalse(Arrays.equals(first, entry(dir, '.module'))) + assertFalse(Arrays.equals(signature, entry(dir, '.module.asc'))) + assertArrayEquals(new File(dir, 'build/custom/module.json').bytes, entry(dir, '.module')) + def reused = run(dir, *(args + ['-PmetadataRevision=two']) as String[]) + assertTrue(reused.output.contains('Reusing configuration cache')) + def isolated = args + ['-PmetadataRevision=two', '-x', 'publishMavenPublicationToMavenLocal', + '-x', 'generateMetadataFileForMavenPublication', '-x', 'clearModuleMetadata', '-x', 'signMavenPublication'] + run(dir, *isolated as String[]) + File custom = new File(dir, 'build/custom/module.json') + File customSignature = new File(custom.path + '.asc') + custom.append('\n ') + def contentChanged = run(dir, *isolated as String[]) + assertTrue(contentChanged.output.contains('Reusing configuration cache')) + assertEquals(TaskOutcome.SUCCESS, contentChanged.task(':bundle').outcome) + assertArrayEquals(custom.bytes, entry(dir, '.module')) + customSignature.append('\n') + def signatureChanged = run(dir, *isolated as String[]) + assertEquals(TaskOutcome.SUCCESS, signatureChanged.task(':bundle').outcome) + assertArrayEquals(customSignature.bytes, entry(dir, '.module.asc')) + } finally { dir.parentFile.deleteDir() } + } + + @Test void selectedPublicationOnlyAndAdditionalArtifactBuiltLazily() { + String script = TestFixtures.createBuildScript() + ''' + tasks.register('distZip', Zip) { archiveClassifier = 'dist'; from('src') } + publishing.publications.maven.artifact(tasks.named('distZip')) + publishing.publications.create('other', MavenPublication) { from components.java; artifactId = 'other' } + signing { sign publishing.publications.other } +''' + File dir = TestFixtures.createTestProject(script) + try { + def result = run(dir, 'bundle', '--parallel') + assertNull(result.task(':publishOtherPublicationToMavenLocal')) + assertEquals(TaskOutcome.SUCCESS, result.task(':distZip').outcome) + assertTrue(entry(dir, '-dist.zip').length > 0) + assertTrue(entry(dir, '-dist.zip.asc').length > 0) + run(dir, 'publishOtherPublicationToMavenLocal') + File otherMetadata = new File(dir, 'build/publications/other/module.json') + byte[] other = otherMetadata.bytes + run(dir, 'bundle') + assertArrayEquals(other, otherMetadata.bytes) + } finally { dir.parentFile.deleteDir() } + } + + @Test void missingPublicationHasClearMessage() { + File dir = TestFixtures.createTestProject("plugins { id 'se.alipsa.nexus-release-plugin' }") + try { assertTrue(fail(dir, 'bundle').output.contains('nexusReleasePlugin.mavenPublication must be set')) } + finally { dir.parentFile.deleteDir() } + } +} diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/BundleTaskTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/BundleTaskTest.groovy new file mode 100644 index 0000000..7349ca3 --- /dev/null +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/BundleTaskTest.groovy @@ -0,0 +1,56 @@ +package se.alipsa.gradle.plugin.release + +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import java.util.zip.ZipFile +import static org.junit.jupiter.api.Assertions.* + +class BundleTaskTest { + @Test void missingFirstSignatureDoesNotSuppressLaterSignatures() { + File dir = File.createTempDir('bundle-signatures', '') + try { + def project = ProjectBuilder.builder().withProjectDir(dir).build() + def task = project.tasks.create('testBundle', BundleTask) + File publication = new File(dir, 'publication'); publication.mkdirs() + new File(publication, 'pom-default.xml').text = '' + new File(publication, 'pom-default.xml.asc').text = 'pom signature' + File first = new File(dir, 'first.jar'); first.text = 'first' + File second = new File(dir, 'second.jar'); second.text = 'second' + new File(second.path + '.asc').text = 'second signature' + task.groupId.set('com.example'); task.artifactId.set('demo'); task.version.set('1.0.0') + task.publicationName.set('maven'); task.publicationDirectory.set(publication) + task.artifactEntries.set(['demo-1.0.0.jar': first, 'demo-1.0.0-sources.jar': second]) + task.artifactInputFiles.from(first, second) + File output = new File(dir, 'bundle.zip'); task.bundleFile.set(output) + task.createBundle() + new ZipFile(output).withCloseable { zip -> + String prefix = 'com/example/demo/1.0.0/demo-1.0.0' + assertNull(zip.getEntry(prefix + '.jar.asc')) + assertNotNull(zip.getEntry(prefix + '-sources.jar.asc')) + assertNotNull(zip.getEntry(prefix + '.pom.asc')) + } + } finally { dir.deleteDir() } + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = [true, false]) + void metadataAndPomMustAgreeBeforeWritingBundle(boolean metadataPresent) { + File dir = File.createTempDir('metadata-state', '') + try { + def project = ProjectBuilder.builder().withProjectDir(dir).build() + def task = project.tasks.create('testBundle', BundleTask) + File publication = new File(dir, 'publication'); publication.mkdirs() + new File(publication, 'pom-default.xml').text = metadataPresent ? '' : '' + File metadata = new File(dir, 'custom/module.json'); metadata.parentFile.mkdirs() + if (metadataPresent) metadata.text = '{}' + task.moduleMetadataFile.set(metadata) + task.groupId.set('com.example'); task.artifactId.set('demo'); task.version.set('1.0.0') + task.publicationName.set('maven'); task.publicationDirectory.set(publication) + task.artifactEntries.set([:]) + File zip = new File(dir, 'bundle.zip'); task.bundleFile.set(zip) + def failure = assertThrows(org.gradle.api.GradleException) { task.createBundle() } + assertTrue(failure.message.contains(metadataPresent ? 'Module metadata state is inconsistent' : 'no metadata was produced in this build')) + assertFalse(zip.exists()) + } finally { dir.deleteDir() } + } + +} diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/BundleValidatorTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/BundleValidatorTest.groovy index a375cf2..653fab0 100644 --- a/src/test/groovy/se/alipsa/gradle/plugin/release/BundleValidatorTest.groovy +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/BundleValidatorTest.groovy @@ -10,6 +10,29 @@ import static org.junit.jupiter.api.Assertions.assertEquals class BundleValidatorTest { + @Test + void pomMetadataMarkerRequiresModuleAndSignature() { + List artifacts = [ + 'demo-1.0.0.jar', 'demo-1.0.0.jar.asc', + 'demo-1.0.0-sources.jar', 'demo-1.0.0-sources.jar.asc', + 'demo-1.0.0-javadoc.jar', 'demo-1.0.0-javadoc.jar.asc', + 'demo-1.0.0.pom.asc' + ] + String pom = validPom().replace('', '\n') + [[], ['demo-1.0.0.module'], ['demo-1.0.0.module', 'demo-1.0.0.module.asc']].eachWithIndex { List metadata, int index -> + File zip = createBundleZip('com.example', 'demo', '1.0.0', pom, artifacts + metadata) + try { + List errors = BundleValidator.validateBundle(zip, 'com.example', 'demo', '1.0.0', 'user', 'pass') + assertEquals(2 - index, errors.size(), "Unexpected validation errors: ${errors}") + if (index == 0) assertTrue(errors.any { it.endsWith('/demo-1.0.0.module') }) + if (index == 0) assertTrue(errors.any { it.endsWith('/demo-1.0.0.module.asc') }) + if (index == 1) assertTrue(errors.any { it.startsWith('Missing signature') && it.endsWith('/demo-1.0.0.module') }) + } finally { + zip.parentFile.deleteDir() + } + } + } + @Test void validBundleHasNoErrors() { File zipFile = createBundleZip('com.example', 'demo', '1.0.0', validPom(), [ @@ -105,6 +128,62 @@ class BundleValidatorTest { assertTrue(errors.size() >= 5) } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = ['bundle', 'maven-plugin', 'ejb', 'custom-jar']) + void jarProducingPackagingRemainsSupported(String packaging) { + File zip = createBundleZip('com.example', 'demo', '1.0.0', + validPom().replace('', "${packaging}"), + ['demo-1.0.0.jar', 'demo-1.0.0.jar.asc', 'demo-1.0.0-sources.jar', + 'demo-1.0.0-sources.jar.asc', 'demo-1.0.0-javadoc.jar', + 'demo-1.0.0-javadoc.jar.asc', 'demo-1.0.0.pom.asc']) + try { + assertEquals([], BundleValidator.validateBundle(zip, 'com.example', 'demo', '1.0.0', 'user', 'pass')) + } finally { zip.parentFile.deleteDir() } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = ['war', 'pom']) + void unsupportedPackagingReportsSingleCause(String packaging) { + File zip = createBundleZip('com.example', 'demo', '1.0.0', validPom().replace('', "${packaging}"), []) + try { + def errors = BundleValidator.validateBundle(zip, 'com.example', 'demo', '1.0.0', 'user', 'pass') + assertEquals(1, errors.size()) + assertTrue(errors[0].contains("Packaging '${packaging}' is not supported")) + if (packaging == 'pom') assertTrue(errors[0].contains('releaseAll')) + } finally { zip.parentFile.deleteDir() } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = ['jar', 'pom', 'module', 'zip']) + void everyPrimaryEntryNeedsNonemptySignatureAndChecksums(String extension) { + String name = "demo-1.0.0.${extension}" + File zip = createBundleZip('com.example', 'demo', '1.0.0', validPom(), extension == 'pom' ? [] : [name]) + try { + def errors = BundleValidator.validateBundle(zip, 'com.example', 'demo', '1.0.0', 'user', 'pass') + assertTrue(errors.any { it == "Missing signature for bundle entry: com/example/demo/1.0.0/${name}" }) + rewriteZip(zip, name + '.asc', new byte[0]) + errors = BundleValidator.validateBundle(zip, 'com.example', 'demo', '1.0.0', 'user', 'pass') + assertTrue(errors.any { it == "Empty signature for bundle entry: com/example/demo/1.0.0/${name}" }) + rewriteZip(zip, name + '.md5', null) + rewriteZip(zip, name + '.sha1', null) + errors = BundleValidator.validateBundle(zip, 'com.example', 'demo', '1.0.0', 'user', 'pass') + assertEquals(2, errors.count { it == "Missing checksum for bundle entry: com/example/demo/1.0.0/${name}" }) + } finally { zip.parentFile.deleteDir() } + } + + static void rewriteZip(File zip, String filename, byte[] replacement) { + Map entries = [:] + new java.util.zip.ZipFile(zip).withCloseable { archive -> + archive.entries().each { entries[it.name] = archive.getInputStream(it).bytes } + } + String path = 'com/example/demo/1.0.0/' + filename + if (replacement == null) entries.remove(path) + else entries[path] = replacement + new ZipOutputStream(new FileOutputStream(zip)).withCloseable { output -> + entries.each { name, bytes -> output.putNextEntry(new ZipEntry(name)); output.write(bytes); output.closeEntry() } + } + } + private static File createBundleZip(String groupId, String artifactId, String version, String pomText, List fileNames) { File dir = File.createTempDir('bundle-validator-test', '') File zipFile = new File(dir, 'bundle.zip') @@ -115,6 +194,14 @@ class BundleValidatorTest { zipOut.write(pomText.getBytes('UTF-8')) zipOut.closeEntry() + List primary = ["${artifactId}-${version}.pom".toString()] + fileNames.findAll { !it.endsWith('.asc') } + primary.each { String name -> + ['md5', 'sha1'].each { String suffix -> + zipOut.putNextEntry(new ZipEntry(basePath + name + '.' + suffix)) + zipOut.write('checksum'.bytes) + zipOut.closeEntry() + } + } fileNames.each { String fileName -> zipOut.putNextEntry(new ZipEntry(basePath + fileName)) zipOut.write('x'.bytes) diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/CentralUploadApiTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/CentralUploadApiTest.groovy index a822502..b19f51d 100644 --- a/src/test/groovy/se/alipsa/gradle/plugin/release/CentralUploadApiTest.groovy +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/CentralUploadApiTest.groovy @@ -7,7 +7,6 @@ import okhttp3.mockwebserver.RecordedRequest import org.gradle.api.GradleException import org.gradle.testfixtures.ProjectBuilder import org.gradle.testkit.runner.BuildResult -import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll @@ -16,6 +15,7 @@ import org.junit.jupiter.api.Test import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream +import static org.junit.jupiter.api.Assertions.assertFalse import static org.junit.jupiter.api.Assertions.assertEquals import static org.junit.jupiter.api.Assertions.assertThrows import static org.junit.jupiter.api.Assertions.assertTrue @@ -34,6 +34,11 @@ class CentralUploadApiTest { server.shutdown() } + @org.junit.jupiter.api.BeforeEach + void drainRecordedRequests() { + while (server.takeRequest(1, java.util.concurrent.TimeUnit.MILLISECONDS) != null) { } + } + @Test void testCentralPortalClientUpload() { server.setDispatcher(new Dispatcher() { @@ -59,7 +64,7 @@ class CentralUploadApiTest { ) File testProjectDir = TestFixtures.createTestProject(buildScript) - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments("bundle") .withPluginClasspath() @@ -123,7 +128,7 @@ class CentralUploadApiTest { ) File testProjectDir = TestFixtures.createTestProject(buildScript) - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('release') .withPluginClasspath() @@ -143,6 +148,36 @@ class CentralUploadApiTest { assertEquals(urlPath, actualPath) } + @Test + void uploadConnectTimeoutReportsConnectionFailure() { + def connectTimeout = new java.net.http.HttpConnectTimeoutException('connection stalled') + SocketTimeoutException timeout = WsClient.timeoutFailure(connectTimeout, 1800000, + 'https://central.sonatype.com/api/v1/publisher/upload') + // Inject a transport failure deterministically; unreachable hosts depend on routing/firewalls. + def client = new CentralPortalClient(org.gradle.api.logging.Logging.getLogger(CentralUploadApiTest), + 'https://portal.example.test/api/v1', 'u', 'p') { + @Override + Map postMultipart(String url, File file, String fieldName, + String username, String password) { + throw timeout + } + } + File dir = File.createTempDir('connect-timeout-upload', '') + try { + File bundle = new File(dir, 'bundle.zip') + bundle.text = 'bundle' + GradleException failure = assertThrows(GradleException) { client.upload(bundle) } + assertTrue(failure.message.contains('Could not connect to Central Portal at https://portal.example.test/api/v1 during upload')) + assertTrue(failure.message.contains('Check the URL, network connectivity, and proxy settings')) + assertFalse(failure.message.contains('nexusUrl')) + assertFalse(failure.message.contains('outcome is unknown')) + assertFalse(failure.message.contains('may already have reached')) + assertFalse(failure.message.contains('publishing/deployments')) + org.junit.jupiter.api.Assertions.assertSame(timeout, failure.cause) + org.junit.jupiter.api.Assertions.assertSame(connectTimeout, failure.cause.cause) + } finally { dir.deleteDir() } + } + @Test void upload401ReturnsFriendlyMessage() { withUploadStatus(401, '{"error":"bad credentials"}') { String targetUrl -> @@ -208,7 +243,7 @@ class CentralUploadApiTest { String buildScript = TestFixtures.createBuildScript(targetUrl) File testProjectDir = TestFixtures.createTestProject(buildScript) - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('release') .withPluginClasspath() @@ -220,6 +255,291 @@ class CentralUploadApiTest { assertTrue(result.output.contains('Failed to release test-project with deploymentId 1234567')) } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.MethodSource('pollScenarios') + @org.junit.jupiter.api.Timeout(120) + void pollingUsesExactAttemptBudget(int retries, List responses, String expected, int requests) { + java.util.concurrent.atomic.AtomicInteger uploads = new java.util.concurrent.atomic.AtomicInteger() + java.util.concurrent.atomic.AtomicInteger statuses = new java.util.concurrent.atomic.AtomicInteger() + server.setDispatcher(new Dispatcher() { + @Override MockResponse dispatch(RecordedRequest request) { + if (request.path.startsWith('/api/v1/publisher/upload')) { + uploads.incrementAndGet() + return new MockResponse().setBody('poll-id') + } + if (request.path.startsWith('/api/v1/publisher/status')) { + int index = statuses.getAndIncrement() + Object response = responses[Math.min(index, responses.size() - 1)] + if (response instanceof Integer) return new MockResponse().setResponseCode((Integer) response).setBody('temporary error') + if (response == 'malformed') return new MockResponse().setBody('{') + return new MockResponse().setBody('{"deploymentState":"' + response + '"}') + } + return new MockResponse().setResponseCode(404) + } + }) + String script = TestFixtures.createBuildScript(server.url('/api/v1').toString()) + """ +nexusReleasePlugin { + initialStatusCheckDelaySeconds = 0 + statusCheckIntervalSeconds = 0 + statusCheckRetries = ${retries} +} +""" + File dir = TestFixtures.createTestProject(script) + try { + def runner = MatrixRunner.create().withProjectDir(dir).withArguments('release').withPluginClasspath() + def result = expected == 'success' ? runner.build() : runner.buildAndFail() + if (expected != 'success') assertTrue(result.output.contains(expected), result.output) + responses.take(requests).eachWithIndex { response, index -> + if (response in ['PENDING', 'VALIDATING']) { + assertTrue(result.output.contains("Deploy status is ${response} (check ${index + 1}/${retries + 1})"), result.output) + } + } + assertEquals(1, uploads.get()) + assertEquals(requests, statuses.get()) + } finally { dir.parentFile.deleteDir() } + } + + static java.util.stream.Stream pollScenarios() { + def rows = [ + [0, ['PUBLISHED'], 'success', 1], + [0, ['PENDING'], 'still PENDING after 1 status checks', 1], + [0, [502], 'Last error:', 1], + [2, ['PENDING', 'VALIDATING', 'PENDING'], 'still PENDING after 3 status checks', 3], + [2, [502, 'PUBLISHED'], 'success', 2], + [2, [429, 'PUBLISHED'], 'success', 2], + [2, ['malformed', 'PUBLISHED'], 'success', 2], + [2, [401], 'Status check for deployment poll-id was rejected (HTTP 401); the upload succeeded.', 1], + [2, [403], 'Status check for deployment poll-id was rejected (HTTP 403); the upload succeeded.', 1] + ] + rows.stream().map { org.junit.jupiter.params.provider.Arguments.of(it as Object[]) } + } + + @Test @org.junit.jupiter.api.Timeout(120) + void releaseUploadDeadlineBoundsStalledWrite() { + ServerSocket stalled = new ServerSocket(0) + Socket peer = null + java.util.concurrent.atomic.AtomicLong acceptedAt = new java.util.concurrent.atomic.AtomicLong() + Thread accept = Thread.startDaemon { try { peer = stalled.accept(); acceptedAt.set(System.nanoTime()) } catch (IOException ignored) { } } + String script = TestFixtures.createBuildScript("http://127.0.0.1:${stalled.localPort}/api/v1") + """ +nexusReleasePlugin { httpRequestTimeoutSeconds = 1 } +tasks.named('release') { + doFirst { new RandomAccessFile(bundleFile.get().asFile, 'rw').withCloseable { it.setLength(64L * 1024 * 1024) } } +} +""" + // Appending padding keeps the ZIP readable only within the ZIP trailing-data tolerance, + // so use a real large, incompressible artifact instead. + script = script.substring(0, script.indexOf("tasks.named('release')")) + ''' +tasks.register('largeArtifact') { + def output = layout.buildDirectory.file('large.bin') + outputs.file(output) + doLast { + output.get().asFile.parentFile.mkdirs() + output.get().asFile.withOutputStream { stream -> + def random = new Random(42) + byte[] bytes = new byte[65536] + 1024.times { random.nextBytes(bytes); stream.write(bytes) } + } + } +} +publishing.publications.maven.artifact(layout.buildDirectory.file('large.bin')) { + classifier = 'large'; extension = 'bin'; builtBy tasks.named('largeArtifact') +} +''' + File dir = TestFixtures.createTestProject(script) + try { + def result = MatrixRunner.create().withProjectDir(dir).withArguments('release').withPluginClasspath().buildAndFail() + assertTrue(result.output.contains('Request exceeded overall deadline'), result.output) + assertTrue(acceptedAt.get() > 0) + assertTrue(java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - acceptedAt.get()) <= 6000, 'Stalled upload did not return promptly') + } finally { stalled.close(); peer?.close(); accept.join(1000); dir.parentFile.deleteDir() } + } + + @Test @org.junit.jupiter.api.Timeout(120) + void stalledStatusIsRetriedWithoutAnotherUpload() { + java.util.concurrent.atomic.AtomicInteger uploads = new java.util.concurrent.atomic.AtomicInteger() + java.util.concurrent.atomic.AtomicInteger statuses = new java.util.concurrent.atomic.AtomicInteger() + server.setDispatcher(new Dispatcher() { + @Override MockResponse dispatch(RecordedRequest request) { + if (request.path.startsWith('/api/v1/publisher/upload')) { + uploads.incrementAndGet(); return new MockResponse().setBody('stall-id') + } + if (request.path.startsWith('/api/v1/publisher/status')) { + if (statuses.incrementAndGet() == 1) return new MockResponse().setBody('{"deploymentState":"PENDING"}').setBodyDelay(3, java.util.concurrent.TimeUnit.SECONDS) + return new MockResponse().setBody('{"deploymentState":"PUBLISHED"}') + } + return new MockResponse().setResponseCode(404) + } + }) + String script = TestFixtures.createBuildScript(server.url('/api/v1').toString()) + ''' +nexusReleasePlugin { + httpRequestTimeoutSeconds = 1 + initialStatusCheckDelaySeconds = 0 + statusCheckIntervalSeconds = 0 + statusCheckRetries = 1 +} +''' + File dir = TestFixtures.createTestProject(script) + try { + def result = MatrixRunner.create().withProjectDir(dir).withPluginClasspath().withArguments('release').build() + assertTrue(result.output.contains('Status check failed (check 1/2)')) + assertTrue(result.output.contains('Request exceeded overall deadline')) + assertEquals(1, uploads.get()); assertEquals(2, statuses.get()) + } finally { dir.parentFile.deleteDir() } + } + + @Test @org.junit.jupiter.api.Timeout(30) + void combinedTimeoutRecordsUnknownUploadAndRetriesOnlyStatus() { + File dir = File.createTempDir('combined-timeout', '') + File record = new File(dir, 'record.json') + try { + def client = new CentralPortalClient(org.gradle.api.logging.Logging.getLogger(CentralUploadApiTest), server.url('/api/v1').toString(), 'u', 'p', 1, 1) + java.util.concurrent.atomic.AtomicInteger uploads = new java.util.concurrent.atomic.AtomicInteger() + java.util.concurrent.atomic.AtomicInteger polls = new java.util.concurrent.atomic.AtomicInteger() + server.setDispatcher(new Dispatcher() { + @Override MockResponse dispatch(RecordedRequest request) { + if (request.path.startsWith('/api/v1/publisher/upload')) { + uploads.incrementAndGet() + return new MockResponse().setBody('combined-id').setBodyDelay(3, java.util.concurrent.TimeUnit.SECONDS) + } + polls.incrementAndGet() + if (polls.get() == 1) return new MockResponse().setBody('{"deploymentState":"PENDING"}').setBodyDelay(3, java.util.concurrent.TimeUnit.SECONDS) + return new MockResponse().setBody('{"deploymentState":"PUBLISHED"}') + } + }) + File bundle = new File(dir, 'bundle.zip'); bundle.text = 'upload' + def manifest = [selected: []] + GradleException timeout = assertThrows(GradleException) { CombinedDeployment.submit(record, bundle, manifest, new PublicationStatus(), client) } + assertTrue(timeout.cause instanceof SocketTimeoutException) + assertTrue(timeout.message.contains('outcome is unknown')) + assertTrue(timeout.message.contains('https://central.sonatype.com/publishing/deployments before retrying')) + assertEquals('UPLOAD_ERROR', ReleaseManifest.read(record).state) + ReleaseManifest.write(record, [deploymentId: 'combined-id', state: 'UPLOADED', manifest: manifest]) + def pollingClient = new CentralPortalClient(org.gradle.api.logging.Logging.getLogger(CentralUploadApiTest), server.url('/api/v1').toString(), 'u', 'p', java.time.Duration.ofSeconds(1), 1800000L, 1000L) + CombinedDeployment.resume(record, 'combined-id', pollingClient, { true }, 2, 0) + def state = ReleaseManifest.read(record) + assertEquals('AVAILABLE', state.state) + assertTrue(state.lastPollingError.contains('Request exceeded overall deadline')) + assertEquals(1, uploads.get()); assertEquals(2, polls.get()) + } finally { dir.deleteDir() } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = ['war', 'pom']) + @org.junit.jupiter.api.Timeout(120) + void packagingRejectedBeforeAnyUpload(String packaging) { + java.util.concurrent.atomic.AtomicInteger requests = new java.util.concurrent.atomic.AtomicInteger() + server.setDispatcher(new Dispatcher() { + @Override MockResponse dispatch(RecordedRequest request) { + requests.incrementAndGet(); return new MockResponse().setResponseCode(500) + } + }) + String key = TestFixtures.generateTestPrivateKey() + String plugin = packaging == 'war' ? 'war' : 'java-platform' + String component = packaging == 'war' ? 'web' : 'javaPlatform' + String script = """ +plugins { id '${plugin}'; id 'se.alipsa.nexus-release-plugin' } +group = 'com.example' +version = '1.0.0' +publishing { + publications { + maven(MavenPublication) { + from components.${component} + pom { + name = 'fixture'; description = 'fixture'; url = 'https://example.com' + licenses { license { name = 'MIT'; url = 'https://example.com/license' } } + developers { developer { id = 'tester' } } + scm { url = 'https://example.com'; connection = 'scm:git:https://example.com' } + } + } + } +} +signing { useInMemoryPgpKeys(null, '''${key}''', ''); sign publishing.publications.maven } +nexusReleasePlugin { + mavenPublication = publishing.publications.maven + userName = 'u'; password = 'p'; nexusUrl = '${server.url('/api/v1')}' +} +""" + File dir = TestFixtures.createTestProject(script) + try { + def result = MatrixRunner.create().withProjectDir(dir).withPluginClasspath().withArguments('release').buildAndFail() + assertTrue(result.output.contains("Packaging '${packaging}' is not supported"), result.output) + assertFalse(result.output.contains('Missing required bundle entry')) + if (packaging == 'pom') assertTrue(result.output.contains('releaseAll')) + assertEquals(0, requests.get()) + } finally { dir.parentFile.deleteDir() } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = ['module', 'zip']) + @org.junit.jupiter.api.Timeout(120) + void missingExtraSignatureFailsBeforeUpload(String kind) { + java.util.concurrent.atomic.AtomicInteger requests = new java.util.concurrent.atomic.AtomicInteger() + server.setDispatcher(new Dispatcher() { + @Override MockResponse dispatch(RecordedRequest request) { requests.incrementAndGet(); return new MockResponse().setResponseCode(500) } + }) + String script = TestFixtures.createBuildScript(server.url('/api/v1').toString()) + if (kind == 'zip') script += ''' +tasks.register('distZip', Zip) { archiveClassifier = 'dist'; from('src') } +publishing.publications.maven.artifact(tasks.named('distZip')) +''' + String fileExpression = kind == 'module' ? "moduleMetadataFile.get().asFile" : "tasks.named('distZip').get().archiveFile.get().asFile" + script += """ +tasks.named('bundle') { doFirst { new File(${fileExpression}.path + '.asc').delete() } } +""" + File dir = TestFixtures.createTestProject(script) + try { + def result = MatrixRunner.create().withProjectDir(dir).withPluginClasspath().withArguments('release').buildAndFail() + assertTrue(result.output.contains('Missing signature for bundle entry:'), result.output) + assertTrue(result.output.contains(kind == 'module' ? 'test-project-1.0.0.module' : 'test-project-1.0.0-dist.zip')) + assertEquals(0, requests.get()) + } finally { dir.parentFile.deleteDir() } + } + + static abstract class ShortDeadlineReleaseTask extends ReleaseTask { + @Override protected CentralPortalClient createClient(String user, String pass) { + new CentralPortalClient(logger, nexusUrl.get(), user, pass, java.time.Duration.ofSeconds(1), + httpRequestTimeoutSeconds.get() * 1000L, 1000L) + } + } + + @Test @org.junit.jupiter.api.Timeout(30) + void singleReleaseUsesSmallCallDeadlineWithLongUploadDeadline() { + java.util.concurrent.atomic.AtomicInteger uploads = new java.util.concurrent.atomic.AtomicInteger() + java.util.concurrent.atomic.AtomicInteger statuses = new java.util.concurrent.atomic.AtomicInteger() + server.setDispatcher(new Dispatcher() { + @Override MockResponse dispatch(RecordedRequest request) { + if (request.path.startsWith('/api/v1/publisher/upload')) { uploads.incrementAndGet(); return new MockResponse().setBody('cap-id') } + if (statuses.incrementAndGet() == 1) return new MockResponse().setBody('{"deploymentState":"PENDING"}').setBodyDelay(3, java.util.concurrent.TimeUnit.SECONDS) + return new MockResponse().setBody('{"deploymentState":"PUBLISHED"}') + } + }) + File dir = File.createTempDir('small-call-release', '') + try { + File zip = new File(dir, 'bundle.zip') + Map entries = [:] + String pom = 'DemoDemohttps://example.comMIThttps://example.com/licensetesterhttps://example.comscm:git:https://example.com' + ['.jar', '-sources.jar', '-javadoc.jar', '.pom'].each { suffix -> + String name = 'com/example/demo/1.0.0/demo-1.0.0' + suffix + entries[name] = (suffix == '.pom' ? pom : 'artifact').getBytes('UTF-8') + ['.asc', '.md5', '.sha1'].each { entries[name + it] = 'fixture'.bytes } + } + new ZipOutputStream(new FileOutputStream(zip)).withCloseable { out -> + entries.each { name, bytes -> out.putNextEntry(new ZipEntry(name)); out.write(bytes); out.closeEntry() } + } + def project = ProjectBuilder.builder().withProjectDir(dir).build() + def task = project.tasks.create('smallDeadlineRelease', ShortDeadlineReleaseTask) + task.bundleFile.set(zip); task.publicationVersion.set('1.0.0'); task.projectName.set('demo') + task.groupId.set('com.example'); task.artifactId.set('demo'); task.userName.set('u'); task.password.set('p') + task.nexusUrl.set(server.url('/api/v1').toString()); task.statusCheckRetries.set(1) + task.initialStatusCheckDelaySeconds.set(0); task.statusCheckIntervalSeconds.set(0) + task.httpRequestTimeoutSeconds.set(1800) + long start = System.nanoTime() + task.release() + assertTrue(java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 7000) + assertEquals(1, uploads.get()); assertEquals(2, statuses.get()) + } finally { dir.deleteDir() } + } + private static void withUploadStatus(int code, String body, Closure assertion) { server.setDispatcher(new Dispatcher() { @Override diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/CharsetTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/CharsetTest.groovy new file mode 100644 index 0000000..83294fb --- /dev/null +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/CharsetTest.groovy @@ -0,0 +1,17 @@ +package se.alipsa.gradle.plugin.release + +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.gradle.api.logging.Logging +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import static org.junit.jupiter.api.Assertions.* + +class CharsetTest { + @Test @Tag('non-utf8-charset') + void credentialsUseUtf8() { + assertNotEquals(StandardCharsets.UTF_8, Charset.defaultCharset(), 'charsetTest must run with a non-UTF-8 default charset') + def client = new CentralPortalClient(Logging.getLogger(CharsetTest), '', '', '') + assertEquals('Bearer ' + Base64.encoder.encodeToString('usér:pässword'.getBytes(StandardCharsets.UTF_8)), client.auth('usér', 'pässword')) + } +} diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/CombinedReleaseTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/CombinedReleaseTest.groovy index 3b2c0f2..04efe2e 100644 --- a/src/test/groovy/se/alipsa/gradle/plugin/release/CombinedReleaseTest.groovy +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/CombinedReleaseTest.groovy @@ -159,6 +159,23 @@ class CombinedReleaseTest { assertThrows(IllegalArgumentException) { CombinedBundle.create(directory, zip, [all]) } } + @Test void listedModuleMetadataIsSignedAndUnsupportedFilesStillFail() { + def component = publication('matrix-all', 'jar', ['matrix-all-1.pom', 'matrix-all-1.jar', 'matrix-all-1.module']) + component.files.each { String name -> + File file = new File(directory, "g/matrix-all/1/${name}") + file.parentFile.mkdirs() + file.text = name.endsWith('.pom') ? pomText('matrix-all', 'jar') : 'artifact fixture' + new File(file.path + '.asc').text = 'signature' + } + File zip = new File(directory, 'metadata.zip') + CombinedBundle.create(directory, zip, [component]) + new ZipFile(zip).withCloseable { assertNotNull(it.getEntry('g/matrix-all/1/matrix-all-1.module.asc')) } + new File(directory, 'g/matrix-all/1/matrix-all-1.module.asc').delete() + assertThrows(IllegalArgumentException) { CombinedBundle.create(directory, zip, [component]) } + component.files = ['matrix-all-1.pom', 'matrix-all-1.jar', 'matrix-all-1.exe'] + assertThrows(IllegalArgumentException) { CombinedBundle.create(directory, zip, [component]) } + } + @Test void staticMavenPropertiesAndUnresolvedExpressions() { File pom = new File(directory, 'pom.xml') pom.text = pomText('a', 'pom').replace('', 'gb${bVersion}') diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/CombinedTasksTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/CombinedTasksTest.groovy index 79ba6f0..2a32525 100644 --- a/src/test/groovy/se/alipsa/gradle/plugin/release/CombinedTasksTest.groovy +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/CombinedTasksTest.groovy @@ -1,7 +1,7 @@ package se.alipsa.gradle.plugin.release -import com.sun.net.httpserver.HttpServer import org.gradle.testkit.runner.GradleRunner +import com.sun.net.httpserver.HttpServer import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import static org.junit.jupiter.api.Assertions.* @@ -81,6 +81,7 @@ releaseAllPrepareMaven { settingsHelperFile = layout.projectDirectory.file('settings-helper.groovy') } """ + MatrixRunner.instrument(root) ReleaseManifest.git(root, 'init'); ReleaseManifest.git(root, 'add', '.') ReleaseManifest.git(root, '-c', 'user.name=Test', '-c', 'user.email=test@example.org', 'commit', '-m', 'Fixture') run('releaseAllPlan') @@ -149,7 +150,7 @@ releaseAll { private def run(String task) { runner(task).build() } private GradleRunner runner(String task) { - GradleRunner.create().withProjectDir(root).withPluginClasspath() + MatrixRunner.create().withProjectDir(root).withPluginClasspath() .withEnvironment(new HashMap(System.getenv()) + [PATH: new File(root, 'bin').path + File.pathSeparator + System.getenv('PATH')]) .withArguments(task, '--configuration-cache', '--configuration-cache-problems=fail', '--stacktrace') } diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/GithubParsingTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/GithubParsingTest.groovy new file mode 100644 index 0000000..6bca748 --- /dev/null +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/GithubParsingTest.groovy @@ -0,0 +1,18 @@ +package se.alipsa.gradle.plugin.release + +import org.junit.jupiter.api.Test +import static org.junit.jupiter.api.Assertions.* + +class GithubParsingTest { + @Test void parsesCredentialsAndTrailingSlashesWithoutLoggingTokens() { + ['https://user@github.com/o/r.git', 'https://x-access-token:abc@github.com/o/r.git', + 'https://github.com/o/r/', 'ssh://git@github.com:22/o/r.git', 'git@github.com:o/r.git'].each { + assertEquals('o/r', NexusReleasePlugin.parseGithubCoordinates(it, 'https://api.github.com')) + } + assertNull(NexusReleasePlugin.parseGithubCoordinates('https://gitlab.com/o/r', 'https://api.github.com')) + ['url=', '\turl = '].each { + assertEquals('https://github.com/o/r.git', NexusReleasePlugin.parseOriginUrlFromConfig("[REMOTE \"origin\"]\n${it}https://github.com/o/r.git\n[remote \"other\"]\nurl=x")) + } + assertNull(NexusReleasePlugin.parseOriginUrlFromConfig('[remote "ORIGIN"]\nurl=https://github.com/o/r.git')) + } +} diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/GradleCompatibilityMatrixTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/GradleCompatibilityMatrixTest.groovy index 0d92810..3bb8306 100644 --- a/src/test/groovy/se/alipsa/gradle/plugin/release/GradleCompatibilityMatrixTest.groovy +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/GradleCompatibilityMatrixTest.groovy @@ -2,7 +2,6 @@ package se.alipsa.gradle.plugin.release import groovy.ant.AntBuilder import org.gradle.testkit.runner.BuildResult -import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -33,7 +32,7 @@ class GradleCompatibilityMatrixTest { void bundleTaskIsCompatibleAcrossSupportedGradleVersions(String gradleVersion) throws IOException { File testProjectDir = TestFixtures.createTestProject() try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withGradleVersion(gradleVersion) .withArguments('bundle') @@ -41,6 +40,7 @@ class GradleCompatibilityMatrixTest { .forwardOutput() .build() + assertTrue(result.output.contains("TESTKIT_RUNTIME Gradle=${gradleVersion} JVM=${System.getProperty('testJavaVersion', '17')}"), result.output) assertEquals(TaskOutcome.SUCCESS, result.task(':bundle').outcome) File zipFile = new File(testProjectDir, 'build/zips/test-project-1.0.0-bundle.zip') assertTrue(zipFile.exists(), "ZIP file should be created when running on Gradle ${gradleVersion}") diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/MatrixRunner.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/MatrixRunner.groovy new file mode 100644 index 0000000..311fbc8 --- /dev/null +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/MatrixRunner.groovy @@ -0,0 +1,22 @@ +package se.alipsa.gradle.plugin.release + +import org.gradle.testkit.runner.GradleRunner + +class MatrixRunner { + static GradleRunner create() { + String version = System.getProperty('testGradleVersion', '') + def runner = GradleRunner.create() + if (version) runner.withGradleVersion(version) + runner + } + + static void instrument(File directory) { + int expected = Integer.parseInt(System.getProperty('testJavaVersion', '17')) + String version = System.getProperty('testGradleVersion', '') + new File(directory, 'build.gradle') << """ +println('TESTKIT_RUNTIME Gradle=' + gradle.gradleVersion + ' JVM=' + Runtime.version().feature()) +assert Runtime.version().feature() == ${expected} : 'Unexpected TestKit daemon JVM' +${version ? "assert gradle.gradleVersion == '${version}'" : ''} +""" + } +} diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/ReleasePluginTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/ReleasePluginTest.groovy index f7ab45a..020ef5d 100644 --- a/src/test/groovy/se/alipsa/gradle/plugin/release/ReleasePluginTest.groovy +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/ReleasePluginTest.groovy @@ -5,7 +5,6 @@ import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest -import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.BuildResult import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.AfterAll @@ -36,7 +35,7 @@ class ReleasePluginTest { File testProjectDir = TestFixtures.createTestProject() try { // First run - should store configuration cache - BuildResult result1 = GradleRunner.create() + BuildResult result1 = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments("bundle", "--configuration-cache") .withPluginClasspath() @@ -52,7 +51,7 @@ class ReleasePluginTest { new File(testProjectDir, "build/libs").deleteDir() // Second run - should reuse configuration cache - BuildResult result2 = GradleRunner.create() + BuildResult result2 = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments("bundle", "--configuration-cache") .withPluginClasspath() @@ -73,7 +72,7 @@ class ReleasePluginTest { File testProjectDir = TestFixtures.createTestProject() try { // Run build - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments("bundle") .withPluginClasspath() @@ -109,7 +108,11 @@ class ReleasePluginTest { artifactPath + '.pom', artifactPath +'.pom.asc', artifactPath +'.pom.md5', - artifactPath +'.pom.sha1' + artifactPath +'.pom.sha1', + artifactPath +'.module', + artifactPath +'.module.asc', + artifactPath +'.module.md5', + artifactPath +'.module.sha1' ] // Read actual ZIP entries @@ -127,29 +130,16 @@ class ReleasePluginTest { } @Test - void releaseFailsBeforeUploadOnFilenameMismatch() throws IOException { - String buildScript = TestFixtures.createBuildScript().replace( - "version = '1.0.0'", - """version = '1.0.0' -base { - archivesName = 'sieparser' -}""" - ) - File testProjectDir = TestFixtures.createTestProject(buildScript) + void bundleUsesCoordinatesWhenArchiveNameDiffers() throws IOException { + String script = TestFixtures.createBuildScript().replace("version = '1.0.0'", "version = '1.0.0'\nbase { archivesName = 'sieparser' }") + File dir = TestFixtures.createTestProject(script) try { - BuildResult result = GradleRunner.create() - .withProjectDir(testProjectDir) - .withArguments("release") - .withPluginClasspath() - .forwardOutput() - .buildAndFail() - - assertTrue(result.output.contains("Bundle validation failed.")) - assertTrue(result.output.contains("Filename mismatch")) - assertTrue(result.output.contains("sieparser-1.0.0.jar")) - } finally { - cleanupTestProject(testProjectDir) - } + MatrixRunner.create().withProjectDir(dir).withArguments('bundle').withPluginClasspath().build() + new java.util.zip.ZipFile(new File(dir, 'build/zips/test-project-1.0.0-bundle.zip')).withCloseable { zip -> + assertTrue(zip.getEntry('com/example/test-project/1.0.0/test-project-1.0.0.jar') != null) + assertTrue(zip.entries().every { !it.name.contains('sieparser') }) + } + } finally { cleanupTestProject(dir) } } @Test @@ -162,7 +152,7 @@ base { ) File testProjectDir = TestFixtures.createTestProject(buildScript) try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments("release") .withPluginClasspath() @@ -187,7 +177,7 @@ base { File testProjectDir = TestFixtures.createTestProject(buildScript) try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments("release") .withPluginClasspath() @@ -222,7 +212,7 @@ base { File testProjectDir = TestFixtures.createTestProject(buildScript) try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('printReleasePollingConfig') .withPluginClasspath() @@ -248,7 +238,7 @@ base { String metadataBaseUrl = server.url('/maven2').toString() File testProjectDir = TestFixtures.createMultiModuleProject(metadataBaseUrl) try { - BuildResult firstRun = GradleRunner.create() + BuildResult firstRun = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestMavenVersions', '--configuration-cache') .withPluginClasspath() @@ -261,7 +251,7 @@ base { assertTrue(firstRun.output.contains('multi-module-project: com.example:multi-module-project:1.2.0')) assertTrue(firstRun.output.contains('Configuration cache entry stored')) - BuildResult secondRun = GradleRunner.create() + BuildResult secondRun = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestMavenVersions', '--configuration-cache') .withPluginClasspath() @@ -286,7 +276,7 @@ base { String remoteUrl = "${server.url('/')}${repoSlug}.git" File testProjectDir = TestFixtures.createGithubProject(githubApiBaseUrl, repoSlug, remoteUrl) try { - BuildResult firstRun = GradleRunner.create() + BuildResult firstRun = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestGithubRelease', '--configuration-cache') .withPluginClasspath() @@ -298,7 +288,7 @@ base { "Expected output to contain '${repoSlug}: ${expectedTag}' but was:\n${firstRun.output}") assertTrue(firstRun.output.contains('Configuration cache entry stored')) - BuildResult secondRun = GradleRunner.create() + BuildResult secondRun = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestGithubRelease', '--configuration-cache') .withPluginClasspath() @@ -322,7 +312,7 @@ base { String remoteUrl = "${server.url('/')}${repoSlug}.git" File testProjectDir = TestFixtures.createGithubProject(githubApiBaseUrl, repoSlug, remoteUrl) try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestGithubRelease') .withPluginClasspath() @@ -348,7 +338,7 @@ base { String enterpriseRemote = "${server.url('/')}${repoSlug}.git" File testProjectDir = TestFixtures.createGithubProject(githubApiBaseUrl, repoSlug, enterpriseRemote) try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestGithubRelease') .withPluginClasspath() @@ -372,7 +362,7 @@ base { String githubApiBaseUrl = server.url('/').toString() File testProjectDir = TestFixtures.createGithubProjectWithExplicitRepo(githubApiBaseUrl, repoSlug) try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestGithubRelease') .withPluginClasspath() @@ -399,7 +389,7 @@ base { File testProjectDir = TestFixtures.createGithubProjectWithFakeGh(githubApiBaseUrl, repoSlug, fakeToken, remoteUrl) try { String ghBinPath = new File(testProjectDir, 'bin/gh').absolutePath - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments("-Dse.alipsa.nexus-release-plugin.ghBin=${ghBinPath}", 'latestGithubRelease') .withPluginClasspath() @@ -422,7 +412,7 @@ base { File testProjectDir = TestFixtures.createGithubProject( 'https://api.github.com', repoSlug, "https://gitlab.com/${repoSlug}.git") try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestGithubRelease') .withPluginClasspath() @@ -448,7 +438,7 @@ base { String sshRemote = "ssh://git@localhost:${server.port}/${repoSlug}.git" File testProjectDir = TestFixtures.createGithubProject(githubApiBaseUrl, repoSlug, sshRemote) try { - BuildResult result = GradleRunner.create() + BuildResult result = MatrixRunner.create() .withProjectDir(testProjectDir) .withArguments('latestGithubRelease') .withPluginClasspath() diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/ShellHarnessPolicyTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/ShellHarnessPolicyTest.groovy new file mode 100644 index 0000000..9990bce --- /dev/null +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/ShellHarnessPolicyTest.groovy @@ -0,0 +1,53 @@ +package se.alipsa.gradle.plugin.release + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Test +import static org.junit.jupiter.api.Assertions.* + +class ShellHarnessPolicyTest { + @Test void probesBashOnlyWhenHarnessRunsIncludingConfigurationCacheReuse() { + boolean bashPresent + try { bashPresent = new ProcessBuilder('bash', '-c', 'exit 0').start().waitFor() == 0 } + catch (IOException ignored) { bashPresent = false } + org.junit.jupiter.api.Assumptions.assumeTrue(bashPresent, 'Probe fixture needs Bash') + File fixture = File.createTempDir('shell-policy-lazy', '') + try { + new File(fixture, 'settings.gradle').text = "rootProject.name = 'shell-policy-lazy'" + new File(fixture, 'policy.gradle').bytes = new File(System.getProperty('shellHarnessPolicyScript')).bytes + new File(fixture, 'build.gradle').text = "tasks.register('shellHarness') { doLast { logger.lifecycle('harness ran') } }\ntasks.register('unrelated')\napply from: 'policy.gradle'\n" + File marker = new File(fixture, 'probed') + File bash = new File(fixture, 'bash-probe') + bash.text = '#!/usr/bin/env bash\ntouch "' + marker.absolutePath + '"\nexit 0\n' + assertTrue(bash.setExecutable(true)) + def runner = GradleRunner.create().withProjectDir(fixture) + def args = ['--configuration-cache', "-PshellHarnessBash=${bash.absolutePath}".toString()] + runner.withArguments(['unrelated'] + args).build() + assertFalse(marker.exists(), 'Unrelated task must not probe Bash during configuration') + def reused = runner.withArguments(['unrelated'] + args).build() + assertTrue(reused.output.contains('Reusing configuration cache')) + assertFalse(marker.exists(), 'Unrelated cache hit must not probe Bash') + assertEquals(TaskOutcome.SUCCESS, runner.withArguments(['shellHarness'] + args).build().task(':shellHarness').outcome) + assertTrue(marker.exists(), 'Scheduled harness must probe Bash') + } finally { fixture.deleteDir() } + } + + @Test void missingBashSkipsLocallyAndFailsWhenRequired() { + String source = System.getProperty('shellHarnessPolicyScript') + assertNotNull(source, 'shellHarnessPolicyScript must be supplied by PolicyScriptArg') + assertTrue(new File(source).isFile()) + File fixture = File.createTempDir('shell-policy', '') + try { + new File(fixture, 'settings.gradle').text = "rootProject.name = 'shell-policy'" + new File(fixture, 'policy.gradle').bytes = new File(source).bytes + new File(fixture, 'build.gradle').text = "tasks.register('shellHarness')\napply from: 'policy.gradle'\n" + def args = ['shellHarness', '--info', "-PshellHarnessBash=${new File(fixture, 'absent').absolutePath}".toString()] + def runner = GradleRunner.create().withProjectDir(fixture) + def skipped = runner.withArguments(args + ['-PrequireShellHarness=false']).build() + assertEquals(TaskOutcome.SKIPPED, skipped.task(':shellHarness').outcome) + assertTrue(skipped.output.contains('Bash is available, or required by CI')) + def failed = runner.withArguments(args + ['-PrequireShellHarness=true']).buildAndFail() + assertTrue(failed.output.contains('Bash is unavailable but requireShellHarness=true')) + } finally { fixture.deleteDir() } + } +} diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/TestFixtures.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/TestFixtures.groovy index c573cc2..d695eda 100644 --- a/src/test/groovy/se/alipsa/gradle/plugin/release/TestFixtures.groovy +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/TestFixtures.groovy @@ -155,6 +155,7 @@ class TestFixtures { try (PrintWriter writer = new PrintWriter(groovyFile)) { writer.println("class Example { static void main(String[] args) { println 'Hello' } }") } + MatrixRunner.instrument(testProjectDir) testProjectDir } @@ -222,6 +223,7 @@ class TestFixtures { createGroovySource(testProjectDir, 'RootExample.groovy', "class RootExample { }") createGroovySource(new File(testProjectDir, 'lib-one'), 'LibOneExample.groovy', "class LibOneExample { }") createGroovySource(new File(testProjectDir, 'lib-two'), 'LibTwoExample.groovy', "class LibTwoExample { }") + MatrixRunner.instrument(testProjectDir) testProjectDir } @@ -252,6 +254,7 @@ class TestFixtures { ['git', 'init'].execute(null, testProjectDir).waitFor() ['git', 'remote', 'add', 'origin', origin].execute(null, testProjectDir).waitFor() + MatrixRunner.instrument(testProjectDir) testProjectDir } @@ -283,6 +286,7 @@ class TestFixtures { ['git', 'init'].execute(null, testProjectDir).waitFor() ['git', 'remote', 'add', 'origin', "https://gitlab.com/${repoSlug}.git"].execute(null, testProjectDir).waitFor() + MatrixRunner.instrument(testProjectDir) testProjectDir } @@ -300,6 +304,7 @@ fi exit 1 """ ghScript.setExecutable(true) + MatrixRunner.instrument(testProjectDir) testProjectDir } diff --git a/src/test/groovy/se/alipsa/gradle/plugin/release/WsClientTimeoutTest.groovy b/src/test/groovy/se/alipsa/gradle/plugin/release/WsClientTimeoutTest.groovy new file mode 100644 index 0000000..7815b22 --- /dev/null +++ b/src/test/groovy/se/alipsa/gradle/plugin/release/WsClientTimeoutTest.groovy @@ -0,0 +1,243 @@ +package se.alipsa.gradle.plugin.release + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.time.Duration +import java.util.concurrent.* +import static org.junit.jupiter.api.Assertions.* + +@Timeout(30) +class WsClientTimeoutTest { + static class Client extends WsClient { + Client(long deadline = 2000, long cap = 60000) { super(Duration.ofSeconds(1), deadline, cap) } + String auth(String username, String password) { 'Bearer test' } + } + + @ParameterizedTest + @ValueSource(strings = ['headers', 'body', 'upload', 'trickle', 'small']) + void stalledRequestsBoundCallerAndCloseConnection(String mode) { + int expected = Integer.parseInt(System.getProperty('testJavaVersion', '17')) + println "Timeout proof runtime: JDK ${Runtime.version()}, expected ${expected}" + assertEquals(expected, Runtime.version().feature()) + Set before = Thread.getAllStackTraces().keySet() + ServerSocket server = new ServerSocket(0) + CountDownLatch callerDone = new CountDownLatch(1) + CompletableFuture closed = new CompletableFuture<>() + Thread peer = Thread.startDaemon { + try { + server.accept().withCloseable { socket -> + socket.soTimeout = 5000 + if (mode != 'upload') { + readHeaders(socket.inputStream) + if (mode in ['body', 'trickle']) { + socket.outputStream.write('HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n0123456789'.getBytes('US-ASCII')) + socket.outputStream.flush() + } + if (mode == 'trickle') { + while (!callerDone.await(500, TimeUnit.MILLISECONDS)) { + try { socket.outputStream.write(120); socket.outputStream.flush() } + catch (IOException ignored) { break } + } + } + } + assertTrue(callerDone.await(8, TimeUnit.SECONDS)) + // Drain the upload bytes already buffered before observing EOF/reset. + try { + byte[] buffer = new byte[65536] + while (socket.inputStream.read(buffer) != -1) { } + closed.complete(true) + } catch (SocketTimeoutException e) { closed.completeExceptionally(e) } + catch (IOException ignored) { closed.complete(true) } + } + } catch (Throwable e) { closed.completeExceptionally(e) } + } + File upload = File.createTempFile('deadline-upload', '.zip') + try { + new RandomAccessFile(upload, 'rw').withCloseable { it.setLength(64L * 1024 * 1024) } + Client client = mode == 'small' ? new Client(1800000, 1000) : new Client() + String url = "http://127.0.0.1:${server.localPort}/" + long start = System.nanoTime() + IOException failure = assertThrows(IOException) { + if (mode == 'upload') client.postMultipart(url, upload, 'user', 'pass') + else client.get(url, 'user', 'pass') + } + assertTrue(failure instanceof SocketTimeoutException || failure instanceof java.net.http.HttpTimeoutException, failure.toString()) + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) <= (mode == 'small' ? 6000 : 7000)) + callerDone.countDown() + assertTrue(closed.get(5, TimeUnit.SECONDS), "Connection not closed for ${mode}") + peer.join(1000) + assertTrue(Thread.getAllStackTraces().keySet().findAll { !before.contains(it) && it != peer }.every { it.daemon || !it.alive }) + } finally { + callerDone.countDown(); server.close(); upload.delete(); peer.join(1000) + } + } + + @Test void independentDeadlinesAndHealthyRequest() { + ServerSocket stalled = new ServerSocket(0) + List peers = Collections.synchronizedList([]) + Thread accept = Thread.startDaemon { + try { + 2.times { + Socket socket = stalled.accept() + peers.add(socket) + readHeaders(socket.inputStream) + socket.outputStream.write('HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n0123456789'.bytes) + socket.outputStream.flush() + } + } catch (IOException ignored) { } + } + ExecutorService workers = Executors.newFixedThreadPool(2) + try { + Client client = new Client(500) + long start = System.nanoTime() + def results = (1..2).collect { workers.submit({ assertThrows(IOException) { client.get("http://127.0.0.1:${stalled.localPort}/", '', '') } } as Callable) } + results.each { it.get(6, TimeUnit.SECONDS) } + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 5500) + ServerSocket healthy = new ServerSocket(0) + Thread responder = Thread.startDaemon { + healthy.accept().withCloseable { socket -> + readHeaders(socket.inputStream) + socket.outputStream.write('HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok'.bytes) + } + } + try { assertEquals('ok', client.get("http://127.0.0.1:${healthy.localPort}/", '', '').body) } + finally { healthy.close(); responder.join(1000) } + } finally { stalled.close(); peers.each { it.close() }; workers.shutdownNow(); accept.join(1000) } + } + + @Test void smallCallCapAndValidation() { + Client client = new Client(1800000, 1000) + assertEquals(1000L, client.effectiveDeadlineMillis(false)) + assertEquals(1800000L, client.effectiveDeadlineMillis(true)) + assertEquals(500L, new Client(500, 1000).effectiveDeadlineMillis(false)) + ['httpConnectTimeoutSeconds', 'httpRequestTimeoutSeconds'].each { name -> + [0, -1, 86401, Integer.MAX_VALUE].each { value -> + assertTrue(assertThrows(org.gradle.api.GradleException) { TimeoutSettings.validate(name, value) }.message.contains('between 1 and 86400')) + } + [1, 86400].each { assertEquals(it, TimeoutSettings.validate(name, it)) } + } + def defaults = new WsClient() { String auth(String u, String p) { '' } } + assertEquals(60000L, defaults.effectiveDeadlineMillis(false)) + assertEquals(java.net.http.HttpClient.Version.HTTP_1_1, defaults.httpClient.version()) + assertEquals(java.net.http.HttpClient.Redirect.NORMAL, defaults.httpClient.followRedirects()) + assertEquals(ProxySelector.default, defaults.httpClient.proxy().orElse(null)) + } + + @Test void redirectAndErrorBodyParity() { + def server = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + server.createContext('/redirect') { exchange -> + exchange.responseHeaders.set('Location', '/ok') + exchange.sendResponseHeaders(302, -1); exchange.close() + } + server.createContext('/ok') { exchange -> + assertEquals('Bearer test', exchange.requestHeaders.getFirst('Authorization')) + exchange.sendResponseHeaders(200, 2) + exchange.responseBody.withCloseable { it.write('ok'.bytes) } + } + server.createContext('/error') { exchange -> + exchange.sendResponseHeaders(422, 8) + exchange.responseBody.withCloseable { it.write('rejected'.bytes) } + } + server.start() + try { + def client = new Client() + String base = "http://127.0.0.1:${server.address.port}" + assertEquals('ok', client.get(base + '/redirect', '', '').body) + WsException error = assertThrows(WsException) { client.post(base + '/error', null, '', '') } + assertEquals(422, error.statusCode) + assertEquals('rejected', error.body) + } finally { server.stop(0) } + } + + @ParameterizedTest + @ValueSource(ints = [301, 302, 307, 308]) + void postRedirectsRejectMethodChangesAndPreserveBodies(int code) { + def server = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + def methods = new java.util.concurrent.CopyOnWriteArrayList() + def bodies = new java.util.concurrent.CopyOnWriteArrayList() + server.createContext('/redirect') { exchange -> + exchange.requestBody.readAllBytes() + exchange.responseHeaders.set('Location', '/target') + exchange.sendResponseHeaders(code, -1) + exchange.close() + } + server.createContext('/target') { exchange -> + methods.add(exchange.requestMethod) + bodies.add(new String(exchange.requestBody.readAllBytes(), 'UTF-8')) + exchange.sendResponseHeaders(200, 7) + exchange.responseBody.withCloseable { it.write('fake-id'.bytes) } + } + server.start() + File bundle = File.createTempFile('redirect-upload', '.zip') + bundle.text = 'bundle-content' + try { + String url = "http://127.0.0.1:${server.address.port}/redirect" + def client = new Client() + [ { client.post(url, 'status-payload'.bytes, '', '') }, + { client.postMultipart(url, bundle, '', '') } ].each { call -> + if (code in [301, 302]) { + WsException failure = assertThrows(WsException) { call() } + assertEquals(code, failure.statusCode) + assertTrue(failure.body.contains('converted to GET'), failure.message) + assertTrue(failure.body.contains('/target'), failure.message) + assertEquals('GET', methods.last()) + assertEquals('', bodies.last()) + } else { + assertEquals('fake-id', call().body) + assertEquals('POST', methods.last()) + assertTrue(bodies.last().contains('status-payload') || bodies.last().contains('bundle-content')) + } + } + } finally { bundle.delete(); server.stop(0) } + } + + @Test + void crossOriginPostRedirectReportsRedirectInsteadOfAuthenticationFailure() { + def source = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + def target = com.sun.net.httpserver.HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + target.createContext('/') { exchange -> + exchange.sendResponseHeaders(401, -1) + exchange.close() + } + source.createContext('/') { exchange -> + exchange.requestBody.readAllBytes() + exchange.responseHeaders.set('Location', "http://127.0.0.1:${target.address.port}/") + exchange.sendResponseHeaders(302, -1) + exchange.close() + } + source.start(); target.start() + File bundle = File.createTempFile('redirect-upload', '.zip') + try { + def client = new CentralPortalClient(org.gradle.api.logging.Logging.getLogger(getClass()), + "http://127.0.0.1:${source.address.port}", 'user', 'password') + def failure = assertThrows(org.gradle.api.GradleException) { client.upload(bundle) } + assertTrue(failure.message.contains('converted to GET'), failure.message) + assertFalse(failure.message.contains('Authentication failed'), failure.message) + assertEquals(302, ((WsException) failure.cause).statusCode) + } finally { bundle.delete(); source.stop(0); target.stop(0) } + } + + static void readHeaders(InputStream input) { + int state = 0 + byte[] end = '\r\n\r\n'.bytes + while (state < 4) { + int value = input.read() + if (value == -1) throw new EOFException() + state = value == end[state] ? state + 1 : (value == 13 ? 1 : 0) + } + } + @org.junit.jupiter.api.Test + void distinguishesConnectTimeoutFromOverallDeadline() { + def connect = new java.net.http.HttpConnectTimeoutException('connection stalled') + def failure = WsClient.timeoutFailure(connect, 1800000, 'http://localhost/') + assertEquals('Connect timed out: http://localhost/', failure.message) + assertSame(connect, failure.cause) + def overall = new java.net.http.HttpTimeoutException('request stalled') + failure = WsClient.timeoutFailure(overall, 1800000, 'http://localhost/') + assertTrue(failure.message.startsWith('Request exceeded overall deadline of '), failure.message) + assertSame(overall, failure.cause) + } + +} diff --git a/src/test/shell/publish-harness.sh b/src/test/shell/publish-harness.sh new file mode 100644 index 0000000..e668a52 --- /dev/null +++ b/src/test/shell/publish-harness.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +source_script=$1 +unset PUBLISH_HARNESS_UNSET +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +mkdir -p "$fixture/bin" +cp "$source_script" "$fixture/publish.sh" +cat > "$fixture/bin/git" <<'STUB' +#!/usr/bin/env bash +exit 0 +STUB +cat > "$fixture/bin/java" <<'STUB' +#!/usr/bin/env bash +exit 0 +STUB +cat > "$fixture/bin/jdk17" <<'STUB' +# The external helper is allowed to read an unset variable. +helper_value="$PUBLISH_HARNESS_UNSET" +JAVA_HOME="$HARNESS_FIXTURE" +printf 'sourced\n' >> "$HARNESS_FIXTURE/helper-log" +STUB +cat > "$fixture/gradlew" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$HARNESS_LOG" +if [[ "$*" == "${HARNESS_FAIL:-}" ]]; then exit 17; fi +STUB +chmod +x "$fixture/bin/git" "$fixture/bin/java" "$fixture/bin/jdk17" "$fixture/gradlew" +expected=('clean build' 'publishPlugins --validate-only' 'release' 'publishPlugins') +for failure in '' "${expected[@]}"; do + export HARNESS_FIXTURE="$fixture" HARNESS_LOG="$fixture/log" HARNESS_FAIL="$failure" + : > "$HARNESS_LOG" + : > "$fixture/helper-log" + result=0 + (cd "$fixture"; PATH="$fixture/bin:$PATH" bash publish.sh) || result=$? + [[ "$(cat "$fixture/helper-log")" == sourced ]] + count=4 + if [[ -n "$failure" ]]; then + [[ "$result" == 17 ]] + for i in "${!expected[@]}"; do + if [[ "${expected[$i]}" == "$failure" ]]; then count=$((i + 1)); fi + done + else + [[ "$result" == 0 ]] + fi + printf '%s\n' "${expected[@]:0:count}" > "$fixture/expected" + diff -u "$fixture/expected" "$HARNESS_LOG" +done