NotarizeMojo.java
package network.ike.plugin;
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.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Sign and notarize macOS installer packages ({@code .pkg}, {@code .dmg}).
*
* <p>This goal automates the Apple notarization workflow for installer
* artifacts produced by JReleaser's jpackage assembler:
* <ol>
* <li>Locate {@code .pkg} / {@code .dmg} files in the jpackage output directory</li>
* <li>Submit each artifact to Apple's notary service via {@code xcrun notarytool}</li>
* <li>Staple the notarization ticket to the artifact via {@code xcrun stapler}</li>
* <li>Verify the result with {@code spctl --assess}</li>
* </ol>
*
* <p>On non-macOS platforms, the goal skips silently — no profile
* activation or conditional configuration required.
*
* <p><b>Prerequisites:</b>
* <ul>
* <li>A "Developer ID Installer" certificate in the login keychain</li>
* <li>A notarytool keychain profile configured via:
* <pre>xcrun notarytool store-credentials "notarytool" \
* --apple-id "your@email.com" \
* --team-id "YOURTEAMID" \
* --password "app-specific-password"</pre></li>
* </ul>
*
* <p><b>Usage:</b>
* <pre>
* mvn ike:notarize
* </pre>
*
* <p>Or bound to the {@code verify} phase in a POM:
* <pre>{@code
* <plugin>
* <groupId>network.ike.tooling</groupId>
* <artifactId>ike-maven-plugin</artifactId>
* <executions>
* <execution>
* <id>notarize-installer</id>
* <goals><goal>notarize</goal></goals>
* </execution>
* </executions>
* </plugin>
* }</pre>
*
* @see <a href="https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution">
* Apple: Notarizing macOS Software Before Distribution</a>
*/
@Mojo(name = "notarize",
defaultPhase = "verify",
projectRequired = true)
public class NotarizeMojo implements org.apache.maven.api.plugin.Mojo {
@org.apache.maven.api.di.Inject
private org.apache.maven.api.plugin.Log log;
/**
* Access the Maven logger.
*
* @return the logger
*/
protected org.apache.maven.api.plugin.Log getLog() { return log; }
/** Creates this goal instance. */
public NotarizeMojo() {}
/**
* Directory containing jpackage output artifacts.
* Defaults to the JReleaser jpackage output location.
*/
@Parameter(property = "notarize.artifactDir",
defaultValue = "${project.build.directory}/jreleaser/assemble/komet/jpackage")
private File artifactDir;
/**
* Keychain profile name for {@code xcrun notarytool}.
* Created via {@code xcrun notarytool store-credentials}.
*/
@Parameter(property = "notarize.keychainProfile",
defaultValue = "notarytool")
private String keychainProfile;
/**
* Skip notarization entirely.
*/
@Parameter(property = "notarize.skip", defaultValue = "false")
private boolean skip;
/**
* Timeout in minutes for notarization submission.
* Apple typically processes in 5–15 minutes but can take longer.
*/
@Parameter(property = "notarize.timeoutMinutes", defaultValue = "30")
private int timeoutMinutes;
/**
* Maximum number of submit attempts before failing on transient
* network errors. A value of 1 disables retry entirely; the previous
* behavior. Genuine notarization rejections (Apple returns
* {@code status: Invalid}) never trigger a retry — they fail immediately.
*/
@Parameter(property = "notarize.maxAttempts", defaultValue = "3")
private int maxAttempts;
/**
* Comma-separated list of seconds to wait before each retry attempt.
* The first value is the wait before attempt 2, the second before
* attempt 3, and so on. If shorter than {@code maxAttempts - 1} the
* last entry is reused.
*/
@Parameter(property = "notarize.retryBackoffSeconds", defaultValue = "30,120")
private String retryBackoffSeconds;
@Override
public void execute() throws MojoException {
if (skip) {
getLog().info("Notarization skipped (notarize.skip=true)");
return;
}
if (!isMacOS()) {
getLog().info("Notarization skipped — not running on macOS");
return;
}
List<Path> artifacts = findInstallerArtifacts();
if (artifacts.isEmpty()) {
getLog().warn("No .pkg or .dmg files found in " + artifactDir);
return;
}
getLog().info("");
getLog().info("Apple Notarization");
getLog().info("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
getLog().info(" Artifact directory: " + artifactDir);
getLog().info(" Keychain profile: " + keychainProfile);
getLog().info(" Timeout: " + timeoutMinutes + " minutes");
getLog().info(" Artifacts found: " + artifacts.size());
for (Path artifact : artifacts) {
getLog().info("");
notarize(artifact);
}
getLog().info("");
getLog().info("Notarization complete — " + artifacts.size() + " artifact(s) processed");
getLog().info("");
}
/**
* Submit, staple, and verify a single installer artifact.
*
* @param artifact path to the .pkg or .dmg file
* @throws MojoException if any step fails
*/
private void notarize(Path artifact) throws MojoException {
String fileName = artifact.getFileName().toString();
File workDir = artifact.getParent().toFile();
SubmitResult submission = submitWithRetry(workDir, artifact, fileName);
if (submission.status() == null || !submission.status().equalsIgnoreCase("Accepted")) {
getLog().error("Notarization REJECTED for " + fileName
+ " — status: " + (submission.status() != null ? submission.status() : "unknown"));
if (submission.submissionId() != null) {
getLog().error("Fetching rejection details (id: " + submission.submissionId() + ")...");
try {
ReleaseSupport.exec(workDir, getLog(),
"xcrun", "notarytool", "log",
submission.submissionId(),
"--keychain-profile", keychainProfile);
} catch (Exception e) {
getLog().warn("Could not fetch notarization log: " + e.getMessage());
}
}
throw new MojoException(
"Notarization failed for " + fileName
+ " — status: " + (submission.status() != null ? submission.status() : "unknown")
+ (submission.submissionId() != null ? " (id: " + submission.submissionId() + ")" : ""));
}
getLog().info("Notarization accepted — stapling ticket");
getLog().info("Stapling ticket: " + fileName);
ReleaseSupport.exec(workDir, getLog(),
"xcrun", "stapler", "staple",
artifact.toString());
getLog().info("Verifying: " + fileName);
String assessType = fileName.endsWith(".pkg") ? "install" : "open";
ReleaseSupport.exec(workDir, getLog(),
"spctl", "--assess",
"--type", assessType,
"--verbose=4",
artifact.toString());
getLog().info("Notarized and verified: " + fileName);
}
/**
* Submit the artifact (or resume an in-flight submission) with bounded
* retry on transient network errors. Genuine notarization rejections
* (exit 0 with non-Accepted status) return on the first attempt and are
* dispatched by the caller.
*
* <p>Three failure modes are distinguished:
* <ul>
* <li>Non-zero exit, no submission id captured, output matches
* {@link #TRANSIENT_NETWORK} — fresh {@code submit} retry.</li>
* <li>Non-zero exit, submission id <em>was</em> captured before the
* failure — switch to {@code notarytool info <id> --wait} so we
* don't waste an upload slot resubmitting.</li>
* <li>Non-zero exit, no transient match — fail immediately.</li>
* </ul>
*
* @param workDir working directory for the subprocess
* @param artifact path to the .pkg or .dmg
* @param fileName bare file name (for log messages)
* @return the resolved submission result
* @throws MojoException if all attempts fail
*/
private SubmitResult submitWithRetry(File workDir, Path artifact, String fileName)
throws MojoException {
List<Integer> backoff = parseBackoffSchedule(retryBackoffSeconds);
int attempts = Math.max(1, maxAttempts);
String knownSubmissionId = null;
ExecOutcome lastOutcome = null;
for (int attempt = 1; attempt <= attempts; attempt++) {
if (attempt > 1) {
int waitSeconds = backoffWait(backoff, attempt);
getLog().warn("Notarize attempt " + attempt + "/" + attempts
+ " in " + waitSeconds + "s "
+ "(previous attempt: " + summarizeOutcome(lastOutcome) + ")");
sleepSeconds(waitSeconds);
}
String[] command;
if (knownSubmissionId != null) {
getLog().info("Resuming wait on submission " + knownSubmissionId
+ " (attempt " + attempt + "/" + attempts + ")");
command = new String[] {
"xcrun", "notarytool", "info",
knownSubmissionId,
"--keychain-profile", keychainProfile,
"--wait"
};
} else {
getLog().info("Submitting for notarization: " + fileName
+ " (attempt " + attempt + "/" + attempts + ")");
command = new String[] {
"xcrun", "notarytool", "submit",
artifact.toString(),
"--keychain-profile", keychainProfile,
"--timeout", timeoutMinutes + "m",
"--wait"
};
}
lastOutcome = runCapturing(workDir, getLog(), command);
String idFromThisAttempt = extractSubmissionId(lastOutcome.output());
if (idFromThisAttempt != null) {
knownSubmissionId = idFromThisAttempt;
}
if (lastOutcome.exitCode() == 0) {
return new SubmitResult(extractStatus(lastOutcome.output()),
knownSubmissionId,
lastOutcome.output());
}
if (!isTransientNetworkError(lastOutcome.output())) {
throw new MojoException(
"Notarization command failed (exit " + lastOutcome.exitCode()
+ ", non-transient): " + String.join(" ", command)
+ (lastOutcome.output().isEmpty()
? ""
: "\nOutput:\n" + lastOutcome.output().trim()));
}
getLog().warn("Transient network error on attempt " + attempt + "/"
+ attempts + " — "
+ (knownSubmissionId != null
? "will resume wait on submission " + knownSubmissionId
: "will resubmit"));
}
throw new MojoException(
"Notarization exhausted " + attempts + " attempt(s) for " + fileName
+ " — last error: " + summarizeOutcome(lastOutcome)
+ (knownSubmissionId != null
? " (submission id: " + knownSubmissionId + ")"
: ""));
}
/**
* Resolved outcome of a notarization {@code submit} or {@code info} call.
*
* @param status terminal status as reported by Apple ({@code Accepted},
* {@code Invalid}, {@code Rejected}), or {@code null} if
* not parseable
* @param submissionId Apple-assigned submission id, or {@code null} if no id
* was ever captured (e.g. transport error before upload)
* @param fullOutput captured stdout+stderr from the final attempt
*/
private record SubmitResult(String status, String submissionId, String fullOutput) {}
/**
* Captured result of a single subprocess invocation.
*
* @param exitCode process exit code; {@code -1} for runner-side I/O failure
* @param output captured stdout+stderr (combined)
*/
private record ExecOutcome(int exitCode, String output) {}
/**
* Transport-layer error patterns from {@code xcrun notarytool}. Matching
* any of these on a non-zero exit triggers a retry; every other non-zero
* exit is treated as a hard failure.
*
* <p>Intentionally narrow — only well-known network conditions, never
* keychain or signing errors.
*/
private static final Pattern TRANSIENT_NETWORK = Pattern.compile(
"NSURLErrorDomain Code=-1001"
+ "|kCFErrorDomainCFNetwork"
+ "|Could not connect to the notary service"
+ "|connection was lost"
+ "|The Internet connection appears to be offline"
+ "|HTTPError\\(statusCode: nil",
Pattern.CASE_INSENSITIVE);
/**
* Test whether captured output contains a recognized transient network
* error pattern.
*
* @param output captured xcrun output
* @return {@code true} if the output matches a known transient pattern
*/
static boolean isTransientNetworkError(String output) {
return output != null && TRANSIENT_NETWORK.matcher(output).find();
}
/**
* Extract the Apple-assigned submission id from xcrun output.
*
* <p>Looks for lines beginning with {@code id:} after trimming. Returns
* the first match — xcrun prints the same id repeatedly during
* {@code --wait}, but they are all the same value. Skips lines starting
* with {@code Current } so {@code Current status: ...} progress noise
* is ignored.
*
* @param output captured xcrun output (may be {@code null})
* @return the submission id, or {@code null} if none found
*/
static String extractSubmissionId(String output) {
if (output == null) return null;
for (String line : output.lines().toList()) {
String trimmed = line.trim();
if (trimmed.startsWith("id:")) {
String candidate = trimmed.substring(3).trim();
if (!candidate.isEmpty()) {
return candidate;
}
}
}
return null;
}
/**
* Extract the terminal notarization status (e.g. {@code Accepted},
* {@code Invalid}) from xcrun output. Returns the <em>last</em>
* matching value so {@code Current status: In Progress} progress
* lines are ignored in favor of the final {@code status: ...} block.
*
* @param output captured xcrun output (may be {@code null})
* @return the terminal status, or {@code null} if not found
*/
static String extractStatus(String output) {
if (output == null) return null;
String last = null;
for (String line : output.lines().toList()) {
String trimmed = line.trim();
if (trimmed.startsWith("status:")) {
last = trimmed.substring("status:".length()).trim();
}
}
return last;
}
/**
* Parse a comma-separated list of positive integers (e.g. {@code "30,120"}).
* Whitespace, blanks, non-numeric tokens, and non-positive values are skipped.
*
* @param csv comma-separated list (may be {@code null} or blank)
* @return parsed list of positive seconds, possibly empty
*/
static List<Integer> parseBackoffSchedule(String csv) {
List<Integer> out = new ArrayList<>();
if (csv == null || csv.isBlank()) {
return out;
}
for (String token : csv.split(",")) {
String t = token.trim();
if (t.isEmpty()) continue;
try {
int v = Integer.parseInt(t);
if (v > 0) out.add(v);
} catch (NumberFormatException ignored) {
// skip non-numeric entries silently
}
}
return out;
}
/**
* Pick the wait-seconds for attempt {@code N} (where {@code N >= 2}).
* Falls back to the last list entry if {@code schedule} is shorter than
* {@code attempt - 1}. Returns 0 for an empty schedule (no wait).
*
* @param schedule parsed backoff seconds list
* @param attempt 1-based attempt number; values < 2 yield 0
* @return seconds to wait before this attempt
*/
static int backoffWait(List<Integer> schedule, int attempt) {
if (schedule.isEmpty() || attempt < 2) return 0;
int idx = Math.min(attempt - 2, schedule.size() - 1);
return schedule.get(idx);
}
/**
* Sleep for {@code seconds}, propagating interruption as a
* {@link MojoException} so an interrupted Maven build aborts cleanly.
*
* @param seconds duration; values ≤ 0 return immediately
* @throws MojoException if interrupted while sleeping
*/
private void sleepSeconds(int seconds) throws MojoException {
if (seconds <= 0) return;
try {
Thread.sleep(seconds * 1000L);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new MojoException("Interrupted during retry backoff", ie);
}
}
/**
* One-line summary of a captured outcome for log messages.
*
* @param o outcome to summarize (may be {@code null})
* @return short human-readable summary
*/
private static String summarizeOutcome(ExecOutcome o) {
if (o == null) return "no attempts made";
String firstNonEmpty = o.output().lines()
.map(String::trim)
.filter(s -> !s.isEmpty())
.findFirst()
.orElse("");
return "exit=" + o.exitCode()
+ (firstNonEmpty.isEmpty() ? "" : " — " + firstNonEmpty);
}
/**
* Run a command capturing combined stdout+stderr while streaming it
* through Maven's logger. Unlike {@link ReleaseSupport#execCaptureAndLog},
* does <em>not</em> throw on non-zero exit — the caller inspects the
* returned {@link ExecOutcome} and decides whether to retry or fail.
*
* @param workDir working directory for the subprocess
* @param log Maven logger for real-time output
* @param command the command and arguments to execute
* @return captured exit code and output (never {@code null})
*/
private static ExecOutcome runCapturing(File workDir,
org.apache.maven.api.plugin.Log log,
String... command) {
log.debug("» " + String.join(" ", command));
StringBuilder captured = new StringBuilder();
try {
Process proc = new ProcessBuilder(command)
.directory(workDir)
.redirectErrorStream(true)
.start();
try (var reader = new BufferedReader(
new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
ReleaseSupport.routeSubprocessLine(log, line);
captured.append(line).append('\n');
}
}
int exit = proc.waitFor();
return new ExecOutcome(exit, captured.toString());
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
return new ExecOutcome(-1,
captured.toString() + "\n[runner-error] " + e.getMessage());
}
}
/**
* Find all .pkg and .dmg files in the artifact directory.
*
* @return list of installer artifact paths
* @throws MojoException if the directory cannot be read
*/
private List<Path> findInstallerArtifacts() throws MojoException {
List<Path> result = new ArrayList<>();
if (!artifactDir.isDirectory()) {
return result;
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(
artifactDir.toPath(),
entry -> {
String name = entry.getFileName().toString().toLowerCase(Locale.ROOT);
return name.endsWith(".pkg") || name.endsWith(".dmg");
})) {
for (Path entry : stream) {
if (Files.isRegularFile(entry)) {
result.add(entry);
}
}
} catch (IOException e) {
throw new MojoException(
"Failed to scan for installer artifacts in " + artifactDir, e);
}
return result;
}
/**
* Check if the current platform is macOS.
*
* @return true if running on macOS
*/
static boolean isMacOS() {
return ReleaseSupport.isMacOS();
}
}