This is an automated email from the ASF dual-hosted git repository. royteeuwen pushed a commit to branch bugfix/SLING-13320-nexus-error-response in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git
commit 6b0bce74e03ff8244aef6b2c277e0cd2bc1c6569 Author: Roy Teeuwen <[email protected]> AuthorDate: Tue Aug 25 21:07:54 2026 +0200 SLING-13320 - handle a staging repository that no longer exists finalize promotes the staging repository in step 2 and Nexus drops it on release, so the website step in step 6 searched a repository that was already gone. Nexus answers that with an HTML error page, which parsed as JSON only produced "MalformedJsonException at line 2 column 4" and, being unchecked, took down the whole run after every irreversible step had already succeeded. * RepositoryService.getArtifacts checks the response status and reports the failure as an IOException naming the repository * UpdateLocalSiteCommand falls back to the released POMs on dist.apache.org when the staged ones cannot be read * FinalizeCommand treats the repository as gone once it has promoted it, and the website step no longer lets an unchecked exception fail finalize The mock Nexus now answers an unknown repository the way the real one does, so the regression test reproduces the reported exception exactly. --- .../sling/cli/impl/nexus/RepositoryService.java | 10 ++++++ .../sling/cli/impl/release/FinalizeCommand.java | 20 ++++++++---- .../cli/impl/release/UpdateLocalSiteCommand.java | 19 ++++++++--- .../cli/impl/nexus/QueryLuceneIndexHandler.java | 36 ++++++++++++++++++++- .../cli/impl/nexus/RepositoryServiceTest.java | 15 +++++++++ .../cli/impl/release/FinalizeCommandTest.java | 37 +++++++++++++++++++++- .../impl/release/UpdateLocalSiteCommandTest.java | 25 +++++++++++++++ 7 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/apache/sling/cli/impl/nexus/RepositoryService.java b/src/main/java/org/apache/sling/cli/impl/nexus/RepositoryService.java index 9ee49bf..6e86d61 100644 --- a/src/main/java/org/apache/sling/cli/impl/nexus/RepositoryService.java +++ b/src/main/java/org/apache/sling/cli/impl/nexus/RepositoryService.java @@ -245,6 +245,16 @@ public class RepositoryService { HttpGet get = newGet( "/service/local/lucene/search?g=org.apache.sling&repositoryId=" + repository.getRepositoryId()); try (CloseableHttpResponse response = client.execute(get)) { + // a repository Nexus does not know - typically one dropped right after promotion - is + // answered with an HTML error page, which parsed as JSON only yields an opaque + // MalformedJsonException (SLING-13320) + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != 200) { + throw new IOException(String.format( + "Got %d instead of 200 when searching for the artifacts of %s; Nexus answers 400 for" + + " a staging repository that no longer exists.", + statusCode, repository.getRepositoryId())); + } try (InputStream content = response.getEntity().getContent(); InputStreamReader reader = new InputStreamReader(content)) { JsonParser parser = new JsonParser(); diff --git a/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java b/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java index f6bba25..fc30ca0 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java @@ -214,8 +214,9 @@ public class FinalizeCommand implements Command { // resumable by --release. (PMC members only; auto-detected.) stepUpdateDistStage(repository, mode, isPmcMember); - // Step 2: Promote to Maven Central - stepPromoteStage(repository, mode); + // Step 2: Promote to Maven Central. Nexus drops the staging repository on release, so from here on + // it is gone and the later steps must treat it as such (SLING-13320). + repository = stepPromoteStage(repository, mode); // Step 3: Create next JIRA version and move unresolved issues (idempotent: skips if the successor // already exists / there are no unresolved issues left to move) @@ -250,8 +251,9 @@ public class FinalizeCommand implements Command { /** * Delegates to {@link UpdateLocalSiteCommand} so the site editing, committing and pushing flow lives in - * one place. A failure here is reported but does not fail finalize: everything irreversible has already - * succeeded by this point, and the website can be updated separately with {@code update-local-site}. + * one place. Any failure here - unchecked ones included - is reported but does not fail finalize: + * everything irreversible has already succeeded by this point, and the website can be updated separately + * with {@code update-local-site}. */ private void stepUpdateSite(StagingRepository repository, Set<Release> releases, ExecutionMode mode) { try { @@ -267,7 +269,7 @@ public class FinalizeCommand implements Command { LOGGER.warn( "The downloads page has no entry for {} — please add it by hand.", update.downloadsNotListed()); } - } catch (GitAPIException | IOException e) { + } catch (GitAPIException | IOException | RuntimeException e) { LOGGER.warn( "Failed to update the Sling website; run '{} {}' separately to complete it.", UpdateLocalSiteCommand.GROUP, @@ -292,17 +294,23 @@ public class FinalizeCommand implements Command { } } - private void stepPromoteStage(StagingRepository repository, ExecutionMode mode) throws IOException { + /** + * Promotes the staging repository and returns the repository the later steps may still use: {@code null} + * once it has been promoted, since Nexus drops it on release and every request against it then fails. + */ + private StagingRepository stepPromoteStage(StagingRepository repository, ExecutionMode mode) throws IOException { LOGGER.info("--- Step 2/6: Promote to Maven Central ---"); if (repository == null) { LOGGER.info("SKIPPED (staging repository already promoted and dropped)"); } else if (mode == ExecutionMode.DRY_RUN) { LOGGER.info("Would promote {} to Maven Central", repository.getRepositoryId()); + return repository; } else { LOGGER.info("Promoting {}...", repository.getRepositoryId()); repositoryService.promote(repository); LOGGER.info("Promoted. Artifacts will appear on Maven Central within ~10 minutes."); } + return null; } private void stepUpdateDist(StagingRepository repository, ExecutionMode mode) throws IOException { diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java index 0b5ce65..02f9186 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java @@ -247,14 +247,25 @@ public class UpdateLocalSiteCommand extends AbstractReleaseCommand { /** * Resolves the artifact ids of {@code release}, preferring the staged POMs and falling back to the - * released POMs on dist.apache.org so a run after promotion still works. + * released POMs on dist.apache.org so a run after promotion still works — whether the staging + * repository is unset because the run resumes by name, or gone because this run just promoted it. */ private static Set<String> resolveArtifactIds( RepositoryService repositoryService, StagingRepository repository, Release release) throws IOException { if (repository != null) { - Set<String> staged = repositoryService.getArtifactIds(repository, release); - if (!staged.isEmpty()) { - return new TreeSet<>(staged); + try { + Set<String> staged = repositoryService.getArtifactIds(repository, release); + if (!staged.isEmpty()) { + return new TreeSet<>(staged); + } + } catch (IOException e) { + // Nexus drops the repository on promotion, so a run that promoted moments ago no longer + // finds its staged POMs; the released ones answer the same question (SLING-13320) + LOGGER.info( + "Could not read the POMs staged in {} ({}); falling back to the released POMs on" + + " dist.apache.org.", + repository.getRepositoryId(), + e.getMessage()); } } List<String> candidates = DistRepository.listReleasePomFileNames(release.getVersion()); diff --git a/src/test/java/org/apache/sling/cli/impl/nexus/QueryLuceneIndexHandler.java b/src/test/java/org/apache/sling/cli/impl/nexus/QueryLuceneIndexHandler.java index a17855f..70b842b 100644 --- a/src/test/java/org/apache/sling/cli/impl/nexus/QueryLuceneIndexHandler.java +++ b/src/test/java/org/apache/sling/cli/impl/nexus/QueryLuceneIndexHandler.java @@ -19,10 +19,13 @@ package org.apache.sling.cli.impl.nexus; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.List; import com.sun.net.httpserver.HttpExchange; +import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.http.NameValuePair; import org.apache.http.client.utils.URLEncodedUtils; @@ -55,7 +58,38 @@ public class QueryLuceneIndexHandler implements HttpExchangeHandler { LOGGER.warn("Expected a group (g) parameter. Skipping handler."); return false; } - serveFileFromClasspath(ex, "/nexus/" + repositoryId + "/lucene.json"); + try (InputStream in = getClass().getResourceAsStream("/nexus/" + repositoryId + "/lucene.json")) { + if (in == null) { + serveRepositoryGone(ex); + return true; + } + ex.sendResponseHeaders(200, 0); + try (OutputStream out = ex.getResponseBody()) { + IOUtils.copy(in, out); + } + } return true; } + + /** + * Answers exactly like Nexus does for a repository it does not know - a staging repository dropped after + * promotion, most notably: HTTP 400 with an HTML error page rather than JSON (SLING-13320). + */ + private void serveRepositoryGone(HttpExchange ex) throws IOException { + byte[] body = ("<html>\n" + + " <head>\n" + + " <title>400 - Bad Request</title>\n" + + " </head>\n" + + " <body>\n" + + " <h1>400 - Bad Request</h1>\n" + + " <p>Repository to be searched does not exists!</p>\n" + + " </body>\n" + + "</html>") + .getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "text/html"); + ex.sendResponseHeaders(400, body.length); + try (OutputStream out = ex.getResponseBody()) { + out.write(body); + } + } } diff --git a/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryServiceTest.java b/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryServiceTest.java index 2acff01..7b4dde9 100644 --- a/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryServiceTest.java +++ b/src/test/java/org/apache/sling/cli/impl/nexus/RepositoryServiceTest.java @@ -88,6 +88,21 @@ public class RepositoryServiceTest { assertEquals(5, artifacts.size()); } + @Test + public void testLuceneSearchOfADroppedRepositoryFailsWithAReadableError() { + // Nexus answers an unknown repository - one dropped after promotion, typically - with an HTML + // error page; parsing that as JSON produced an opaque MalformedJsonException (SLING-13320) + StagingRepository dropped = new StagingRepository(); + dropped.setRepositoryId("orgapachesling-3121"); + try { + repositoryService.getArtifacts(dropped); + fail("Expected an IOException for a repository Nexus no longer knows."); + } catch (IOException e) { + assertTrue(e.getMessage(), e.getMessage().contains("400")); + assertTrue(e.getMessage(), e.getMessage().contains("orgapachesling-3121")); + } + } + @Test public void testRepositoryFind() throws IOException { StagingRepository stagingRepository = repositoryService.find(0); diff --git a/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java index 0b06b43..17fb881 100644 --- a/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java +++ b/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java @@ -70,6 +70,7 @@ public class FinalizeCommandTest { @Rule public final LogCapture logCapture = new LogCapture(FinalizeCommand.class); + private StagingRepository stagingRepository; private RepositoryService repositoryService; private VersionClient versionClient; private CloseableHttpClient client; @@ -97,7 +98,7 @@ public class FinalizeCommandTest { * a PMC member, which drives the dist.apache.org step. */ private void prepare(boolean pmcMember) throws Exception { - StagingRepository stagingRepository = mock(StagingRepository.class); + stagingRepository = mock(StagingRepository.class); when(stagingRepository.getRepositoryId()).thenReturn("orgapachesling-123"); when(stagingRepository.getDescription()).thenReturn("Apache Sling CLI Test 1.0.0"); @@ -222,6 +223,40 @@ public class FinalizeCommandTest { site.verify(() -> UpdateLocalSiteCommand.applySiteUpdate(any(), any(), eq(ExecutionMode.AUTO), any(), any())); } + @Test + public void testSiteUpdateDoesNotUseTheRepositoryPromoteJustDropped() throws Exception { + prepare(false); // non-PMC: the dist step is skipped, so this test needs no network + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + // Nexus drops the staging repository on release, so step 6 must resolve the artifact ids from + // dist.apache.org instead of searching a repository that is gone (SLING-13320) + site.verify(() -> UpdateLocalSiteCommand.updateLocalSite(any(), isNull(), any(), any())); + } + + @Test + public void testDryRunKeepsTheRepositoryForTheSiteStep() throws Exception { + prepare(false); + // nothing was promoted, so the staged POMs are still the best source for the artifact ids + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + site.verify(() -> UpdateLocalSiteCommand.updateLocalSite(any(), eq(stagingRepository), any(), any())); + } + + @Test + public void testSiteUpdateRuntimeFailureDoesNotFailFinalize() throws Exception { + prepare(false); + // an unchecked exception escaping step 6 used to fail the whole run after everything irreversible + // had already succeeded (SLING-13320) + site.when(() -> UpdateLocalSiteCommand.updateLocalSite(any(), any(), any(), any())) + .thenThrow(new com.google.gson.JsonSyntaxException("malformed JSON")); + + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + assertTrue(logCapture.containsMessage("Failed to update the Sling website")); + } + @Test public void testSiteUpdateFailureDoesNotFailFinalize() throws Exception { prepare(false); // non-PMC: the dist step is skipped, so this test needs no network diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java index f198e0c..998b643 100644 --- a/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java +++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java @@ -139,6 +139,31 @@ public class UpdateLocalSiteCommandTest { assertTrue(downloads().contains("\"Event|org.apache.sling.event|4.4.2|")); } + @Test + public void testAnUnreadableStagingRepositoryFallsBackToTheReleasedPoms() throws Exception { + // finalize promotes before updating the site, and Nexus drops the repository on release; the + // staged POMs are then unreachable, so the released ones must answer instead (SLING-13320) + RepositoryService repositoryService = mock(RepositoryService.class); + StagingRepository repository = mock(StagingRepository.class); + when(repository.getRepositoryId()).thenReturn("orgapachesling-3121"); + when(repositoryService.find(123)).thenReturn(repository); + when(repositoryService.getReleases(repository)) + .thenReturn(Set.copyOf(Release.fromString("Apache Sling Event Impl 4.4.2"))); + when(repositoryService.getArtifactIds(eq(repository), any())) + .thenThrow(new IOException("Got 400 instead of 200 for orgapachesling-3121")); + when(repositoryService.getArtifactIdsFromPomUrls(any(), any(), any())) + .thenReturn(Set.of("org.apache.sling.event")); + registerServices(repositoryService); + + try (MockedStatic<DistRepository> dist = stubDist("4.4.2")) { + Command command = createCommand(123, null, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + } + + assertTrue(logCapture.containsMessage("falling back to the released POMs on dist.apache.org")); + assertTrue(downloads().contains("\"Event|org.apache.sling.event|4.4.2|")); + } + @Test public void testMaintenanceReleaseOfAnOlderMajorLeavesTheDownloadsPageAlone() throws Exception { // the fixture lists Resource Resolver at 1.6.6; releasing 2.0.0 must not rewrite that entry
