IkeReleaseChangelogMojo.java
package network.ike.plugin;
import network.ike.plugin.support.AbstractGoalMojo;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.support.GoalReportSpec;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
/**
* Renders a "What's changed" changelog from the commits in a release
* range, for a CI release notification (the Zulip step) to consume
* (IKE-Network/ike-issues#699, #706).
*
* <p>The composition is {@link ReleaseNotesSupport#formatChangelog}:
* release-machinery commits are filtered out, and each surviving entry
* is annotated with the full {@code owner/repo#N} issue references
* parsed from its trailers — repo-agnostic, so a downstream linkifier
* resolves each reference in whatever tracker it lives, with no tracker
* hardcoded. This replaces the hand-rolled {@code gh compare | jq}
* pipeline previously inlined in the TeamCity notify step, putting the
* logic in one tested place.
*
* <p>The range is {@code <from>..<to>}. {@code from} defaults to the
* previous release tag (auto-derived); {@code to} defaults to
* {@code HEAD}. When no previous tag is reachable (a repo's first
* release) the changelog is empty and the caller omits the block.
*
* <p>Usage:
* <pre>
* mvn ike:release-changelog # prev-tag..HEAD to the log
* mvn ike:release-changelog -DoutputFile=target/changelog.md
* mvn ike:release-changelog -Dfrom=v222 -Dto=v223
* # changelog + the cascade topic env for the notify step, one call:
* mvn ike:release-changelog -DoutputFile=target/changelog.md \
* -DmetaFile=target/cascade.env
* </pre>
*
* <p>With {@code metaFile}, the notify step gets the changelog plus the
* cascade Zulip-topic grouping from one invocation:
* {@code . target/cascade.env} sets {@code CASCADE_LABEL} and
* {@code CASCADE_TOPIC_INPROGRESS} (#699).
*
* <p>For clean capture into a shell variable, prefer
* {@code -DoutputFile} and read the file — the build log carries
* Maven's own decoration.
*/
@Mojo(name = IkeGoal.NAME_RELEASE_CHANGELOG, projectRequired = false, aggregator = true)
public class IkeReleaseChangelogMojo extends AbstractGoalMojo {
/** The exclusive lower bound; defaults to the previous release tag. */
@Parameter(property = "from")
String from;
/** The inclusive upper bound; defaults to {@code HEAD}. */
@Parameter(property = "to", defaultValue = "HEAD")
String to;
/** File to write the changelog to. When unset, it is logged to stdout. */
@Parameter(property = "outputFile")
String outputFile;
/**
* Optional shell-sourceable env file to also write, carrying the
* cascade topic grouping for the notify step (#699):
* {@code CASCADE_LABEL} (this build's date in the server's zone) and
* {@code CASCADE_TOPIC_INPROGRESS}. Lets the notify step group a
* cascade under one Zulip topic from a single goal call, without
* recomputing the date in shell. See
* {@link network.ike.plugin.ReleaseNotesSupport#cascadeMetaEnv}.
*/
@Parameter(property = "metaFile")
String metaFile;
/** Override working directory for tests. If null, uses current directory. */
File baseDir;
/** Creates this goal instance. */
public IkeReleaseChangelogMojo() {}
@Override
protected GoalReportSpec runGoal() throws MojoException {
File startDir = baseDir != null ? baseDir : new File(".");
File gitRoot = ReleaseSupport.gitRoot(startDir);
String fromRef = (from != null && !from.isBlank())
? from
: ReleaseNotesSupport.resolvePreviousTag(gitRoot, to);
String changelog;
if (fromRef == null || fromRef.isBlank()) {
// No previous tag (first release) — nothing to compare.
getLog().info("No previous release tag reachable from " + to
+ "; changelog is empty.");
changelog = "";
} else if (new File(gitRoot, "workspace.yaml").isFile()) {
// Workspace aggregator: fan out across subprojects by pin diff so
// the changelog reflects every subproject's code change, not just
// the aggregator's own workspace.yaml/merge commits — the gap that
// left subproject changes out of the checkpoint Zulip note
// (IKE-Network/ike-issues#792).
changelog = ReleaseNotesSupport.formatWorkspaceChangelog(
gitRoot, fromRef, to);
if (changelog.isBlank()) {
// No subproject pin advanced (or no readable history) — fall
// back to the aggregator's own commits so the output is never
// silently empty.
changelog = ReleaseNotesSupport.formatChangelog(
ReleaseNotesSupport.commitMessagesBetween(
gitRoot, fromRef, to));
}
} else {
List<String> commits = ReleaseNotesSupport.commitMessagesBetween(
gitRoot, fromRef, to);
changelog = ReleaseNotesSupport.formatChangelog(commits);
}
String location;
if (outputFile != null && !outputFile.isBlank()) {
Path out = Path.of(outputFile);
try {
if (out.getParent() != null) {
Files.createDirectories(out.getParent());
}
Files.writeString(out, changelog, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException(
"Could not write " + out + ": " + e.getMessage(), e);
}
getLog().info("Release changelog written to " + out);
location = "written to `" + out + "`";
} else if (changelog.isEmpty()) {
getLog().info("Release changelog: (empty)");
location = "empty";
} else {
getLog().info("Release changelog:");
changelog.lines().forEach(getLog()::info);
location = "printed to the build log";
}
// Cascade topic grouping for the notify step (#699): the date is
// stamped here, once, in the release server's own zone.
String cascadeLabel = null;
if (metaFile != null && !metaFile.isBlank()) {
cascadeLabel = ReleaseNotesSupport.cascadeTopicLabel(
Instant.now(), ZoneId.systemDefault());
Path mf = Path.of(metaFile);
try {
if (mf.getParent() != null) {
Files.createDirectories(mf.getParent());
}
Files.writeString(mf,
ReleaseNotesSupport.cascadeMetaEnv(cascadeLabel),
StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException(
"Could not write " + mf + ": " + e.getMessage(), e);
}
getLog().info("Cascade meta written to " + mf
+ " (CASCADE_LABEL=" + cascadeLabel + ")");
}
String report = new GoalReportBuilder()
.section("Release changelog")
.paragraph("Rendered the changelog for `"
+ (fromRef == null ? "(first release)" : fromRef)
+ ".." + to + "`, " + location + ".")
.codeBlock("markdown",
changelog.isEmpty() ? "(empty)" : changelog)
.build();
return new GoalReportSpec(IkeGoal.RELEASE_CHANGELOG,
startDir.toPath(), report);
}
}