This is an automated email from the ASF dual-hosted git repository. royteeuwen pushed a commit to branch feature/SLING-13253-finalize-site-update in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git
commit e69033c458d76d78ec91aa70f04b1e0730cd55dc Author: Roy Teeuwen <[email protected]> AuthorDate: Tue Aug 4 17:26:43 2026 +0200 SLING-13253 - finalize: update the Sling website as the last step Promoting a release also requires updating the website, which finalize did not do. UpdateLocalSiteCommand already edited releases.md and downloads.tpl but only printed a diff, so nothing ever landed. finalize now orchestrates that command as step 6/6, reusing its editing and commit/push helpers rather than reimplementing them, following the existing planDistRelease/publishToDistRelease pattern. The checkout is cloned from gitbox so the ASF credentials that already commit to dist.apache.org can push. downloads.tpl entries are now matched on the artifact id instead of the display name. The two routinely differ (Tracer is listed as Log Tracer, Commons Mime as Commons Mime Type Service) and one release can own several entries (Testing OSGi Mock has .core/.junit4/.junit5), so name matching silently did nothing for roughly a third of releases. Artifact ids come from the staged POMs, or from the released POMs in dist/release when the staging repository has already been dropped, so this works before and after promotion. Only entries on the same major version are rewritten. dist/release keeps several major streams published while the downloads page lists only the latest, so matching on the artifact id alone would have turned a Resource Resolver 1.12.x maintenance release into a downgrade of the 2.x entry. The news page stays a separate, manual command: the release guide only asks for a news entry when a release warrants an announcement. --- README.md | 22 +- .../sling/cli/impl/jbake/JBakeContentUpdater.java | 166 ++++++++++++++ .../org/apache/sling/cli/impl/nexus/PomParser.java | 38 ++++ .../sling/cli/impl/nexus/RepositoryService.java | 48 +++- .../sling/cli/impl/release/FinalizeCommand.java | 47 +++- .../sling/cli/impl/release/TallyVotesCommand.java | 3 +- .../sling/cli/impl/release/UpdateDistCommand.java | 11 + .../cli/impl/release/UpdateLocalSiteCommand.java | 253 ++++++++++++++++++--- .../sling/cli/impl/release/UpdateNewsCommand.java | 132 +++++++++++ .../cli/impl/jbake/JBakeContentUpdaterTest.java | 150 ++++++++++++ .../cli/impl/release/FinalizeCommandTest.java | 51 ++++- .../cli/impl/release/TallyVotesCommandTest.java | 3 + .../impl/release/UpdateLocalSiteCommandTest.java | 211 +++++++++++++++-- src/test/resources/news.md | 10 + 14 files changed, 1079 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 8f17c15..b5a46ad 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,8 @@ After `release:perform` has staged the artifacts, drive the rest with the CLI: docker run --env-file=./docker-env apache/sling-cli release tally-votes --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO 5. **Finalize** the release (post successful vote). This runs, in order: promote to Maven Central, - create the next Jira version, release the current Jira version, and update the Apache Reporter: + create the next Jira version, release the current Jira version, update the Apache Reporter, and + update the Sling website: docker run --env-file=./docker-env apache/sling-cli release finalize --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO @@ -205,6 +206,20 @@ After `release:perform` has staged the artifacts, drive the rest with the CLI: PMC membership is determined from your ASF id (via Whimsy). A non-PMC committer's `finalize` skips the dist upload and the `tally-votes` result email asks a PMC member to perform it. + The last step updates the website: it adds the release to `content/releases.md` and bumps the + matching entries in `templates/downloads.tpl`, then commits and pushes to `sling-site` over gitbox + using the same ASF credentials. Entries are matched on the *artifact id* rather than on the display + name, because the two often differ (*Tracer* is listed as *Log Tracer*) and one release can own + several entries. Only entries on the same major version are touched, so a maintenance release of an + older line (e.g. Resource Resolver 1.12.x while the page lists 2.x) never downgrades the page. If an + artifact has no entry at all it is reported so it can be added by hand, which is also what the + release guide asks for when a brand new module is released. + + The news page is deliberately *not* part of `finalize` — the release guide only asks for a news entry + when a release warrants an announcement. Run it by hand for those: + + docker run --env-file=./docker-env apache/sling-cli release update-news --release "Apache Sling Foo 1.2.0" --link /documentation/bundles/foo.html --execution-mode=AUTO + If the vote does not pass, **drop** the staging repository: docker run --env-file=./docker-env apache/sling-cli release drop --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO @@ -220,12 +235,13 @@ If the vote does not pass, **drop** the staging repository: | `release tally-votes -r <id>` | Count votes and generate the `[RESULT]` email (PMC membership auto-detected; non-PMC email asks a PMC member to do the dist upload) | | `release promote -r <id>` | Promote a closed staging repo to Maven Central | | `release update-dist -r <id>` | Move artifacts to `dist.apache.org` (PMC only); previous version auto-deduced, override with `--previous-version <v>` | -| `release finalize -r <id>` | Promote + Jira + Reporter in one step; also updates `dist.apache.org` when you are a PMC member | +| `release finalize -r <id>` | Promote + Jira + Reporter + website in one step; also updates `dist.apache.org` when you are a PMC member | | `release drop -r <id>` | Drop a staging repository (failed vote / cleanup) | | `release create-new-jira-version -r <id>` | Create the next Jira version and move unresolved issues | | `release release-jira-version -r <id>` | Mark the Jira version as released and close fixed issues | | `release update-reporter -r <id>` | Register the release with the Apache Reporter System | -| `release update-local-site -r <id>` | Generate the website update (diff only for now) | +| `release update-local-site -r <id>` | Update `releases.md` and `downloads.tpl` in a `sling-site` checkout, then commit and push | +| `release update-news -r <id>` | Announce a release on the news page; run only for releases worth announcing (not part of `finalize`) | ## Assumptions diff --git a/src/main/java/org/apache/sling/cli/impl/jbake/JBakeContentUpdater.java b/src/main/java/org/apache/sling/cli/impl/jbake/JBakeContentUpdater.java index 3cfec67..78db324 100644 --- a/src/main/java/org/apache/sling/cli/impl/jbake/JBakeContentUpdater.java +++ b/src/main/java/org/apache/sling/cli/impl/jbake/JBakeContentUpdater.java @@ -35,6 +35,12 @@ public class JBakeContentUpdater { private static final Pattern DOWNLOAD_LINE_PATTERN = Pattern.compile("^.*\"([a-zA-Z\\s\\-]+)\\|([a-zA-Z\\.\\-]+)\\|([0-9\\.\\-]+).*$"); + /** + * A version column: starts with a digit and contains only version characters. Deliberately does not + * match a Groovy interpolation such as {@code ${starterVersion}}, which must never be rewritten. + */ + private static final Pattern VERSION_COLUMN = Pattern.compile("^[0-9][0-9A-Za-z.\\-]*$"); + public int updateDownloads(Path downloadsTemplatePath, String newReleaseName, String newReleaseVersion) throws IOException { @@ -63,6 +69,123 @@ public class JBakeContentUpdater { return changeCount[0]; } + /** + * Updates the version of every {@code downloads.tpl} entry that declares {@code artifactId}. + * + * <p>Matching on the artifact id rather than on the human-readable first column is what makes this + * reliable: the display name regularly differs from the released component name (<em>Tracer</em> is + * listed as <em>Log Tracer</em>, <em>Commons Mime</em> as <em>Commons Mime Type Service</em>), and a + * single release can own several entries (<em>Testing OSGi Mock</em> has {@code .core}, {@code .junit4} + * and {@code .junit5} rows). The artifact id is also what the site's own Renovate configuration keys on. + * + * <p>The columns are split rather than matched as a whole, because entries come in two layouts — with + * and without a description column, and sometimes with a file-extension column between the artifact id + * and the version. The version is therefore the first version-shaped column <em>after</em> the artifact + * id, which holds for every layout in use. + * + * <p>Only entries whose current version has the <em>same major version</em> as {@code newReleaseVersion} + * are rewritten. The downloads page lists a single, latest-major entry per artifact while + * {@code dist/release} keeps several major streams published side by side — {@code resourceresolver} is + * listed at {@code 2.0.2} while {@code 1.12.16} is still distributed. Rewriting on artifact id alone + * would therefore turn a {@code 1.12.18} maintenance release into a downgrade of the {@code 2.x} entry. + */ + public DownloadsUpdate updateDownloadsByArtifactId( + Path downloadsTemplatePath, String artifactId, String newReleaseVersion) throws IOException { + + int[] updated = new int[1]; + int[] otherMajor = new int[1]; + + List<String> updatedLines = Files.readAllLines(downloadsTemplatePath, StandardCharsets.UTF_8).stream() + .map(line -> { + String rewritten = updateDownloadsLine(line, artifactId, newReleaseVersion, otherMajor); + if (rewritten != null) { + updated[0]++; + return rewritten; + } + return line; + }) + .collect(Collectors.toList()); + + Files.write(downloadsTemplatePath, updatedLines); + + return new DownloadsUpdate(updated[0], otherMajor[0]); + } + + /** + * The outcome of a {@code downloads.tpl} update. + * + * @param updated entries rewritten to the new version + * @param skippedOtherMajor entries for the same artifact left alone because they track another major + * version; a non-zero count with {@code updated == 0} means the release is a + * maintenance release of an older line, which the downloads page does not list + */ + public record DownloadsUpdate(int updated, int skippedOtherMajor) { + + /** {@code true} when the artifact is not listed on the downloads page at all. */ + public boolean notListed() { + return updated == 0 && skippedOtherMajor == 0; + } + } + + /** + * Returns the rewritten line, or {@code null} when it does not declare {@code artifactId}, already + * carries the new version, or tracks a different major version (counted in {@code otherMajor}). + */ + private String updateDownloadsLine(String line, String artifactId, String newReleaseVersion, int[] otherMajor) { + int quoteStart = line.indexOf('"'); + if (quoteStart == -1) { + return null; + } + int quoteEnd = line.indexOf('"', quoteStart + 1); + if (quoteEnd == -1) { + return null; + } + String[] columns = line.substring(quoteStart + 1, quoteEnd).split("\\|", -1); + + int artifactColumn = -1; + for (int i = 0; i < columns.length; i++) { + if (columns[i].equals(artifactId)) { + artifactColumn = i; + break; + } + } + if (artifactColumn == -1) { + return null; + } + for (int i = artifactColumn + 1; i < columns.length; i++) { + if (!VERSION_COLUMN.matcher(columns[i]).matches()) { + continue; + } + if (!sameMajor(columns[i], newReleaseVersion)) { + otherMajor[0]++; + return null; + } + if (columns[i].equals(newReleaseVersion)) { + return null; // already up to date; do not report a change + } + columns[i] = newReleaseVersion; + return line.substring(0, quoteStart + 1) + String.join("|", columns) + line.substring(quoteEnd); + } + return null; + } + + /** + * Compares the leading numeric segment of two versions. Handles the composite versions some Sling + * modules use, where the Sling version is combined with the version of what it wraps, e.g. + * {@code 4.1.0-1.86.0} for Testing Sling Mock Oak. + */ + private static boolean sameMajor(String currentVersion, String newVersion) { + return majorOf(currentVersion).equals(majorOf(newVersion)); + } + + private static String majorOf(String version) { + int end = 0; + while (end < version.length() && Character.isDigit(version.charAt(end))) { + end++; + } + return version.substring(0, end); + } + public void updateReleases(Path releasesPath, String releaseName, String releaseVersion, LocalDateTime releaseTime) throws IOException { @@ -125,6 +248,49 @@ public class JBakeContentUpdater { Files.write(releasesPath, releasesLines); } + /** + * Prepends a release announcement to the news page, directly above the existing entries. + * + * <p>Unlike the releases list, the news page is deliberately not maintained for every release — only + * releases worth announcing are listed — so this is never run as part of finalizing a release. + * + * @param link an optional page the announcement should link to, e.g. {@code /news/sling-14-released.html} + * @return {@code true} if the entry was added, {@code false} if the news page already announces it + */ + public boolean updateNews(Path newsPath, String releaseFullName, String link, LocalDateTime releaseTime) + throws IOException { + + List<String> newsLines = Files.readAllLines(newsPath, StandardCharsets.UTF_8); + + String subject = (link == null || link.isBlank()) ? releaseFullName : "[" + releaseFullName + "](" + link + ")"; + String entry = "* Released " + subject + " (" + + releaseTime.format(DateTimeFormatter.ofPattern("MMMM", Locale.ENGLISH)) + " " + + formattedDay(releaseTime) + ", " + + releaseTime.format(DateTimeFormatter.ofPattern("uuuu", Locale.ENGLISH)) + ")."; + + // an existing announcement may or may not be a link, so match on the release name rather than on + // the rendered entry + if (newsLines.stream().anyMatch(l -> l.startsWith("* Released ") && l.contains(releaseFullName))) { + return false; + } + + int firstEntryIdx = -1; + for (int i = 0; i < newsLines.size(); i++) { + if (newsLines.get(i).startsWith("* ")) { + firstEntryIdx = i; + break; + } + } + if (firstEntryIdx == -1) { + newsLines.add(entry); + } else { + newsLines.add(firstEntryIdx, entry); + } + + Files.write(newsPath, newsLines); + return true; + } + private String formattedDay(LocalDateTime releaseTime) { String date = releaseTime.format(DateTimeFormatter.ofPattern("d", Locale.ENGLISH)); switch (date) { diff --git a/src/main/java/org/apache/sling/cli/impl/nexus/PomParser.java b/src/main/java/org/apache/sling/cli/impl/nexus/PomParser.java index 1dfb7bd..d91d2bd 100644 --- a/src/main/java/org/apache/sling/cli/impl/nexus/PomParser.java +++ b/src/main/java/org/apache/sling/cli/impl/nexus/PomParser.java @@ -134,6 +134,44 @@ class PomParser { return Set.copyOf(releases); } + /** + * Returns the artifact ids among {@code poms} that belong to {@code release}. + * + * <p>A module's own {@code <name>} identifies it only when it is the release itself. In a multi-module + * reactor the children carry their own names — <em>Apache Sling Testing Sling Mock Core</em> is not the + * release <em>Apache Sling Testing Sling Mock</em> — so they are collected through the {@code <parent>} + * chain instead, which is the same relationship {@link #toReleases(List)} uses to fold a reactor into a + * single release. + */ + static Set<String> artifactIdsFor(List<PomCoordinates> poms, Release release) { + Set<String> roots = poms.stream() + .filter(p -> buildReleases(p).contains(release)) + .map(PomCoordinates::ownKey) + .filter(k -> k != null) + .collect(Collectors.toSet()); + if (roots.isEmpty()) { + return Set.of(); + } + // walk down the parent chain until no further module is pulled in, so grandchildren are included too + Set<String> family = new HashSet<>(roots); + boolean grown = true; + while (grown) { + grown = false; + for (PomCoordinates pom : poms) { + String key = pom.ownKey(); + if (key != null && !family.contains(key) && family.contains(pom.parentKey())) { + family.add(key); + grown = true; + } + } + } + return poms.stream() + .filter(p -> p.ownKey() != null && family.contains(p.ownKey())) + .map(PomCoordinates::artifactId) + .filter(a -> a != null && !a.isBlank()) + .collect(Collectors.toSet()); + } + private static Set<Release> buildReleases(PomCoordinates pom) { try { return new HashSet<>(Release.fromString(pom.name() + " " + pom.version())); 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 93f4813..80f7fdd 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 @@ -24,6 +24,7 @@ import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -292,6 +293,19 @@ public class RepositoryService { } public Set<Release> getReleases(StagingRepository stagingRepository) throws IOException { + return PomParser.toReleases(readStagedPoms(stagingRepository)); + } + + /** + * Returns the artifact ids belonging to {@code release}, resolved from the POMs staged in + * {@code stagingRepository}. Used to key website updates on the artifact id rather than on the + * human-readable component name, which frequently differs from what the site lists. + */ + public Set<String> getArtifactIds(StagingRepository stagingRepository, Release release) throws IOException { + return PomParser.artifactIdsFor(readStagedPoms(stagingRepository), release); + } + + private List<PomParser.PomCoordinates> readStagedPoms(StagingRepository stagingRepository) throws IOException { List<PomParser.PomCoordinates> poms = new ArrayList<>(); getArtifacts(stagingRepository).stream() .filter(artifact -> "pom".equals(artifact.getType())) @@ -307,7 +321,39 @@ public class RepositoryService { LOGGER.error(String.format("Unable to process artifact %s.", pom), e); } }); - return PomParser.toReleases(poms); + return poms; + } + + /** + * Resolves the artifact ids belonging to {@code release} by reading the released POMs published at + * {@code baseUrl} (dist.apache.org). Used when the staging repository is gone — after promotion it is + * dropped, so a resumed run has no staged POMs to consult, but the released ones are still available. + * + * <p>{@code pomFileNames} are the {@code <artifactId>-<version>.pom} names already known to carry the + * release's version. Several unrelated artifacts share a version (29 different artifacts sit at + * {@code 1.0.0}), so the version alone does not identify the release; each candidate POM is read and + * matched on its {@code <name>}, which is exactly what the release name was derived from. + */ + public Set<String> getArtifactIdsFromPomUrls(String baseUrl, Collection<String> pomFileNames, Release release) + throws IOException { + List<PomParser.PomCoordinates> poms = new ArrayList<>(); + try (CloseableHttpClient client = httpClientFactory.newClient()) { + for (String pomFileName : pomFileNames) { + HttpGet get = new HttpGet(baseUrl + pomFileName); + try (CloseableHttpResponse response = client.execute(get)) { + if (response.getStatusLine().getStatusCode() != 200) { + continue; + } + try (InputStream stream = response.getEntity().getContent()) { + PomParser.PomCoordinates coordinates = pomParser.parse(stream, pomFileName); + if (coordinates != null) { + poms.add(coordinates); + } + } + } + } + } + return PomParser.artifactIdsFor(poms, release); } /** 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 69f15fd..70bc6d2 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 @@ -33,6 +33,7 @@ import org.apache.sling.cli.impl.jira.VersionClient; import org.apache.sling.cli.impl.nexus.RepositoryService; import org.apache.sling.cli.impl.nexus.StagingRepository; import org.apache.sling.cli.impl.people.MembersFinder; +import org.eclipse.jgit.api.errors.GitAPIException; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; @@ -51,6 +52,7 @@ import picocli.CommandLine; * <li>Create the next JIRA version and move unresolved issues</li> * <li>Mark the current JIRA version as released</li> * <li>Update the Apache Reporter System</li> + * <li>Update the Sling website: the releases list and the downloads page</li> * </ol> * The only repository-dependent step (dist.apache.org) runs <em>before</em> promotion, which drops the * staging repository; every later step is repository-independent. This makes finalize <em>resumable</em>: @@ -75,7 +77,7 @@ import picocli.CommandLine; @CommandLine.Command( name = FinalizeCommand.NAME, description = "Runs all post-vote finalization steps, in order: update dist.apache.org (PMC members only)," - + " promote to Maven Central, update JIRA, and report to Apache.", + + " promote to Maven Central, update JIRA, report to Apache, and update the Sling website.", subcommands = CommandLine.HelpCommand.class) public class FinalizeCommand implements Command { @@ -174,7 +176,7 @@ public class FinalizeCommand implements Command { return null; } - /** Runs the pre-flight check and the five finalize steps for an already-resolved, non-empty target. */ + /** Runs the pre-flight check and the six finalize steps for an already-resolved, non-empty target. */ private Integer runFinalize(FinalizeTarget target, ExecutionMode mode) throws Exception { StagingRepository repository = target.repository(); Set<Release> releases = target.releases(); @@ -213,19 +215,19 @@ public class FinalizeCommand implements Command { // 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) - LOGGER.info("--- Step 3/5: Create next JIRA version ---"); + LOGGER.info("--- Step 3/6: Create next JIRA version ---"); for (Release release : releases) { stepCreateNextJiraVersion(release, mode); } // Step 4: Mark JIRA version as released (idempotent: release() skips an already-released version) - LOGGER.info("--- Step 4/5: Release JIRA version ---"); + LOGGER.info("--- Step 4/6: Release JIRA version ---"); for (Release release : releases) { stepReleaseJiraVersion(release, stagedAt, mode); } // Step 5: Update Apache Reporter (idempotent: skips releases the reporter already lists) - LOGGER.info("--- Step 5/5: Update Apache Reporter ---"); + LOGGER.info("--- Step 5/6: Update Apache Reporter ---"); if (mode == ExecutionMode.DRY_RUN) { LOGGER.info("Would add {} release(s) to the Apache Reporter System", releases.size()); releases.forEach(r -> LOGGER.info(" - {}", r.getFullName())); @@ -233,18 +235,47 @@ public class FinalizeCommand implements Command { stepUpdateReporter(releases); } + // Step 6: Update the Sling website (releases list and downloads page). Last, because it is the only + // step that is safe to redo at any time and the only one a non-committer cannot break anything with. + LOGGER.info("--- Step 6/6: Update the Sling website ---"); + stepUpdateSite(repository, releases, mode); + LOGGER.info("=== Release finalization complete! ==="); return CommandLine.ExitCode.OK; } + /** + * 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}. + */ + private void stepUpdateSite(StagingRepository repository, Set<Release> releases, ExecutionMode mode) { + try { + UpdateLocalSiteCommand.SiteUpdate update = + UpdateLocalSiteCommand.updateLocalSite(repositoryService, repository, releases); + UpdateLocalSiteCommand.applySiteUpdate( + update, mode, credentialsService.getAsfCredentials(), membersFinder.getCurrentMember()); + if (!update.downloadsNotListed().isEmpty()) { + LOGGER.warn( + "The downloads page has no entry for {} — please add it by hand.", update.downloadsNotListed()); + } + } catch (GitAPIException | IOException e) { + LOGGER.warn( + "Failed to update the Sling website; run '{} {}' separately to complete it.", + UpdateLocalSiteCommand.GROUP, + UpdateLocalSiteCommand.NAME, + e); + } + } + private void stepUpdateDistStage(StagingRepository repository, ExecutionMode mode, boolean isPmcMember) throws IOException { if (!isPmcMember) { - LOGGER.info("--- Step 1/5: Update dist.apache.org --- SKIPPED (current user is not a PMC member;" + LOGGER.info("--- Step 1/6: Update dist.apache.org --- SKIPPED (current user is not a PMC member;" + " a PMC member must update dist.apache.org separately) ---"); return; } - LOGGER.info("--- Step 1/5: Update dist.apache.org ---"); + LOGGER.info("--- Step 1/6: Update dist.apache.org ---"); if (repository == null) { LOGGER.info("SKIPPED (staging repository already promoted; if dist still needs updating a PMC" + " member must run update-dist separately)"); @@ -254,7 +285,7 @@ public class FinalizeCommand implements Command { } private void stepPromoteStage(StagingRepository repository, ExecutionMode mode) throws IOException { - LOGGER.info("--- Step 2/5: Promote to Maven Central ---"); + 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) { diff --git a/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java b/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java index d772a83..ba59320 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java @@ -95,7 +95,8 @@ public class TallyVotesCommand implements Command { + " 2. promote the staged artifacts to the central Maven repository\n" + " 3. create the next JIRA version and move any unresolved issues to it\n" + " 4. mark the JIRA version as released\n" - + " 5. add the release to the Apache Reporter System"; + + " 5. add the release to the Apache Reporter System\n" + + " 6. update the Sling website: the releases list and the downloads page"; private static final String EMAIL_TEMPLATE; diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java index 76d45dc..62c45f8 100644 --- a/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java @@ -390,6 +390,17 @@ public class UpdateDistCommand implements Command { return next == '.' || next == '-'; } + /** + * Returns the names of every {@code .pom} published in {@code dist/release} for {@code version}, across + * all artifacts. Used to resolve a release's artifact ids from the released POMs once the staging + * repository is gone; the caller narrows the candidates down by reading each POM's {@code <name>}. + */ + static List<String> listReleasePomFileNames(String version) throws IOException { + return listDistFiles(DIST_RELEASE_URL, "").stream() + .filter(f -> f.endsWith("-" + version + ".pom")) + .toList(); + } + static List<String> listDistFiles(String baseUrl, String prefix) throws IOException { List<String> files = new ArrayList<>(); try { 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 93c8f8a..fa193ac 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 @@ -23,21 +23,46 @@ import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.InputOption; +import org.apache.sling.cli.impl.UserInput; import org.apache.sling.cli.impl.jbake.JBakeContentUpdater; import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.cli.impl.people.Member; +import org.apache.sling.cli.impl.people.MembersFinder; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ResetCommand.ResetType; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.TextProgressMonitor; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import picocli.CommandLine; +/** + * Updates the Sling website with new release information, in a local checkout of the site repository. + * + * <p>Two files make up the website side of promoting a release: {@code content/releases.md}, the + * chronological list of every release, and {@code templates/downloads.tpl}, which drives the downloads + * page. Both are edited here and committed together, matching how the site is maintained by hand. + * + * <p>The downloads page is keyed on the <em>artifact id</em> rather than on the release's component name, + * because the two routinely differ (<em>Tracer</em> is listed as <em>Log Tracer</em>) and one release can + * own several entries. Artifact ids come from the staged POMs when a staging repository is given, and from + * the released POMs on dist.apache.org otherwise, so the command works before and after promotion. + */ @Component( service = Command.class, property = { @@ -46,62 +71,238 @@ import picocli.CommandLine; }) @CommandLine.Command( name = UpdateLocalSiteCommand.NAME, - description = "Updates the Sling website with the new release information, " + "based on a local checkout", + description = "Updates the Sling website with the new release information, based on a local checkout", subcommands = CommandLine.HelpCommand.class) public class UpdateLocalSiteCommand extends AbstractReleaseCommand { static final String GROUP = "release"; static final String NAME = "update-local-site"; - private static final String GIT_CHECKOUT = "/tmp/sling-site"; + static final String GIT_CHECKOUT = "/tmp/sling-site"; + + /** Cloned over https from gitbox so the same ASF credentials that commit to dist.apache.org can push. */ + static final String SITE_GIT_URL = "https://gitbox.apache.org/repos/asf/sling-site.git"; + + private static final Logger LOGGER = LoggerFactory.getLogger(UpdateLocalSiteCommand.class); @Reference private RepositoryService repositoryService; - private final Logger logger = LoggerFactory.getLogger(getClass()); + @Reference + private CredentialsService credentialsService; + + @Reference + private MembersFinder membersFinder; + + @CommandLine.Mixin + private ReusableCLIOptions reusableCLIOptions; @Override public Integer call() { try { - ensureRepo(); - try (Git git = Git.open(new File(GIT_CHECKOUT))) { + Set<Release> releases = resolveReleases(repositoryService); + if (releases.isEmpty()) { + LOGGER.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } + StagingRepository repository = repositoryId != null ? repositoryService.find(repositoryId) : null; - Set<Release> releases = resolveReleases(repositoryService); - if (releases.isEmpty()) { - logger.error("Provide either --repository or --release."); - return CommandLine.ExitCode.USAGE; - } + SiteUpdate update = updateLocalSite(repositoryService, repository, releases); + applySiteUpdate( + update, + reusableCLIOptions.executionMode, + credentialsService.getAsfCredentials(), + membersFinder.getCurrentMember()); + } catch (GitAPIException | IOException e) { + LOGGER.warn("Failed executing command", e); + return CommandLine.ExitCode.SOFTWARE; + } + return CommandLine.ExitCode.OK; + } - JBakeContentUpdater updater = new JBakeContentUpdater(); + /** + * The result of editing the site checkout. + * + * @param hasChanges whether the checkout has anything to commit + * @param releaseNames the releases the edit covered, for the commit message + * @param downloadsNotListed releases with no downloads-page entry at all, which a human must add; + * releases skipped because the page tracks another major version are not + * listed here, since for those there is legitimately nothing to do + */ + record SiteUpdate(boolean hasChanges, String releaseNames, List<String> downloadsNotListed) {} - Path templatePath = Paths.get(GIT_CHECKOUT, "src", "main", "jbake", "templates", "downloads.tpl"); - Path releasesPath = Paths.get(GIT_CHECKOUT, "src", "main", "jbake", "content", "releases.md"); - LocalDateTime now = LocalDateTime.now(); - for (Release release : releases) { - updater.updateDownloads(templatePath, release.getComponent(), release.getVersion()); - updater.updateReleases(releasesPath, release.getComponent(), release.getVersion(), now); - } + /** + * Clones or refreshes the site checkout and applies the release information to {@code releases.md} and + * {@code downloads.tpl}. Nothing is committed; shared with {@link FinalizeCommand} so the editing flow + * is not duplicated there. + */ + static SiteUpdate updateLocalSite( + RepositoryService repositoryService, StagingRepository repository, Set<Release> releases) + throws GitAPIException, IOException { + + ensureRepo(); + JBakeContentUpdater updater = new JBakeContentUpdater(); + Path templatePath = Paths.get(GIT_CHECKOUT, "src", "main", "jbake", "templates", "downloads.tpl"); + Path releasesPath = Paths.get(GIT_CHECKOUT, "src", "main", "jbake", "content", "releases.md"); + + List<String> notListed = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + + for (Release release : releases) { + updater.updateReleases(releasesPath, release.getComponent(), release.getVersion(), now); + updateDownloadsFor(repositoryService, repository, release, updater, templatePath, notListed); + } + + String releaseNames = + releases.stream().map(Release::getFullName).sorted().collect(Collectors.joining(", ")); + + try (Git git = Git.open(new File(GIT_CHECKOUT))) { + git.diff().setOutputStream(System.out).call(); + boolean hasChanges = !git.status().call().isClean(); + return new SiteUpdate(hasChanges, releaseNames, notListed); + } + } + + /** Updates every downloads-page entry belonging to {@code release}, recording why nothing changed. */ + private static void updateDownloadsFor( + RepositoryService repositoryService, + StagingRepository repository, + Release release, + JBakeContentUpdater updater, + Path templatePath, + List<String> notListed) + throws IOException { - git.diff().setOutputStream(System.out).call(); + Set<String> artifactIds = resolveArtifactIds(repositoryService, repository, release); + if (artifactIds.isEmpty()) { + LOGGER.warn( + "Could not determine the artifact id(s) for {}; downloads.tpl not updated for it.", + release.getFullName()); + notListed.add(release.getFullName()); + return; + } + + int updated = 0; + int otherMajor = 0; + for (String artifactId : artifactIds) { + JBakeContentUpdater.DownloadsUpdate result = + updater.updateDownloadsByArtifactId(templatePath, artifactId, release.getVersion()); + updated += result.updated(); + otherMajor += result.skippedOtherMajor(); + } + + if (updated > 0) { + LOGGER.info("Updated {} downloads.tpl entry/entries for {}", updated, release.getFullName()); + } else if (otherMajor > 0) { + // dist.apache.org keeps several major streams published while the downloads page lists only the + // latest; a maintenance release of an older line therefore has nothing to update here + LOGGER.info( + "downloads.tpl lists {} only for another major version; leaving it unchanged for {}.", + artifactIds, + release.getFullName()); + } else { + LOGGER.warn( + "downloads.tpl has no entry for {} ({}); it may need to be added by hand.", + release.getFullName(), + artifactIds); + notListed.add(release.getFullName()); + } + } + + /** + * 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. + */ + 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); } - } catch (GitAPIException | IOException e) { - logger.warn("Failed executing command", e); - return CommandLine.ExitCode.SOFTWARE; } - return CommandLine.ExitCode.OK; + List<String> candidates = UpdateDistCommand.listReleasePomFileNames(release.getVersion()); + if (candidates.isEmpty()) { + return Set.of(); + } + return new TreeSet<>( + repositoryService.getArtifactIdsFromPomUrls(UpdateDistCommand.DIST_RELEASE_URL, candidates, release)); + } + + /** Commits and pushes the site update, honouring the execution mode. */ + static void applySiteUpdate(SiteUpdate update, ExecutionMode mode, Credentials credentials, Member author) + throws GitAPIException, IOException { + + if (!update.hasChanges()) { + LOGGER.info("The Sling website is already up to date; nothing to commit."); + return; + } + commitAndPushSiteChanges( + "Released " + update.releaseNames(), + "Commit the website changes above and push to sling-site?", + mode, + credentials, + author); + } + + /** + * Commits everything staged under the site content directory and pushes it, honouring the execution + * mode. Shared by every command that edits the site checkout. + */ + static void commitAndPushSiteChanges( + String message, String confirmQuestion, ExecutionMode mode, Credentials credentials, Member author) + throws GitAPIException, IOException { + switch (mode) { + case DRY_RUN: + LOGGER.info( + "Would commit the changes above to {} with message \"{}\" and push.", SITE_GIT_URL, message); + break; + case INTERACTIVE: + if (InputOption.YES.equals(UserInput.yesNo(confirmQuestion, InputOption.YES))) { + commitAndPush(message, credentials, author); + } else { + LOGGER.info("Aborted; the changes are left in {}.", GIT_CHECKOUT); + } + break; + case AUTO: + commitAndPush(message, credentials, author); + break; + } + } + + private static void commitAndPush(String message, Credentials credentials, Member author) + throws GitAPIException, IOException { + try (Git git = Git.open(new File(GIT_CHECKOUT))) { + git.add().addFilepattern("src/main/jbake").call(); + git.commit() + .setMessage(message) + .setAuthor(author.getName(), author.getEmail()) + .call(); + git.push() + .setCredentialsProvider(new UsernamePasswordCredentialsProvider( + credentials.getUsername(), credentials.getPassword())) + .setProgressMonitor(new TextProgressMonitor()) + .call(); + LOGGER.info("Pushed the website update to {}.", SITE_GIT_URL); + } } - private void ensureRepo() throws GitAPIException, IOException { + /** + * Makes sure {@link #GIT_CHECKOUT} holds a clean checkout at the tip of the default branch, so a + * previous run's leftovers are never committed and the edits apply to current content. + */ + static void ensureRepo() throws GitAPIException, IOException { if (!Paths.get(GIT_CHECKOUT).toFile().exists()) { Git.cloneRepository() - .setURI("https://github.com/apache/sling-site.git") + .setURI(SITE_GIT_URL) .setProgressMonitor(new TextProgressMonitor()) .setDirectory(new File(GIT_CHECKOUT)) .call(); } else { try (Git git = Git.open(new File(GIT_CHECKOUT))) { - git.reset().setMode(ResetType.HARD).call(); + git.fetch().setProgressMonitor(new TextProgressMonitor()).call(); + git.reset().setMode(ResetType.HARD).setRef("origin/master").call(); } } } diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateNewsCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateNewsCommand.java new file mode 100644 index 0000000..5823df4 --- /dev/null +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateNewsCommand.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sling.cli.impl.release; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.jbake.JBakeContentUpdater; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.people.MembersFinder; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import picocli.CommandLine; + +/** + * Announces a release on the Sling website's news page. + * + * <p>Deliberately <em>not</em> part of {@link FinalizeCommand}: the release management guide only asks for a + * news entry when a release warrants an announcement, which is a judgement call, and most module releases do + * not get one. Run this by hand for the releases that do. + */ +@Component( + service = Command.class, + property = { + Command.PROPERTY_NAME_COMMAND_GROUP + "=" + UpdateNewsCommand.GROUP, + Command.PROPERTY_NAME_COMMAND_NAME + "=" + UpdateNewsCommand.NAME + }) [email protected]( + name = UpdateNewsCommand.NAME, + description = "Announces a release on the Sling website's news page. Run only for releases worth announcing;" + + " this is not part of finalize.", + subcommands = CommandLine.HelpCommand.class) +public class UpdateNewsCommand extends AbstractReleaseCommand { + + static final String GROUP = "release"; + static final String NAME = "update-news"; + + private static final Logger LOGGER = LoggerFactory.getLogger(UpdateNewsCommand.class); + + @CommandLine.Option( + names = {"--link"}, + description = "Optional page the announcement should link to, e.g." + + " /news/sling-14-released.html or /documentation/bundles/sling-pipes.html") + private String link; + + @CommandLine.Mixin + private ReusableCLIOptions reusableCLIOptions; + + @Reference + private RepositoryService repositoryService; + + @Reference + private CredentialsService credentialsService; + + @Reference + private MembersFinder membersFinder; + + @Override + public Integer call() { + try { + Set<Release> releases = resolveReleases(repositoryService); + if (releases.isEmpty()) { + LOGGER.error("Provide either --repository or --release."); + return CommandLine.ExitCode.USAGE; + } + + UpdateLocalSiteCommand.ensureRepo(); + Path newsPath = + Paths.get(UpdateLocalSiteCommand.GIT_CHECKOUT, "src", "main", "jbake", "content", "news.md"); + + JBakeContentUpdater updater = new JBakeContentUpdater(); + LocalDateTime now = LocalDateTime.now(); + boolean changed = false; + for (Release release : releases) { + if (updater.updateNews(newsPath, release.getFullName(), link, now)) { + LOGGER.info("Added a news entry for {}", release.getFullName()); + changed = true; + } else { + LOGGER.info("The news page already announces {}; skipping.", release.getFullName()); + } + } + + if (!changed) { + LOGGER.info("Nothing to commit."); + return CommandLine.ExitCode.OK; + } + + try (Git git = Git.open(new File(UpdateLocalSiteCommand.GIT_CHECKOUT))) { + git.diff().setOutputStream(System.out).call(); + } + + String names = releases.stream().map(Release::getFullName).sorted().collect(Collectors.joining(", ")); + UpdateLocalSiteCommand.commitAndPushSiteChanges( + "Announce " + names, + "Commit the news entry above and push to sling-site?", + reusableCLIOptions.executionMode, + credentialsService.getAsfCredentials(), + membersFinder.getCurrentMember()); + } catch (GitAPIException | IOException e) { + LOGGER.warn("Failed executing command", e); + return CommandLine.ExitCode.SOFTWARE; + } + return CommandLine.ExitCode.OK; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/jbake/JBakeContentUpdaterTest.java b/src/test/java/org/apache/sling/cli/impl/jbake/JBakeContentUpdaterTest.java index d0eacd8..936b185 100644 --- a/src/test/java/org/apache/sling/cli/impl/jbake/JBakeContentUpdaterTest.java +++ b/src/test/java/org/apache/sling/cli/impl/jbake/JBakeContentUpdaterTest.java @@ -66,6 +66,155 @@ public class JBakeContentUpdaterTest { Files.copy( getClass().getResourceAsStream("/releases.md"), Paths.get(new File(tmp.getRoot(), "releases.md").toURI())); + Files.copy(getClass().getResourceAsStream("/news.md"), Paths.get(new File(tmp.getRoot(), "news.md").toURI())); + } + + private Path templatePath() { + return Paths.get(new File(tmp.getRoot(), "downloads.tpl").toURI()); + } + + private Path newsPath() { + return Paths.get(new File(tmp.getRoot(), "news.md").toURI()); + } + + private List<String> templateLines() throws IOException { + return Files.readAllLines(templatePath(), StandardCharsets.UTF_8); + } + + /** Returns the single template line declaring {@code artifactId}, failing if there is not exactly one. */ + private String lineFor(String artifactId) throws IOException { + List<String> matches = templateLines().stream() + .filter(l -> l.contains("|" + artifactId + "|")) + .collect(Collectors.toList()); + assertThat("expected exactly one line for " + artifactId, matches.size(), equalTo(1)); + return matches.get(0); + } + + @Test + public void updateDownloadsByArtifactId_matchesWhenDisplayNameHasDigits() throws IOException { + // "I18n" / org.apache.sling.i18n: both the display name and the artifact id contain digits, which + // the display-name based matching could never handle + JBakeContentUpdater.DownloadsUpdate result = + updater.updateDownloadsByArtifactId(templatePath(), "org.apache.sling.i18n", "2.5.14"); + + assertThat(result.updated(), equalTo(1)); + assertThat(result.skippedOtherMajor(), equalTo(0)); + assertThat(lineFor("org.apache.sling.i18n"), containsString("|2.5.14|")); + } + + @Test + public void updateDownloadsByArtifactId_updatesEntryWithDescriptionColumn() throws IOException { + // the IDE tooling entry carries a description column, so the version is not simply the third column + JBakeContentUpdater.DownloadsUpdate result = + updater.updateDownloadsByArtifactId(templatePath(), "eclipse", "1.4.0"); + + assertThat(result.updated(), equalTo(1)); + assertThat(lineFor("eclipse"), containsString("|1.4.0|")); + } + + @Test + public void updateDownloadsByArtifactId_updatesEveryArtifactOfAMultiModuleRelease() throws IOException { + // Testing OSGi Mock is released as one unit but listed as three entries, one per artifact + for (String artifactId : Arrays.asList( + "org.apache.sling.testing.osgi-mock.core", + "org.apache.sling.testing.osgi-mock.junit4", + "org.apache.sling.testing.osgi-mock.junit5")) { + assertThat( + updater.updateDownloadsByArtifactId(templatePath(), artifactId, "2.4.8") + .updated(), + equalTo(1)); + assertThat(lineFor(artifactId), containsString("|2.4.8|")); + } + } + + @Test + public void updateDownloadsByArtifactId_leavesOtherMajorVersionAlone() throws IOException { + // dist.apache.org keeps several major streams published while the downloads page lists only the + // latest, so releasing 2.0.0 must not rewrite the 1.6.6 entry into a different major version + String before = lineFor("org.apache.sling.resourceresolver"); + + JBakeContentUpdater.DownloadsUpdate result = + updater.updateDownloadsByArtifactId(templatePath(), "org.apache.sling.resourceresolver", "2.0.0"); + + assertThat(result.updated(), equalTo(0)); + assertThat(result.skippedOtherMajor(), equalTo(1)); + assertThat( + "the entry must not have been touched", lineFor("org.apache.sling.resourceresolver"), equalTo(before)); + } + + @Test + public void updateDownloadsByArtifactId_updatesMaintenanceReleaseOfTheListedMajor() throws IOException { + JBakeContentUpdater.DownloadsUpdate result = + updater.updateDownloadsByArtifactId(templatePath(), "org.apache.sling.resourceresolver", "1.6.8"); + + assertThat(result.updated(), equalTo(1)); + assertThat(result.skippedOtherMajor(), equalTo(0)); + assertThat(lineFor("org.apache.sling.resourceresolver"), containsString("|1.6.8|")); + } + + @Test + public void updateDownloadsByArtifactId_reportsAnArtifactThatIsNotListed() throws IOException { + JBakeContentUpdater.DownloadsUpdate result = + updater.updateDownloadsByArtifactId(templatePath(), "org.apache.sling.not.on.the.page", "1.0.0"); + + assertThat(result.updated(), equalTo(0)); + assertThat(result.skippedOtherMajor(), equalTo(0)); + assertTrue("should be reported as not listed", result.notListed()); + } + + @Test + public void updateDownloadsByArtifactId_isIdempotent() throws IOException { + updater.updateDownloadsByArtifactId(templatePath(), "org.apache.sling.api", "2.20.2"); + + JBakeContentUpdater.DownloadsUpdate second = + updater.updateDownloadsByArtifactId(templatePath(), "org.apache.sling.api", "2.20.2"); + + assertThat("re-running must not report a change", second.updated(), equalTo(0)); + assertThat(lineFor("org.apache.sling.api"), containsString("|2.20.2|")); + } + + @Test + public void updateNews_addsEntryAboveTheExistingOnes() throws IOException { + boolean added = updater.updateNews( + newsPath(), + "Apache Sling Pipes 4.5.2", + "/documentation/bundles/sling-pipes.html", + LocalDateTime.of(2026, 8, 4, 12, 0)); + + assertTrue(added); + List<String> lines = Files.readAllLines(newsPath(), StandardCharsets.UTF_8); + String firstEntry = + lines.stream().filter(l -> l.startsWith("* ")).findFirst().orElseThrow(); + assertThat( + firstEntry, + equalTo("* Released [Apache Sling Pipes 4.5.2](/documentation/bundles/sling-pipes.html)" + + " (August 4th, 2026).")); + } + + @Test + public void updateNews_withoutLinkOmitsTheMarkdownLink() throws IOException { + updater.updateNews(newsPath(), "Apache Sling Pipes 4.5.2", null, LocalDateTime.of(2026, 8, 1, 12, 0)); + + List<String> lines = Files.readAllLines(newsPath(), StandardCharsets.UTF_8); + assertThat( + lines.stream().filter(l -> l.startsWith("* ")).findFirst().orElseThrow(), + equalTo("* Released Apache Sling Pipes 4.5.2 (August 1st, 2026).")); + } + + @Test + public void updateNews_doesNotAnnounceTheSameReleaseTwice() throws IOException { + LocalDateTime when = LocalDateTime.of(2026, 8, 4, 12, 0); + assertTrue(updater.updateNews(newsPath(), "Apache Sling Pipes 4.5.2", null, when)); + + assertThat( + "an already announced release must not be added again", + updater.updateNews(newsPath(), "Apache Sling Pipes 4.5.2", null, when), + equalTo(false)); + assertThat( + Files.readAllLines(newsPath(), StandardCharsets.UTF_8).stream() + .filter(l -> l.contains("Apache Sling Pipes 4.5.2")) + .count(), + equalTo(1L)); } @Test @@ -129,6 +278,7 @@ public class JBakeContentUpdaterTest { git.add() .addFilepattern("downloads.tpl") .addFilepattern("releases.md") + .addFilepattern("news.md") .call(); git.commit().setMessage("Initial commit").call(); 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 2f04f8b..43a6035 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 @@ -40,6 +40,8 @@ import org.apache.sling.cli.impl.nexus.StagingRepository; import org.apache.sling.cli.impl.people.Member; import org.apache.sling.cli.impl.people.MembersFinder; import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.mockito.MockedStatic; @@ -70,6 +72,24 @@ public class FinalizeCommandTest { private RepositoryService repositoryService; private VersionClient versionClient; private CloseableHttpClient client; + private MockedStatic<UpdateLocalSiteCommand> site; + + /** + * Stubs out the website step for every test. Without this the step would clone the real sling-site + * repository and attempt an actual push to gitbox; because the step deliberately only warns on failure, + * that would go unnoticed instead of failing the build. + */ + @Before + public void stubSiteStep() { + site = mockStatic(UpdateLocalSiteCommand.class); + site.when(() -> UpdateLocalSiteCommand.updateLocalSite(any(), any(), any())) + .thenReturn(new UpdateLocalSiteCommand.SiteUpdate(true, "Apache Sling CLI Test 1.0.0", List.of())); + } + + @After + public void releaseSiteStep() { + site.close(); + } /** * Sets up all collaborators. {@code pmcMember} controls whether the current user is detected as @@ -126,7 +146,7 @@ public class FinalizeCommandTest { prepare(false); Command command = createCommand(123, ExecutionMode.DRY_RUN); assertEquals(CommandLine.ExitCode.OK, (int) command.call()); - assertTrue(logCapture.containsMessage("--- Step 1/5: Update dist.apache.org --- SKIPPED (current user is not a" + assertTrue(logCapture.containsMessage("--- Step 1/6: Update dist.apache.org --- SKIPPED (current user is not a" + " PMC member; a PMC member must update dist.apache.org separately) ---")); // dry-run: nothing is actually promoted verify(repositoryService, never()).promote(any()); @@ -145,7 +165,7 @@ public class FinalizeCommandTest { false)); Command command = createCommand(123, ExecutionMode.DRY_RUN); assertEquals(CommandLine.ExitCode.OK, (int) command.call()); - assertTrue(logCapture.containsMessage("--- Step 1/5: Update dist.apache.org ---")); + assertTrue(logCapture.containsMessage("--- Step 1/6: Update dist.apache.org ---")); // dry-run: dist is described, not committed dist.verify(() -> UpdateDistCommand.publishToDistRelease(any(), any(), any(), any(), any()), never()); } @@ -161,7 +181,7 @@ public class FinalizeCommandTest { verify(versionClient).release(any(), any()); // reporter now queries (GET overview) before posting (POST addrelease) verify(client, atLeastOnce()).execute(any()); - assertTrue(logCapture.containsMessage("--- Step 1/5: Update dist.apache.org --- SKIPPED (current user is not a" + assertTrue(logCapture.containsMessage("--- Step 1/6: Update dist.apache.org --- SKIPPED (current user is not a" + " PMC member; a PMC member must update dist.apache.org separately) ---")); } @@ -186,6 +206,31 @@ public class FinalizeCommandTest { } } + @Test + public void testSiteUpdateIsDelegatedToUpdateLocalSite() 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()); + + assertTrue(logCapture.containsMessage("--- Step 6/6: Update the Sling website ---")); + // finalize orchestrates rather than reimplements: the editing and the commit/push both come from + // UpdateLocalSiteCommand + site.verify(() -> UpdateLocalSiteCommand.updateLocalSite(any(), any(), any())); + site.verify(() -> UpdateLocalSiteCommand.applySiteUpdate(any(), eq(ExecutionMode.AUTO), any(), any())); + } + + @Test + public void testSiteUpdateFailureDoesNotFailFinalize() throws Exception { + prepare(false); // non-PMC: the dist step is skipped, so this test needs no network + // everything irreversible has already succeeded by step 6, so a website hiccup must not fail the run + site.when(() -> UpdateLocalSiteCommand.updateLocalSite(any(), any(), any())) + .thenThrow(new java.io.IOException("gitbox down")); + + 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 testReporterFailureReturnsSoftware() throws Exception { prepare(false); diff --git a/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java index 5b31706..a7cfa23 100644 --- a/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java +++ b/src/test/java/org/apache/sling/cli/impl/release/TallyVotesCommandTest.java @@ -111,6 +111,7 @@ public class TallyVotesCommandTest { + " 3. create the next JIRA version and move any unresolved issues to it\n" + " 4. mark the JIRA version as released\n" + " 5. add the release to the Apache Reporter System\n" + + " 6. update the Sling website: the releases list and the downloads page\n" + "\n" + "Regards,\n" + "John Doe\n")); @@ -156,6 +157,7 @@ public class TallyVotesCommandTest { 3. create the next JIRA version and move any unresolved issues to it 4. mark the JIRA version as released 5. add the release to the Apache Reporter System + 6. update the Sling website: the releases list and the downloads page Steps 1 and 5 require PMC membership, which I do not have. @@ -222,6 +224,7 @@ public class TallyVotesCommandTest { + " 3. create the next JIRA version and move any unresolved issues to it\n" + " 4. mark the JIRA version as released\n" + " 5. add the release to the Apache Reporter System\n" + + " 6. update the Sling website: the releases list and the downloads page\n" + "\n" + "Regards,\n" + "John Doe\n"); 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 f034fb6..c6792b4 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 @@ -23,15 +23,26 @@ import java.util.Set; import org.apache.commons.lang3.reflect.FieldUtils; import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.ExecutionMode; import org.apache.sling.cli.impl.jbake.JBakeContentUpdater; import org.apache.sling.cli.impl.junit.LogCapture; import org.apache.sling.cli.impl.nexus.RepositoryService; import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.cli.impl.people.Member; +import org.apache.sling.cli.impl.people.MembersFinder; import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.eclipse.jgit.api.AddCommand; import org.eclipse.jgit.api.CloneCommand; +import org.eclipse.jgit.api.CommitCommand; import org.eclipse.jgit.api.DiffCommand; +import org.eclipse.jgit.api.FetchCommand; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.PushCommand; import org.eclipse.jgit.api.ResetCommand; +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.StatusCommand; import org.junit.Rule; import org.junit.Test; import org.mockito.MockedConstruction; @@ -46,6 +57,7 @@ import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -58,22 +70,51 @@ public class UpdateLocalSiteCommandTest { @Rule public final LogCapture logCapture = new LogCapture(UpdateLocalSiteCommand.class); + private PushCommand pushCommand; + private CommitCommand commitCommand; + /** - * Stubs out the JGit interactions so that no repository is cloned, opened or reset against the - * real filesystem or network. The {@code Git} instance returned by {@code Git.open(...)} is a - * mock whose {@code diff()} returns a mock {@link DiffCommand}. + * Stubs out the JGit interactions so that no repository is cloned, opened, reset or pushed against the + * real filesystem or network. */ - private MockedStatic<Git> stubGit() { + private MockedStatic<Git> stubGit(boolean clean) { MockedStatic<Git> git = mockStatic(Git.class); Git gitInstance = mock(Git.class); - // ensureRepo: the checkout already exists -> Git.open(...).reset()...call() + // ensureRepo: the checkout already exists -> fetch, then reset --hard origin/master ResetCommand resetCommand = mock(ResetCommand.class); when(resetCommand.setMode(any())).thenReturn(resetCommand); + when(resetCommand.setRef(any())).thenReturn(resetCommand); when(gitInstance.reset()).thenReturn(resetCommand); - // call(): git.diff().setOutputStream(...).call() + FetchCommand fetchCommand = mock(FetchCommand.class); + when(fetchCommand.setProgressMonitor(any())).thenReturn(fetchCommand); + when(gitInstance.fetch()).thenReturn(fetchCommand); + // diff(): git.diff().setOutputStream(...).call() DiffCommand diffCommand = mock(DiffCommand.class); when(diffCommand.setOutputStream(any())).thenReturn(diffCommand); when(gitInstance.diff()).thenReturn(diffCommand); + // status(): drives whether there is anything to commit + StatusCommand statusCommand = mock(StatusCommand.class); + Status status = mock(Status.class); + when(status.isClean()).thenReturn(clean); + try { + when(statusCommand.call()).thenReturn(status); + } catch (Exception e) { + throw new IllegalStateException(e); + } + when(gitInstance.status()).thenReturn(statusCommand); + // add/commit/push + AddCommand addCommand = mock(AddCommand.class); + when(addCommand.addFilepattern(any())).thenReturn(addCommand); + when(gitInstance.add()).thenReturn(addCommand); + commitCommand = mock(CommitCommand.class); + when(commitCommand.setMessage(any())).thenReturn(commitCommand); + when(commitCommand.setAuthor(any(), any())).thenReturn(commitCommand); + when(gitInstance.commit()).thenReturn(commitCommand); + pushCommand = mock(PushCommand.class); + when(pushCommand.setCredentialsProvider(any())).thenReturn(pushCommand); + when(pushCommand.setProgressMonitor(any())).thenReturn(pushCommand); + when(gitInstance.push()).thenReturn(pushCommand); + git.when(() -> Git.open(any())).thenReturn(gitInstance); // ensureRepo: when the checkout does not yet exist, it is cloned instead CloneCommand cloneCommand = mock(CloneCommand.class); @@ -86,9 +127,9 @@ public class UpdateLocalSiteCommandTest { @Test public void testNoRepositoryNoReleaseReturnsUsage() throws Exception { - osgiContext.registerService(RepositoryService.class, mock(RepositoryService.class)); - try (MockedStatic<Git> git = stubGit()) { - Command command = createCommand(null, null); + registerServices(mock(RepositoryService.class)); + try (MockedStatic<Git> git = stubGit(true)) { + Command command = createCommand(null, null, ExecutionMode.DRY_RUN); assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); assertTrue(logCapture.containsMessage("Provide either --repository or --release.")); } @@ -96,34 +137,140 @@ public class UpdateLocalSiteCommandTest { @Test public void testReleaseNameUpdatesContent() throws Exception { - osgiContext.registerService(RepositoryService.class, mock(RepositoryService.class)); - try (MockedStatic<Git> git = stubGit(); - MockedConstruction<JBakeContentUpdater> updater = mockConstruction(JBakeContentUpdater.class)) { - Command command = createCommand(null, "Apache Sling Foo 1.2.0"); + // resolving by release name only: the artifact ids come from the released POMs on dist.apache.org + RepositoryService repositoryService = mock(RepositoryService.class); + registerServices(repositoryService); + try (MockedStatic<Git> git = stubGit(false); + MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction( + JBakeContentUpdater.class, (m, ctx) -> when(m.updateDownloadsByArtifactId(any(), any(), any())) + .thenReturn(new JBakeContentUpdater.DownloadsUpdate(1, 0)))) { + dist.when(() -> UpdateDistCommand.listReleasePomFileNames("1.2.0")) + .thenReturn(java.util.List.of("org.apache.sling.foo-1.2.0.pom")); + when(repositoryService.getArtifactIdsFromPomUrls(any(), any(), any())) + .thenReturn(Set.of("org.apache.sling.foo")); + + Command command = createCommand(null, "Apache Sling Foo 1.2.0", ExecutionMode.DRY_RUN); assertEquals(CommandLine.ExitCode.OK, (int) command.call()); JBakeContentUpdater instance = updater.constructed().get(0); - verify(instance).updateDownloads(any(), eq("Foo"), eq("1.2.0")); verify(instance).updateReleases(any(), eq("Foo"), eq("1.2.0"), any()); + verify(instance).updateDownloadsByArtifactId(any(), eq("org.apache.sling.foo"), eq("1.2.0")); } } @Test - public void testRepositoryResolvesReleasesFromService() throws Exception { + public void testRepositoryResolvesArtifactIdsFromStagedPoms() throws Exception { RepositoryService repositoryService = mock(RepositoryService.class); StagingRepository repository = mock(StagingRepository.class); when(repositoryService.find(123)).thenReturn(repository); when(repositoryService.getReleases(repository)) .thenReturn(Set.copyOf(Release.fromString("Apache Sling Bar 2.0.0"))); - osgiContext.registerService(RepositoryService.class, repositoryService); + when(repositoryService.getArtifactIds(eq(repository), any())).thenReturn(Set.of("org.apache.sling.bar")); + registerServices(repositoryService); - try (MockedStatic<Git> git = stubGit(); - MockedConstruction<JBakeContentUpdater> updater = mockConstruction(JBakeContentUpdater.class)) { - Command command = createCommand(123, null); + try (MockedStatic<Git> git = stubGit(false); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction( + JBakeContentUpdater.class, (m, ctx) -> when(m.updateDownloadsByArtifactId(any(), any(), any())) + .thenReturn(new JBakeContentUpdater.DownloadsUpdate(1, 0)))) { + Command command = createCommand(123, null, ExecutionMode.DRY_RUN); assertEquals(CommandLine.ExitCode.OK, (int) command.call()); JBakeContentUpdater instance = updater.constructed().get(0); - verify(instance, atLeastOnce()).updateDownloads(any(), eq("Bar"), eq("2.0.0")); + verify(instance, atLeastOnce()).updateDownloadsByArtifactId(any(), eq("org.apache.sling.bar"), eq("2.0.0")); + } + } + + @Test + public void testDryRunDoesNotPush() throws Exception { + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.getArtifactIdsFromPomUrls(any(), any(), any())) + .thenReturn(Set.of("org.apache.sling.foo")); + registerServices(repositoryService); + + try (MockedStatic<Git> git = stubGit(false); + MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction( + JBakeContentUpdater.class, (m, ctx) -> when(m.updateDownloadsByArtifactId(any(), any(), any())) + .thenReturn(new JBakeContentUpdater.DownloadsUpdate(1, 0)))) { + dist.when(() -> UpdateDistCommand.listReleasePomFileNames(any())) + .thenReturn(java.util.List.of("org.apache.sling.foo-1.2.0.pom")); + + Command command = createCommand(null, "Apache Sling Foo 1.2.0", ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(pushCommand, never()).call(); + assertTrue(logCapture.containsMessage("Would commit the changes above to")); + } + } + + @Test + public void testAutoCommitsAndPushes() throws Exception { + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.getArtifactIdsFromPomUrls(any(), any(), any())) + .thenReturn(Set.of("org.apache.sling.foo")); + registerServices(repositoryService); + + try (MockedStatic<Git> git = stubGit(false); + MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction( + JBakeContentUpdater.class, (m, ctx) -> when(m.updateDownloadsByArtifactId(any(), any(), any())) + .thenReturn(new JBakeContentUpdater.DownloadsUpdate(1, 0)))) { + dist.when(() -> UpdateDistCommand.listReleasePomFileNames(any())) + .thenReturn(java.util.List.of("org.apache.sling.foo-1.2.0.pom")); + + Command command = createCommand(null, "Apache Sling Foo 1.2.0", ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(commitCommand).setMessage("Released Apache Sling Foo 1.2.0"); + verify(pushCommand).call(); + } + } + + @Test + public void testNothingToCommitWhenCheckoutIsClean() throws Exception { + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.getArtifactIdsFromPomUrls(any(), any(), any())) + .thenReturn(Set.of("org.apache.sling.foo")); + registerServices(repositoryService); + + try (MockedStatic<Git> git = stubGit(true); + MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction( + JBakeContentUpdater.class, (m, ctx) -> when(m.updateDownloadsByArtifactId(any(), any(), any())) + .thenReturn(new JBakeContentUpdater.DownloadsUpdate(0, 0)))) { + dist.when(() -> UpdateDistCommand.listReleasePomFileNames(any())) + .thenReturn(java.util.List.of("org.apache.sling.foo-1.2.0.pom")); + + Command command = createCommand(null, "Apache Sling Foo 1.2.0", ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + verify(pushCommand, never()).call(); + assertTrue(logCapture.containsMessage("already up to date")); + } + } + + @Test + public void testMaintenanceReleaseOfOlderMajorLeavesDownloadsAlone() throws Exception { + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.getArtifactIdsFromPomUrls(any(), any(), any())) + .thenReturn(Set.of("org.apache.sling.resourceresolver")); + registerServices(repositoryService); + + try (MockedStatic<Git> git = stubGit(false); + MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class); + MockedConstruction<JBakeContentUpdater> updater = mockConstruction( + JBakeContentUpdater.class, + // the page lists only the newer major, so nothing is updated but it is not "missing" + (m, ctx) -> when(m.updateDownloadsByArtifactId(any(), any(), any())) + .thenReturn(new JBakeContentUpdater.DownloadsUpdate(0, 1)))) { + dist.when(() -> UpdateDistCommand.listReleasePomFileNames(any())) + .thenReturn(java.util.List.of("org.apache.sling.resourceresolver-1.12.18.pom")); + + Command command = createCommand(null, "Apache Sling Resource Resolver 1.12.18", ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + assertTrue(logCapture.containsMessage("only for another major version")); } } @@ -131,19 +278,35 @@ public class UpdateLocalSiteCommandTest { public void testIOExceptionReturnsSoftware() throws Exception { RepositoryService repositoryService = mock(RepositoryService.class); when(repositoryService.find(123)).thenThrow(new IOException("nexus down")); - osgiContext.registerService(RepositoryService.class, repositoryService); + registerServices(repositoryService); - try (MockedStatic<Git> git = stubGit()) { - Command command = createCommand(123, null); + try (MockedStatic<Git> git = stubGit(true)) { + Command command = createCommand(123, null, ExecutionMode.DRY_RUN); assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call()); assertTrue(logCapture.containsMessage("Failed executing command")); } } - private Command createCommand(Integer repositoryId, String releaseName) throws IllegalAccessException { + private void registerServices(RepositoryService repositoryService) { + osgiContext.registerService(RepositoryService.class, repositoryService); + + CredentialsService credentialsService = mock(CredentialsService.class); + when(credentialsService.getAsfCredentials()).thenReturn(new Credentials("johndoe", "secret")); + osgiContext.registerService(CredentialsService.class, credentialsService); + + MembersFinder membersFinder = mock(MembersFinder.class); + when(membersFinder.getCurrentMember()).thenReturn(new Member("johndoe", "John Doe", true)); + osgiContext.registerService(MembersFinder.class, membersFinder); + } + + private Command createCommand(Integer repositoryId, String releaseName, ExecutionMode executionMode) + throws IllegalAccessException { UpdateLocalSiteCommand updateLocalSiteCommand = spy(new UpdateLocalSiteCommand()); FieldUtils.writeField(updateLocalSiteCommand, "repositoryId", repositoryId, true); FieldUtils.writeField(updateLocalSiteCommand, "releaseName", releaseName, true); + ReusableCLIOptions options = new ReusableCLIOptions(); + FieldUtils.writeField(options, "executionMode", executionMode, true); + FieldUtils.writeField(updateLocalSiteCommand, "reusableCLIOptions", options, true); osgiContext.registerInjectActivateService(updateLocalSiteCommand); Command result = osgiContext.getService(Command.class); assertTrue( diff --git a/src/test/resources/news.md b/src/test/resources/news.md new file mode 100644 index 0000000..b1adc0e --- /dev/null +++ b/src/test/resources/news.md @@ -0,0 +1,10 @@ +title=News +type=page +status=published +tags=news +tableOfContents=false +~~~~~~ + +* Released [Apache Sling 12](/news/sling-12-released.html) (March 18th, 2022). +* Security Advisory: [Apache Sling advisory regarding CVE-2021-44228 and LOGBACK-1591](./security/log4shell.html) +* Sling has moved to Git (October 20, 2017)
