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)

Reply via email to