Fixes
diff --git a/src/scripts/MavenRelease.java b/src/scripts/MavenRelease.java index 261d7d2..f3959c4 100755 --- a/src/scripts/MavenRelease.java +++ b/src/scripts/MavenRelease.java
@@ -24,12 +24,12 @@ // 3. Validate environment variables (APACHE_USERNAME, GPG_KEY_ID) // 4. Check Gmail configuration (optional) // 5. Validate Maven settings.xml -// 6. Create/update ~/.mavenrc with recommended settings +// 6. Check Maven configuration // 7. Display setup status and next steps // -// start-vote <version> -// -------------------- -// Start release vote (Click 1) - Prepares and stages release +// stage <version> +// --------------- +// Stage release for voting (Click 1) - Prepares and stages release // Steps: // 1. Validate tools, environment, credentials, and version // 2. Check for open blocker issues on GitHub @@ -53,18 +53,19 @@ // 1. Load staging repository ID from saved file or argument // 2. Load milestone information from saved file // 3. Interactive confirmation of vote results (72+ hours, 3+ PMC votes) -// 4. Promote staging repository to Maven Central -// 5. Commit source release to Apache dist area -// 6. Clean up old releases (keep only latest 3) -// 7. Add release to Apache Committee Report Helper (manual step) -// 8. Deploy versioned website documentation -// 9. Close GitHub milestone and create next version milestone -// 10. Publish GitHub release from draft -// 11. Generate announcement email -// 12. Wait for Maven Central sync confirmation -// 13. Optionally send announcement email via Gmail if configured -// 14. Clean up staging info files -// 15. Display success message and final steps +// 4. Generate and send close-vote email +// 5. Promote staging repository to Maven Central +// 6. Commit source release to Apache dist area +// 7. Clean up old releases (keep only latest 3) +// 8. Add release to Apache Committee Report Helper (manual step) +// 9. Deploy versioned website documentation +// 10. Close GitHub milestone and create next version milestone +// 11. Publish GitHub release from draft +// 12. Generate announcement email +// 13. Wait for Maven Central sync confirmation +// 14. Optionally send announcement email via Gmail if configured +// 15. Clean up staging info files +// 16. Display success message and final steps // // cancel <version> // ---------------- @@ -86,6 +87,7 @@ // ============================================================================ // Required: // APACHE_USERNAME - Your Apache LDAP username +// APACHE_PASSWORD - Your Apache LDAP password (for SVN operations) // GPG_KEY_ID - Your GPG key ID for signing releases // // Optional (for email automation): @@ -109,6 +111,7 @@ import java.io.*; import java.nio.file.*; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.*; import java.util.concurrent.Callable; @@ -126,11 +129,12 @@ description = "Maven Release Script - 2-Click Release Automation", subcommands = { MavenRelease.SetupCommand.class, - MavenRelease.StartVoteCommand.class, + MavenRelease.StageCommand.class, MavenRelease.SendVoteCommand.class, MavenRelease.PublishCommand.class, MavenRelease.CancelCommand.class, MavenRelease.StatusCommand.class, + MavenRelease.TestStepCommand.class, CommandLine.HelpCommand.class }) public class MavenRelease implements Callable<Integer> { @@ -155,6 +159,7 @@ public class MavenRelease implements Callable<Integer> { // Release step tracking enum ReleaseStep { + // Staging steps VALIDATION("validation"), BLOCKER_CHECK("blocker-check"), MILESTONE_INFO("milestone-info"), @@ -163,10 +168,26 @@ enum ReleaseStep { PREPARE_RELEASE("prepare-release"), STAGE_ARTIFACTS("stage-artifacts"), STAGE_DOCS("stage-docs"), + STAGE_WEBSITE("stage-website"), COPY_DIST("copy-dist"), GENERATE_EMAIL("generate-email"), SAVE_INFO("save-info"), - COMPLETED("completed"); + COMPLETED("completed"), + + // Publish steps + PUBLISH_VOTE_CONFIRM("publish-vote-confirm"), + PUBLISH_CLOSE_VOTE("publish-close-vote"), + PUBLISH_PROMOTE_STAGING("publish-promote-staging"), + PUBLISH_FINALIZE_DIST("publish-finalize-dist"), + PUBLISH_COMMITTEE_REPORT("publish-committee-report"), + PUBLISH_DEPLOY_WEBSITE("publish-deploy-website"), + PUBLISH_UPDATE_GITHUB("publish-update-github"), + PUBLISH_GITHUB_RELEASE("publish-github-release"), + PUBLISH_GENERATE_ANNOUNCEMENT("publish-generate-announcement"), + PUBLISH_MAVEN_CENTRAL_SYNC("publish-maven-central-sync"), + PUBLISH_SEND_ANNOUNCEMENT("publish-send-announcement"), + PUBLISH_CLEANUP("publish-cleanup"), + PUBLISH_COMPLETED("publish-completed"); private final String stepName; @@ -191,21 +212,23 @@ public Integer call() { System.out.println("Usage: jbang release.java <command> [options]"); System.out.println(); System.out.println("Commands:"); - System.out.println(" setup One-time environment setup"); - System.out.println(" start-vote <version> Start release vote (Click 1)"); - System.out.println(" publish <version> [repo] Publish release after vote (Click 2)"); - System.out.println(" cancel <version> Cancel release vote and clean up"); - System.out.println(" status <version> Check release status and logs"); - System.out.println(" help Show help information"); + System.out.println(" setup One-time environment setup"); + System.out.println(" stage <version> Stage release for voting (Click 1)"); + System.out.println(" publish [version] [repo] Publish release after vote (Click 2)"); + System.out.println(" cancel <version> Cancel release vote and clean up"); + System.out.println(" status <version> Check release status and logs"); + System.out.println(" help Show help information"); System.out.println(); System.out.println("Examples:"); System.out.println(" jbang release.java setup"); - System.out.println(" jbang release.java start-vote 4.0.0-rc-4"); - System.out.println(" jbang release.java publish 4.0.0-rc-4"); + System.out.println(" jbang release.java stage 4.0.0-rc-4"); + System.out.println(" jbang release.java publish # Auto-detect version"); + System.out.println(" jbang release.java publish 4.0.0-rc-4 # Explicit version"); System.out.println(" jbang release.java cancel 4.0.0-rc-4"); System.out.println(); System.out.println("Environment Variables:"); System.out.println(" APACHE_USERNAME Your Apache LDAP username"); + System.out.println(" APACHE_PASSWORD Your Apache LDAP password (for SVN operations)"); System.out.println(" GPG_KEY_ID Your GPG key ID for signing"); System.out.println(" GMAIL_USERNAME Your Gmail address for authentication (optional)"); System.out.println(" GMAIL_APP_PASSWORD Your Gmail app password (optional)"); @@ -386,6 +409,129 @@ static ProcessResult runCommandWithLogging(String version, String step, String.. return new ProcessResult(exitCode, output, error); } + static ProcessResult runCommandWithJvmArgs(String version, String step, String jvmArgs, String... command) throws IOException, InterruptedException { + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(PROJECT_ROOT.toFile()); + + // Set MAVEN_OPTS environment variable with JVM arguments + Map<String, String> env = pb.environment(); + String existingMavenOpts = env.get("MAVEN_OPTS"); + String newMavenOpts = jvmArgs; + if (existingMavenOpts != null && !existingMavenOpts.isEmpty()) { + newMavenOpts = existingMavenOpts + " " + jvmArgs; + } + env.put("MAVEN_OPTS", newMavenOpts); + + // Log command execution + String commandStr = String.join(" ", command); + if (version != null && step != null) { + logToFile(version, step, "Executing with MAVEN_OPTS=" + newMavenOpts + ": " + commandStr); + } + + // Also log to console for debugging + logInfo("Executing command: " + commandStr); + logInfo("MAVEN_OPTS: " + newMavenOpts); + + // Test if MAVEN_OPTS is being picked up by running a simple Maven command first + if (command[0].equals("mvn")) { + logInfo("Testing MAVEN_OPTS with Maven..."); + ProcessBuilder testPb = new ProcessBuilder("mvn", "--version", "-X"); + testPb.directory(PROJECT_ROOT.toFile()); + testPb.environment().put("MAVEN_OPTS", newMavenOpts); + try { + Process testProcess = testPb.start(); + String testOutput = new String(testProcess.getInputStream().readAllBytes()); + String testError = new String(testProcess.getErrorStream().readAllBytes()); + int testExitCode = testProcess.waitFor(); + logInfo("Maven version test exit code: " + testExitCode); + if (!testError.isEmpty()) { + logInfo("Maven version test stderr: " + testError); + } + } catch (Exception e) { + logWarning("Failed to test Maven version: " + e.getMessage()); + } + } + + Process process = pb.start(); + + String output = new String(process.getInputStream().readAllBytes()); + String error = new String(process.getErrorStream().readAllBytes()); + int exitCode = process.waitFor(); + + // Log detailed results + if (version != null && step != null) { + logToFile(version, step, "Command exit code: " + exitCode); + if (!output.isEmpty()) { + logToFile(version, step, "STDOUT:\n" + output); + } + if (!error.isEmpty()) { + logToFile(version, step, "STDERR:\n" + error); + } + + // Also save command output to separate files for long outputs + if (output.length() > 1000 || error.length() > 1000) { + try { + // Ensure logs directory exists + Files.createDirectories(LOGS_DIR); + + String safeCommand = commandStr.replaceAll("[^a-zA-Z0-9-_]", "_"); + if (output.length() > 1000) { + Path outputFile = LOGS_DIR.resolve(step + "-" + safeCommand + "-output.log"); + Files.writeString(outputFile, output); + logToFile(version, step, "Full output saved to: " + outputFile.getFileName()); + } + if (error.length() > 1000) { + Path errorFile = LOGS_DIR.resolve(step + "-" + safeCommand + "-error.log"); + Files.writeString(errorFile, error); + logToFile(version, step, "Full error output saved to: " + errorFile.getFileName()); + } + } catch (IOException e) { + logWarning("Failed to save detailed command output: " + LOGS_DIR); + } + } + } + + ProcessResult result = new ProcessResult(exitCode, output, error); + + // Verify that JVM arguments were actually picked up by Maven + if (command[0].equals("mvn") && jvmArgs.contains("--add-opens")) { + boolean jvmArgsFound = false; + if (output.contains("Command line:")) { + // Look for the JVM arguments in the Maven debug output + String[] lines = output.split("\n"); + for (String line : lines) { + if (line.contains("Command line:") && line.contains("--add-opens=java.base/java.util=ALL-UNNAMED")) { + jvmArgsFound = true; + break; + } + } + } + + if (!jvmArgsFound && result.exitCode != 0) { + logWarning("JVM arguments may not have been picked up by Maven"); + logWarning("This could be due to ~/.mavenrc overriding MAVEN_OPTS"); + logWarning("Check your ~/.mavenrc file and ensure it uses: export MAVEN_OPTS=\"$MAVEN_OPTS <your-options>\""); + + // Check if ~/.mavenrc exists and might be the problem + Path mavenrc = Paths.get(System.getProperty("user.home"), ".mavenrc"); + if (Files.exists(mavenrc)) { + try { + String content = Files.readString(mavenrc); + if (content.contains("export MAVEN_OPTS=") && !content.contains("$MAVEN_OPTS")) { + logError("Found problematic ~/.mavenrc that overrides MAVEN_OPTS:"); + logError(content.trim()); + logError("Please update it to preserve existing MAVEN_OPTS or remove the file"); + } + } catch (IOException e) { + logWarning("Could not read ~/.mavenrc: " + e.getMessage()); + } + } + } + } + + return result; + } + static class ProcessResult { final int exitCode; final String output; @@ -634,15 +780,23 @@ public Integer call() { logSuccess("Maven settings.xml found"); } - // Create/update .mavenrc + // Check Maven configuration Path mavenrc = Paths.get(System.getProperty("user.home"), ".mavenrc"); - if (!Files.exists(mavenrc)) { + if (Files.exists(mavenrc)) { try { - Files.writeString(mavenrc, "# Maven release configuration\nexport MAVEN_OPTS=\"-Xmx2g -XX:ReservedCodeCacheSize=1g\"\n"); - logSuccess("Created ~/.mavenrc with recommended settings"); + String content = Files.readString(mavenrc); + if (content.contains("export MAVEN_OPTS=") && !content.contains("$MAVEN_OPTS")) { + logWarning("Found ~/.mavenrc that may override MAVEN_OPTS environment variable"); + logWarning("Consider updating it to: export MAVEN_OPTS=\"$MAVEN_OPTS <your-options>\""); + logWarning("Current content: " + content.trim()); + } else { + logInfo("Maven configuration looks good"); + } } catch (IOException e) { - logWarning("Failed to create ~/.mavenrc: " + e.getMessage()); + logWarning("Could not read ~/.mavenrc: " + e.getMessage()); } + } else { + logInfo("No ~/.mavenrc found (this is fine)"); } System.out.println(); @@ -659,16 +813,16 @@ public Integer call() { System.out.println(" export GMAIL_APP_PASSWORD=your-app-password"); System.out.println(" export GMAIL_SENDER_ADDRESS=your-sender@domain.org # optional"); System.out.println(); - System.out.println("Then you can start a release:"); - System.out.println(" jbang release.java start-vote 4.0.0-rc-4"); + System.out.println("Then you can stage a release:"); + System.out.println(" jbang release.java stage 4.0.0-rc-4"); return 0; } } - // Start Vote Command - @Command(name = "start-vote", description = "Start release vote (Click 1)") - static class StartVoteCommand implements Callable<Integer> { + // Stage Command + @Command(name = "stage", description = "Stage release for voting (Click 1)") + static class StageCommand implements Callable<Integer> { @Parameters(index = "0", description = "Release version (e.g., 4.0.0-rc-4)") private String version; @@ -681,7 +835,7 @@ static class StartVoteCommand implements Callable<Integer> { @Override public Integer call() { - System.out.println("🚀 Starting Maven release vote for version " + version); + System.out.println("🚀 Staging Maven release for voting: " + version); System.out.println("📁 Project root: " + PROJECT_ROOT); try { @@ -696,7 +850,7 @@ public Integer call() { Scanner scanner = new Scanner(System.in); String response = scanner.nextLine(); if (!response.equalsIgnoreCase("y")) { - logInfo("Starting fresh release process"); + logInfo("Starting fresh staging process"); currentStep = ReleaseStep.VALIDATION; } } @@ -791,7 +945,7 @@ public Integer call() { logWarning("Using --skip-dry-run option - this is faster but riskier!"); } if (skipTests) { - logWarning("Using --skip-tests option - tests will be skipped throughout the release process!"); + logWarning("Using --skip-tests option - tests will be skipped throughout the staging process!"); } prepareRelease(version, skipDryRun, skipTests); markStepCompleted(version, ReleaseStep.PREPARE_RELEASE); @@ -827,7 +981,16 @@ public Integer call() { logInfo("Skipping documentation staging (already completed)"); } - // Step 9: Copy to dist area + // Step 9: Update website for release + if (!isStepCompleted(version, ReleaseStep.STAGE_WEBSITE)) { + saveCurrentStep(version, ReleaseStep.STAGE_WEBSITE); + updateWebsiteForRelease(version); + markStepCompleted(version, ReleaseStep.STAGE_WEBSITE); + } else { + logInfo("Skipping website update (already completed)"); + } + + // Step 10: Copy to dist area if (!isStepCompleted(version, ReleaseStep.COPY_DIST)) { saveCurrentStep(version, ReleaseStep.COPY_DIST); copyToDistArea(version); @@ -836,7 +999,7 @@ public Integer call() { logInfo("Skipping dist area copy (already completed)"); } - // Step 10: Generate vote email + // Step 11: Generate vote email if (!isStepCompleted(version, ReleaseStep.GENERATE_EMAIL)) { saveCurrentStep(version, ReleaseStep.GENERATE_EMAIL); generateVoteEmail(version, stagingRepo, milestoneInfo, releaseNotes); @@ -845,7 +1008,7 @@ public Integer call() { logInfo("Skipping vote email generation (already completed)"); } - // Step 11: Save milestone info (staging repo ID already saved) + // Step 12: Save milestone info (staging repo ID already saved) if (!isStepCompleted(version, ReleaseStep.SAVE_INFO)) { saveCurrentStep(version, ReleaseStep.SAVE_INFO); saveMilestoneInfo(version, milestoneInfo); @@ -884,12 +1047,12 @@ public Integer call() { System.out.println(); System.out.println("Next steps:"); System.out.println("1. Wait for vote results (72+ hours)"); - System.out.println("2. If vote passes, run: jbang release.java publish " + version); + System.out.println("2. If vote passes, run: jbang release.java publish # (version auto-detected)"); return 0; } catch (Exception e) { - logError("Failed to start vote: " + e.getMessage()); + logError("Failed to stage release: " + e.getMessage()); e.printStackTrace(); return 1; } @@ -914,7 +1077,7 @@ public Integer call() { Path emailFile = PROJECT_ROOT.resolve("vote-email-" + version + ".txt"); if (!Files.exists(emailFile)) { logError("Vote email file not found: " + emailFile); - logInfo("Please run 'start-vote " + version + "' first to generate the vote email"); + logInfo("Please run 'stage " + version + "' first to generate the vote email"); return 1; } @@ -941,7 +1104,7 @@ public Integer call() { System.out.println(); System.out.println("Next steps:"); System.out.println("1. Wait for vote results (72+ hours)"); - System.out.println("2. If vote passes, run: jbang release.java publish " + version); + System.out.println("2. If vote passes, run: jbang release.java publish # (version auto-detected)"); } else { logWarning("Vote email was NOT sent automatically"); logInfo("Please send the email manually:"); @@ -1253,6 +1416,596 @@ static void stageDocumentation(String version) throws Exception { logToFile(version, "STAGE_DOCS", "Documentation staging completed successfully"); } + static void updateWebsiteForRelease(String version) throws Exception { + logStep("Updating website for release " + version + "..."); + + // Step 1: Confirm changelog is up to date + confirmChangelogUpToDate(version); + + // Create a temporary directory for the maven-site checkout + Path tempDir = Files.createTempDirectory("maven-site-update"); + Path siteDir = tempDir.resolve("maven-site"); + + try { + // Step 2: Clone the maven-site repository + logInfo("Cloning maven-site repository..."); + ProcessResult cloneResult = runCommandWithLogging(version, "STAGE_WEBSITE", + "git", "clone", "https://github.com/apache/maven-site.git", siteDir.toString()); + + if (!cloneResult.isSuccess()) { + throw new RuntimeException("Failed to clone maven-site repository: " + cloneResult.error); + } + + // Step 3: Update the website files for the release + logInfo("Updating website files for version " + version + "..."); + updateSiteFiles(siteDir, version); + + logSuccess("Website files updated for release " + version); + logInfo("Website checkout available at: " + siteDir); + logInfo("Next steps (to be automated later):"); + logInfo("1. Create a new branch for the release"); + logInfo("2. Commit the changes"); + logInfo("3. Create a pull request"); + logInfo("4. Review and merge the PR"); + + } catch (Exception e) { + // Clean up on error + try { + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) // Delete files before directories + .forEach(path -> { + try { + Files.delete(path); + } catch (Exception ignored) {} + }); + } catch (Exception ignored) {} + throw e; + } + + logToFile(version, "STAGE_WEBSITE", "Website update completed successfully"); + } + + static void confirmChangelogUpToDate(String version) throws Exception { + logInfo("Checking GitHub changelog for version " + version + "..."); + + // Try to fetch changelog from GitHub + String changelog = fetchChangelogFromGitHub(version); + + if (changelog != null && !changelog.trim().isEmpty() && !changelog.equals("null")) { + logSuccess("Found changelog in GitHub release:"); + System.out.println("----------------------------------------"); + // Show first few lines of changelog + String[] lines = changelog.split("\n"); + int linesToShow = Math.min(10, lines.length); + for (int i = 0; i < linesToShow; i++) { + System.out.println(lines[i]); + } + if (lines.length > linesToShow) { + System.out.println("... (" + (lines.length - linesToShow) + " more lines)"); + } + System.out.println("----------------------------------------"); + System.out.println(); + System.out.print("Is the GitHub release changelog up to date and ready for release? (y/N): "); + } else { + logWarning("No changelog found in GitHub releases for version " + version); + logWarning("Please ensure the GitHub release (draft or published) has an up-to-date changelog"); + logWarning("The changelog should include all improvements, bug fixes, and dependency upgrades"); + System.out.println(); + System.out.print("Have you updated the GitHub release changelog and is it ready? (y/N): "); + } + + Scanner scanner = new Scanner(System.in); + String response = scanner.nextLine(); + + if (!response.equalsIgnoreCase("y")) { + logError("Changelog confirmation failed"); + logInfo("Please update the GitHub release changelog before proceeding:"); + logInfo("1. Go to https://github.com/apache/maven/releases"); + logInfo("2. Find the draft release for " + version + " (or create one)"); + logInfo("3. Update the release notes with complete changelog"); + logInfo("4. Save the draft release"); + logInfo("5. Re-run the staging process"); + throw new RuntimeException("Changelog not confirmed as up to date"); + } + + logSuccess("Changelog confirmed as up to date - proceeding with website update"); + } + + static void updateSiteFiles(Path siteDir, String version) throws Exception { + // This function will update the necessary files in the maven-site repository + // Based on the pattern from PR #714: https://github.com/apache/maven-site/pull/714 + + logInfo("Updating site files for Maven " + version); + + // 1. Create release notes directory and file + createReleaseNotes(siteDir, version); + + // 2. Update history.md.vm file + updateHistoryFile(siteDir, version); + + // 3. Copy XSD files for the new version + copyXsdFiles(siteDir, version); + + // 4. Update .htaccess file + updateHtaccessFile(siteDir, version); + + // 5. Update pom.xml version properties + updatePomVersionProperties(siteDir, version); + + logSuccess("Website files updated for Maven " + version); + logInfo("Changes made:"); + logInfo("1. Created release notes at content/markdown/docs/" + version + "/release-notes.md"); + logInfo("2. Updated content/markdown/docs/history.md.vm"); + logInfo("3. Added XSD files for version " + version); + logInfo("4. Updated content/resources/xsd/.htaccess"); + logInfo("5. Updated pom.xml version properties"); + logInfo(""); + logInfo("Next steps (to be automated later):"); + logInfo("1. Review the changes in: " + siteDir); + logInfo("2. Create a new branch: git checkout -b maven-" + version); + logInfo("3. Commit changes: git add . && git commit -m 'Publish Maven " + version + "'"); + logInfo("4. Push branch: git push origin maven-" + version); + logInfo("5. Create PR: gh pr create --title 'Publish Maven " + version + "' --body 'Release notes and documentation for Maven " + version + "'"); + } + + static void createReleaseNotes(Path siteDir, String version) throws Exception { + logInfo("Creating release notes for version " + version + "..."); + + // Create the version-specific directory + Path versionDir = siteDir.resolve("content/markdown/docs/" + version); + Files.createDirectories(versionDir); + + // Create release notes file + Path releaseNotesFile = versionDir.resolve("release-notes.md"); + + String releaseNotesContent = generateReleaseNotesContent(version); + Files.writeString(releaseNotesFile, releaseNotesContent); + + logInfo("Created release notes at: " + releaseNotesFile); + } + + static String generateReleaseNotesContent(String version) { + StringBuilder content = new StringBuilder(); + content.append("# Apache Maven ").append(version).append(" Release Notes\n\n"); + content.append("Apache Maven ").append(version).append(" is available for download.\n\n"); + content.append("Maven is a software project management and comprehension tool. Based on the concept of a project object model (POM), Maven can manage a project's build, reporting and documentation from a central piece of information.\n\n"); + content.append("The core release is independent of plugin releases. Further releases of plugins will be made separately.\n\n"); + content.append("If you have any questions, please consult:\n\n"); + content.append("- the web site: https://maven.apache.org/\n"); + content.append("- the maven-user mailing list: https://maven.apache.org/mailing-lists.html\n"); + content.append("- the reference documentation: https://maven.apache.org/ref/").append(version).append("/\n\n"); + + if (version.startsWith("4.")) { + content.append("## Maven 4.x\n\n"); + content.append("Maven 4.x is a major release that includes significant improvements and changes.\n"); + content.append("Please refer to the [Maven 4.x documentation](https://maven.apache.org/ref/").append(version).append("/) for detailed information about new features and migration guidance.\n\n"); + content.append("### Important Notes\n\n"); + content.append("- This is a release candidate version intended for testing and feedback\n"); + content.append("- Please report any issues to the Maven JIRA: https://issues.apache.org/jira/projects/MNG\n"); + content.append("- For production use, consider the stability and compatibility requirements of your project\n\n"); + } + + // Try to fetch changelog from GitHub + String changelog = fetchChangelogFromGitHub(version); + if (changelog != null && !changelog.trim().isEmpty()) { + content.append("## Changelog\n\n"); + content.append(changelog).append("\n\n"); + } else { + content.append("## Improvements\n\n"); + content.append("<!-- Add specific improvements for this release -->\n\n"); + content.append("## Bug fixes\n\n"); + content.append("<!-- Add specific bug fixes for this release -->\n\n"); + content.append("## Dependency upgrade\n\n"); + content.append("<!-- Add dependency upgrades for this release -->\n\n"); + } + + content.append("## Full changelog\n\n"); + content.append("For a full list of changes, please refer to the [GitHub release page](https://github.com/apache/maven/releases/tag/maven-").append(version).append(").\n"); + + return content.toString(); + } + + static String fetchChangelogFromGitHub(String version) { + try { + logInfo("Fetching changelog from GitHub for version " + version + "..."); + + // Try to get the draft release first + ProcessResult draftResult = runCommandSimple("gh", "api", "repos/apache/maven/releases", + "--jq", ".[] | select(.draft == true and (.tag_name == \"maven-" + version + + "\" or .tag_name == \"" + version + "\" or .name | contains(\"" + version + "\"))) | .body"); + + if (draftResult.isSuccess() && !draftResult.output.trim().isEmpty()) { + String changelog = draftResult.output.trim(); + if (!changelog.equals("null") && !changelog.isEmpty()) { + logInfo("Found changelog in draft release"); + return changelog; + } + } + + // Try to get from published release + ProcessResult releaseResult = runCommandSimple("gh", "api", "repos/apache/maven/releases/tags/maven-" + version, + "--jq", ".body"); + + if (releaseResult.isSuccess() && !releaseResult.output.trim().isEmpty()) { + String changelog = releaseResult.output.trim(); + if (!changelog.equals("null") && !changelog.isEmpty()) { + logInfo("Found changelog in published release"); + return changelog; + } + } + + logWarning("No changelog found in GitHub releases for version " + version); + return null; + + } catch (Exception e) { + logWarning("Failed to fetch changelog from GitHub: " + e.getMessage()); + return null; + } + } + + static void updateHistoryFile(Path siteDir, String version) throws Exception { + logInfo("Updating history.md.vm file..."); + + Path historyFile = siteDir.resolve("content/markdown/docs/history.md.vm"); + + if (!Files.exists(historyFile)) { + logWarning("History file not found at: " + historyFile); + logWarning("Creating a basic history file..."); + Files.createDirectories(historyFile.getParent()); + String basicHistory = "# Apache Maven Release History\n\n" + + "#release( \"TBD\" \"" + version + "\" \"TBD\" \"true\" \"Java 17\" \"1\" )\n\n"; + Files.writeString(historyFile, basicHistory); + logInfo("Created basic history file with release entry for " + version); + return; + } + + // Read existing content + String existingContent = Files.readString(historyFile); + + // Find the Java requirement sequence number (count of releases with same Java requirement) + int javaSequenceNumber = findJavaSequenceNumber(existingContent, "Java 17"); + int newSequenceNumber = javaSequenceNumber + 1; + + // Create new release entry for the top + String newReleaseEntry = "#release( \"TBD\" \"" + version + "\" \"TBD\" \"true\" \"Java 17\" \"" + newSequenceNumber + "\" )"; + + // Transform the existing content (only update the first entry) + String updatedContent = transformHistoryContentWithJavaSequence(existingContent, newReleaseEntry); + + Files.writeString(historyFile, updatedContent); + + logInfo("Updated history file with new release entry: " + newReleaseEntry); + logInfo("Java 17 sequence number: " + newSequenceNumber); + logInfo("Previous top release entry updated to move Java requirement to first line"); + logWarning("Note: Date and announcement URL are set to 'TBD' - update after announcement email is sent"); + } + + static String transformHistoryContentWithJavaSequence(String existingContent, String newReleaseEntry) { + String[] lines = existingContent.split("\n"); + StringBuilder result = new StringBuilder(); + + boolean newEntryAdded = false; + boolean firstReleaseEntryProcessed = false; + boolean inReleaseSection = false; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + + // Check if this is a #release line + if (line.trim().startsWith("#release(")) { + inReleaseSection = true; + + // Add the new entry and transform the first existing release entry + if (!newEntryAdded) { + result.append(newReleaseEntry).append("\n"); + newEntryAdded = true; + } + + // Only transform the first (top) release entry + if (!firstReleaseEntryProcessed) { + String transformedLine = transformReleaseEntryToEmpty(line); + result.append(transformedLine).append("\n"); + firstReleaseEntryProcessed = true; + } else { + // Keep other entries as-is + result.append(line).append("\n"); + } + } else if (inReleaseSection && line.trim().isEmpty()) { + // Skip empty lines in the release section + continue; + } else { + // Not a release line and not an empty line in release section + if (inReleaseSection && !line.trim().isEmpty() && !line.trim().startsWith("#release(")) { + // We've left the release section + inReleaseSection = false; + result.append("\n"); // Add one empty line before the next section + } + result.append(line).append("\n"); + } + } + + // If we never found any #release entries, add the new one at the end + if (!newEntryAdded) { + result.append("\n").append(newReleaseEntry).append("\n"); + } + + return result.toString(); + } + + static String transformHistoryContent(String existingContent, String newReleaseEntry) { + String[] lines = existingContent.split("\n"); + StringBuilder result = new StringBuilder(); + + boolean headerFound = false; + boolean newEntryAdded = false; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + + // Check if this is a #release line that needs transformation + if (line.trim().startsWith("#release(")) { + // Add the new entry before the first existing release entry + if (!newEntryAdded) { + result.append(newReleaseEntry).append("\n"); + newEntryAdded = true; + } + + // Transform the existing release entry + String transformedLine = transformReleaseEntry(line); + result.append(transformedLine).append("\n"); + } else { + result.append(line).append("\n"); + + // Add new entry after header if we haven't found any release entries yet + if (!headerFound && !newEntryAdded && !line.trim().isEmpty() && !line.startsWith("#")) { + headerFound = true; + // Don't add here yet, wait for first #release entry or end of file + } + } + } + + // If we never found any #release entries, add the new one at the end + if (!newEntryAdded) { + result.append("\n").append(newReleaseEntry).append("\n"); + } + + return result.toString(); + } + + static String transformReleaseEntryToEmpty(String releaseEntry) { + // Pattern to match: #release( "date" "version" "url" "param4" "param5" "param6" ) + // Handle both filled and empty parameters + Pattern pattern = Pattern.compile("#release\\s*\\(\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\\)"); + java.util.regex.Matcher matcher = pattern.matcher(releaseEntry); + + if (matcher.find()) { + String date = matcher.group(1); + String version = matcher.group(2); + String url = matcher.group(3); + String param4 = matcher.group(4); + String param5 = matcher.group(5); + String param6 = matcher.group(6); + + // If this entry already has empty parameters, leave it as-is + if (param4.isEmpty() && param5.isEmpty() && param6.isEmpty()) { + return releaseEntry; + } + + // Transform to: #release( "date" "version" "url" "" "" "" ) + return "#release( \"" + date + "\" \"" + version + "\" \"" + url + "\" \"\" \"\" \"\" )"; + } else { + // If pattern doesn't match, return as-is (but don't warn for already processed entries) + if (!releaseEntry.contains("\"\" \"\" \"\"")) { + logWarning("Could not parse release entry: " + releaseEntry); + } + return releaseEntry; + } + } + + static String transformReleaseEntry(String releaseEntry) { + // Pattern to match: #release( "date" "version" "url" "param4" "param5" "param6" ) + // Handle both filled and empty parameters + Pattern pattern = Pattern.compile("#release\\s*\\(\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\"([^\"]*)\"\\s*\\)"); + java.util.regex.Matcher matcher = pattern.matcher(releaseEntry); + + if (matcher.find()) { + String date = matcher.group(1); + String version = matcher.group(2); + String url = matcher.group(3); + String param4 = matcher.group(4); + String param5 = matcher.group(5); + String param6 = matcher.group(6); + + // If this entry already has empty parameters, leave it as-is + if (param4.isEmpty() && param5.isEmpty() && param6.isEmpty()) { + return releaseEntry; + } + + // Transform to: #release( "date" "version" "url" "" "" "" ) + return "#release( \"" + date + "\" \"" + version + "\" \"" + url + "\" \"\" \"\" \"\" )"; + } else { + // If pattern doesn't match, return as-is (but don't warn for already processed entries) + if (!releaseEntry.contains("\"\" \"\" \"\"")) { + logWarning("Could not parse release entry: " + releaseEntry); + } + return releaseEntry; + } + } + + static int findJavaSequenceNumber(String content, String javaRequirement) { + // Find all #release entries with the same Java requirement and count them + // Pattern to match: #release( "date" "version" "url" "true" "Java X" "number" ) + Pattern releasePattern = Pattern.compile("#release\\s*\\(\\s*\"[^\"]*\"\\s*\"[^\"]*\"\\s*\"[^\"]*\"\\s*\"[^\"]*\"\\s*\"" + Pattern.quote(javaRequirement) + "\"\\s*\"(\\d+)\"\\s*\\)"); + java.util.regex.Matcher matcher = releasePattern.matcher(content); + + int maxNumber = 0; + while (matcher.find()) { + try { + int number = Integer.parseInt(matcher.group(1)); + maxNumber = Math.max(maxNumber, number); + } catch (NumberFormatException e) { + // Ignore invalid numbers + } + } + + return maxNumber; + } + + static int findMaxSequenceNumber(String content) { + // Find all #release entries and extract the sequence number (last parameter) + Pattern releasePattern = Pattern.compile("#release\\s*\\(\\s*\"[^\"]*\"\\s*\"[^\"]*\"\\s*\"[^\"]*\"\\s*\"[^\"]*\"\\s*\"[^\"]*\"\\s*\"(\\d+)\"\\s*\\)"); + java.util.regex.Matcher matcher = releasePattern.matcher(content); + + int maxNumber = 0; + while (matcher.find()) { + try { + int number = Integer.parseInt(matcher.group(1)); + maxNumber = Math.max(maxNumber, number); + } catch (NumberFormatException e) { + // Ignore invalid numbers + } + } + + return maxNumber; + } + + static void copyXsdFiles(Path siteDir, String version) throws Exception { + logInfo("Copying XSD files for version " + version + "..."); + + Path xsdDir = siteDir.resolve("content/resources/xsd"); + Files.createDirectories(xsdDir); + + // Define the XSD file mappings: source path -> target filename + Map<String, String> xsdMappings = new LinkedHashMap<>(); + String rcVersion = extractRcVersion(version); + + xsdMappings.put("target/checkout/api/maven-api-metadata/target/site/xsd/repository-metadata-1.2.0.xsd", + "repository-metadata-1.2.0-" + rcVersion + ".xsd"); + xsdMappings.put("target/checkout/api/maven-api-toolchain/target/site/xsd/toolchains-1.2.0.xsd", + "toolchains-1.2.0-" + rcVersion + ".xsd"); + xsdMappings.put("target/checkout/api/maven-api-model/target/site/xsd/maven-4.1.0.xsd", + "maven-4.1.0-" + rcVersion + ".xsd"); + xsdMappings.put("target/checkout/api/maven-api-cli/target/site/xsd/core-extensions-1.2.0.xsd", + "core-extensions-1.2.0-" + rcVersion + ".xsd"); + xsdMappings.put("target/checkout/api/maven-api-plugin/target/site/xsd/lifecycle-2.0.0.xsd", + "lifecycle-2.0.0-" + rcVersion + ".xsd"); + xsdMappings.put("target/checkout/api/maven-api-plugin/target/site/xsd/plugin-2.0.0.xsd", + "plugin-2.0.0-" + rcVersion + ".xsd"); + xsdMappings.put("target/checkout/api/maven-api-settings/target/site/xsd/settings-2.0.0.xsd", + "settings-2.0.0-" + rcVersion + ".xsd"); + + int copiedCount = 0; + int placeholderCount = 0; + + for (Map.Entry<String, String> entry : xsdMappings.entrySet()) { + String sourcePath = entry.getKey(); + String targetFileName = entry.getValue(); + + Path sourceFile = PROJECT_ROOT.resolve(sourcePath); + Path targetFile = xsdDir.resolve(targetFileName); + + if (Files.exists(sourceFile)) { + Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); + logInfo("Copied XSD file: " + sourcePath + " -> " + targetFileName); + copiedCount++; + } else { + // Create placeholder if source doesn't exist + String xsdContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + + "<!-- XSD file for Maven " + version + " -->\n" + + "<!-- Source not found: " + sourcePath + " -->\n" + + "<!-- This is a placeholder - replace with actual XSD content -->\n" + + "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">\n" + + " <!-- Schema content goes here -->\n" + + "</xs:schema>\n"; + Files.writeString(targetFile, xsdContent); + logWarning("Source XSD not found, created placeholder: " + targetFileName); + logWarning("Expected source: " + sourceFile); + placeholderCount++; + } + } + + if (copiedCount > 0) { + logSuccess("Successfully copied " + copiedCount + " XSD files from Maven build output"); + } + if (placeholderCount > 0) { + logWarning(placeholderCount + " XSD files created as placeholders - build the project first or copy manually"); + } + } + + static String extractRcVersion(String version) { + // Extract the RC part from version like "4.0.0-rc-4" -> "rc-4" + if (version.contains("-rc-")) { + return version.substring(version.indexOf("-rc-") + 1); + } + return "rc-1"; // fallback + } + + static void updateHtaccessFile(Path siteDir, String version) throws Exception { + logInfo("Updating .htaccess file for version " + version + "..."); + + Path htaccessFile = siteDir.resolve("content/resources/xsd/.htaccess"); + + if (!Files.exists(htaccessFile)) { + logWarning(".htaccess file not found, creating new one..."); + Files.createDirectories(htaccessFile.getParent()); + String basicHtaccess = "# Apache Maven XSD redirects\n" + + "# Updated for version " + version + "\n\n"; + Files.writeString(htaccessFile, basicHtaccess); + } + + // Read existing content + String existingContent = Files.readString(htaccessFile); + String rcVersion = extractRcVersion(version); + + // Define the XSD redirects that need to be updated + Map<String, String> xsdRedirects = new LinkedHashMap<>(); + xsdRedirects.put("maven-4.1.0.xsd", "maven-4.1.0-" + rcVersion + ".xsd"); + xsdRedirects.put("settings-2.0.0.xsd", "settings-2.0.0-" + rcVersion + ".xsd"); + xsdRedirects.put("toolchains-1.2.0.xsd", "toolchains-1.2.0-" + rcVersion + ".xsd"); + xsdRedirects.put("core-extensions-1.2.0.xsd", "core-extensions-1.2.0-" + rcVersion + ".xsd"); + xsdRedirects.put("lifecycle-2.0.0.xsd", "lifecycle-2.0.0-" + rcVersion + ".xsd"); + xsdRedirects.put("plugin-2.0.0.xsd", "plugin-2.0.0-" + rcVersion + ".xsd"); + xsdRedirects.put("repository-metadata-1.2.0.xsd", "repository-metadata-1.2.0-" + rcVersion + ".xsd"); + + String updatedContent = existingContent; + int updatedCount = 0; + int addedCount = 0; + + for (Map.Entry<String, String> entry : xsdRedirects.entrySet()) { + String baseXsd = entry.getKey(); + String versionedXsd = entry.getValue(); + + // Pattern to match existing redirect for this XSD (with or without Redirect keyword) + String redirectPattern = "(Redirect\\s+)?/xsd/" + Pattern.quote(baseXsd) + "\\s+/xsd/[^\\s]+"; + String newRedirect = "Redirect /xsd/" + baseXsd + " /xsd/" + versionedXsd; + + if (updatedContent.matches("(?s).*" + redirectPattern + ".*")) { + // Update existing redirect + updatedContent = updatedContent.replaceAll(redirectPattern, newRedirect); + logInfo("Updated redirect for " + baseXsd + " -> " + versionedXsd); + updatedCount++; + } else { + // Add new redirect + updatedContent += "\n" + newRedirect; + logInfo("Added new redirect for " + baseXsd + " -> " + versionedXsd); + addedCount++; + } + } + + // Add comment about the update + if (updatedCount > 0 || addedCount > 0) { + String updateComment = "# Updated for Maven " + version + " (" + + updatedCount + " updated, " + addedCount + " added)\n"; + updatedContent = updateComment + updatedContent + "\n"; + } + + // Write the updated content + Files.writeString(htaccessFile, updatedContent); + + logSuccess("Updated .htaccess file: " + htaccessFile); + logInfo("Redirects updated: " + updatedCount + ", new redirects added: " + addedCount); + } + static String findStagingRepository(String version) { logInfo("Searching for staging repository ID..."); @@ -1990,16 +2743,142 @@ static boolean confirmVoteResults() { return true; } - static void promoteStagingRepo(String stagingRepo) throws Exception { + static void promoteStagingRepo(String version, String stagingRepo) throws Exception { logStep("Promoting staging repository..."); - ProcessResult result = runCommandSimple("mvn", "-V", - "org.sonatype.plugins:nexus-staging-maven-plugin:1.7.0:promote", + + // First, check if the staging repository exists and is in the correct state + logInfo("Checking staging repository status: " + stagingRepo); + ProcessResult statusResult = runCommandWithLogging(version, "CHECK_STAGING", "mvn", "-V", + "org.sonatype.plugins:nexus-staging-maven-plugin:1.7.0:rc-list", + "-DnexusUrl=https://repository.apache.org/", + "-DserverId=apache.releases.https"); + + if (statusResult.isSuccess() && statusResult.output.contains(stagingRepo)) { + logInfo("Staging repository " + stagingRepo + " found"); + } else if (statusResult.isSuccess() && !statusResult.output.contains(stagingRepo)) { + // Repository not found - it might have been promoted already + logWarning("Staging repository " + stagingRepo + " not found in staging area"); + logWarning("This could mean:"); + logWarning("1. The repository was already promoted to Maven Central"); + logWarning("2. The repository was dropped/deleted"); + logWarning("3. There was a timeout during a previous promotion attempt"); + System.out.println(); + System.out.print("Has the staging repository been successfully promoted to Maven Central? (y/N): "); + Scanner scanner = new Scanner(System.in); + String response = scanner.nextLine(); + if (response.equalsIgnoreCase("y")) { + logSuccess("Staging repository promotion confirmed - continuing with release process"); + return; + } else { + logError("Staging repository promotion not confirmed"); + logInfo("You can check the status at: https://repository.apache.org/"); + logInfo("Or check Maven Central: https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/" + version + "/"); + throw new RuntimeException("Staging repository promotion status unclear - please verify manually"); + } + } else { + logWarning("Could not verify staging repository status, proceeding anyway..."); + } + + // Use enhanced logging to capture detailed output + ProcessResult result = runCommandWithJvmArgs(version, "PROMOTE_STAGING", + "--add-opens=java.base/java.util=ALL-UNNAMED " + + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED " + + "--add-opens=java.base/java.text=ALL-UNNAMED " + + "--add-opens=java.desktop/java.awt.font=ALL-UNNAMED", + "mvn", "-V", "-X", + "org.sonatype.plugins:nexus-staging-maven-plugin:1.7.0:rc-release", "-DstagingRepositoryId=" + stagingRepo, "-DnexusUrl=https://repository.apache.org/", "-DserverId=apache.releases.https"); + // Check if the command actually failed or if it's just warnings if (!result.isSuccess()) { - throw new RuntimeException("Failed to promote staging repository: " + result.error); + // Check if the error is only warnings about deprecated methods + boolean onlyWarnings = result.error.trim().isEmpty() || + (result.error.contains("WARNING:") && + result.error.contains("sun.misc.Unsafe") && + !result.error.toLowerCase().contains("error") && + !result.error.toLowerCase().contains("failed") && + !result.error.toLowerCase().contains("exception")); + + if (onlyWarnings && result.output.contains("BUILD SUCCESS")) { + // Maven succeeded but printed warnings to stderr + logWarning("Maven command completed with warnings (this is normal):"); + for (String line : result.error.split("\n")) { + if (line.trim().startsWith("WARNING:")) { + logWarning(" " + line.trim()); + } + } + logSuccess("Staging repository promoted to Maven Central"); + return; + } + + // Real error occurred + logError("Staging repository promotion failed"); + logError("Exit code: " + result.exitCode); + logError("Command output: " + result.output); + logError("Command error: " + result.error); + + // Check if this is a known issue with the plugin or a timeout + boolean isKnownIssue = result.output.contains("buildPromotionProfileId") || + result.error.contains("buildPromotionProfileId") || + result.output.contains("No converter available") || + result.output.contains("does not \"opens java.util\" to unnamed module") || + result.error.contains("No converter available") || + result.error.contains("does not \"opens java.util\" to unnamed module"); + + boolean mightBeTimeout = result.output.contains("RC-Releasing staging repository") || + result.output.contains("Connected to Nexus"); + + if (isKnownIssue || mightBeTimeout) { + if (result.output.contains("No converter available") || + result.output.contains("does not \"opens java.util\" to unnamed module")) { + logWarning("The nexus-staging-maven-plugin has compatibility issues with Java " + + System.getProperty("java.version") + "."); + logWarning("This is a known issue with the plugin and newer Java versions."); + } else if (mightBeTimeout) { + logWarning("The staging repository promotion may have started but timed out or failed."); + logWarning("The repository might have been promoted successfully despite the error."); + } else { + logWarning("The nexus-staging-maven-plugin requires additional configuration."); + } + + System.out.println(); + logWarning("Please check the staging repository status:"); + logWarning("1. Go to https://repository.apache.org/"); + logWarning("2. Login with your Apache credentials"); + logWarning("3. Go to 'Build Promotion' -> 'Staging Repositories'"); + logWarning("4. Check if repository " + stagingRepo + " is still there or was promoted"); + logWarning("5. If still there, select it and click 'Release' to promote to Maven Central"); + logWarning("6. You can also check Maven Central: https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/" + version + "/"); + System.out.println(); + System.out.print("Has the staging repository been successfully promoted to Maven Central? (y/N): "); + Scanner scanner = new Scanner(System.in); + String response = scanner.nextLine(); + if (response.equalsIgnoreCase("y")) { + logSuccess("Staging repository promotion confirmed - continuing with release process"); + return; + } else { + throw new RuntimeException("Staging repository promotion was not completed"); + } + } + + // Filter out warnings to show the real error + String actualError = result.error; + if (actualError.contains("WARNING:") && actualError.contains("sun.misc.Unsafe")) { + String[] lines = actualError.split("\n"); + StringBuilder realError = new StringBuilder(); + for (String line : lines) { + if (!line.trim().startsWith("WARNING:") && !line.trim().isEmpty()) { + realError.append(line).append("\n"); + } + } + if (realError.length() > 0) { + actualError = realError.toString().trim(); + } + } + + throw new RuntimeException("Failed to promote staging repository: " + actualError); } logSuccess("Staging repository promoted to Maven Central"); @@ -2008,49 +2887,181 @@ static void promoteStagingRepo(String stagingRepo) throws Exception { static void finalizeDistribution(String version) throws Exception { logStep("Finalizing Apache distribution area..."); - Path distDir = PROJECT_ROOT.resolve("maven-dist-staging"); - if (!Files.exists(distDir)) { - throw new RuntimeException("Distribution staging directory not found: " + distDir); + // Step 1: Move from dev/staging area to release area + moveFromStagingToRelease(version); + + // Step 2: Clean up old releases (keep only latest 3) + cleanupOldReleases(version); + + logSuccess("Distribution finalized in Apache release area"); + } + + static void updatePomVersionProperties(Path siteDir, String version) throws Exception { + logInfo("Updating pom.xml version properties for version " + version + "..."); + + Path pomFile = siteDir.resolve("pom.xml"); + + if (!Files.exists(pomFile)) { + logWarning("pom.xml file not found at: " + pomFile); + logWarning("Skipping pom.xml version property update"); + return; } - // Commit new release - ProcessBuilder pb = new ProcessBuilder("svn", "commit", "-m", "Add Apache Maven " + version + " release"); - pb.directory(distDir.toFile()); - Process process = pb.start(); - int exitCode = process.waitFor(); + // Read existing content + String existingContent = Files.readString(pomFile); + String updatedContent = existingContent; - if (exitCode != 0) { - throw new RuntimeException("Failed to commit to Apache dist area"); + // Determine which version property to update based on the version + if (version.startsWith("4.0.")) { + // Update current4xVersion for 4.0.x releases + updatedContent = updateVersionProperty(updatedContent, "current4xVersion", version); + logInfo("Updated current4xVersion to " + version); + } else if (version.startsWith("4.1.")) { + // Future: Update current41xVersion for 4.1.x releases + updatedContent = updateVersionProperty(updatedContent, "current41xVersion", version); + // Also update current4xVersion to point to latest 4.1.x + updatedContent = updateVersionProperty(updatedContent, "current4xVersion", version); + logInfo("Updated current41xVersion and current4xVersion to " + version); + } else if (version.startsWith("4.")) { + // Generic 4.x version - update current4xVersion + updatedContent = updateVersionProperty(updatedContent, "current4xVersion", version); + logInfo("Updated current4xVersion to " + version); + } else { + logWarning("Version " + version + " does not match expected 4.x pattern - skipping pom.xml update"); + return; } - // Clean up old releases (keep only latest 3) - pb = new ProcessBuilder("svn", "list"); - pb.directory(distDir.toFile()); - process = pb.start(); - String output = new String(process.getInputStream().readAllBytes()); + // Write the updated content + Files.writeString(pomFile, updatedContent); - String[] dirs = output.split("\n"); - List<String> mavenDirs = Arrays.stream(dirs) - .filter(dir -> dir.startsWith("maven-")) + logSuccess("Updated pom.xml version properties"); + } + + static String updateVersionProperty(String pomContent, String propertyName, String newVersion) { + // Pattern to match: <propertyName>old-version</propertyName> + String propertyPattern = "(<" + Pattern.quote(propertyName) + ">)[^<]*(</\\s*" + Pattern.quote(propertyName) + "\\s*>)"; + String replacement = "$1" + newVersion + "$2"; + + String updatedContent = pomContent.replaceAll(propertyPattern, replacement); + + if (updatedContent.equals(pomContent)) { + logWarning("Property " + propertyName + " not found in pom.xml - no update made"); + } else { + logInfo("Updated property " + propertyName + " to " + newVersion); + } + + return updatedContent; + } + + static void moveFromStagingToRelease(String version) throws Exception { + logInfo("Moving release from staging to final distribution area..."); + + // Determine the correct paths based on Maven version + String stagingPath, releasePath; + if (version.startsWith("4.")) { + stagingPath = "https://dist.apache.org/repos/dist/dev/maven/maven-4/" + version + "/"; + releasePath = "https://dist.apache.org/repos/dist/release/maven/maven-4/" + version + "/"; + } else { + stagingPath = "https://dist.apache.org/repos/dist/dev/maven/maven-3/" + version + "/"; + releasePath = "https://dist.apache.org/repos/dist/release/maven/maven-3/" + version + "/"; + } + + logInfo("Moving from: " + stagingPath); + logInfo("Moving to: " + releasePath); + + // Get SVN authentication + String svnUsername = System.getenv("APACHE_USERNAME"); + String svnPassword = System.getenv("APACHE_PASSWORD"); + + // Use svnmucc to move the entire directory + ProcessResult result; + if (svnUsername != null && !svnUsername.isEmpty() && svnPassword != null && !svnPassword.isEmpty()) { + result = runCommandSimple("svnmucc", "-m", "Release Apache Maven " + version, + "--non-interactive", "--trust-server-cert", + "--username", svnUsername, + "--password", svnPassword, + "mv", stagingPath, releasePath); + } else { + logWarning("SVN credentials not available - you may need to authenticate interactively"); + result = runCommandSimple("svnmucc", "-m", "Release Apache Maven " + version, + "mv", stagingPath, releasePath); + } + + if (!result.isSuccess()) { + if (result.error.contains("Authentication failed")) { + logError("SVN authentication failed. Please ensure APACHE_USERNAME and APACHE_PASSWORD are set:"); + logError(" export APACHE_USERNAME=your-apache-id"); + logError(" export APACHE_PASSWORD=your-apache-password"); + logError("Or run the command manually:"); + logError(" svnmucc -m 'Release Apache Maven " + version + "' --username your-apache-id mv " + stagingPath + " " + releasePath); + } + throw new RuntimeException("Failed to move release from staging to final area: " + result.error); + } + + logSuccess("Release moved from staging to final distribution area"); + logInfo("Release now available at: " + releasePath); + } + + static void cleanupOldReleases(String version) throws Exception { + logInfo("Cleaning up old releases (keeping latest 3)..."); + + // Determine the correct release area based on Maven version + String releaseAreaUrl; + if (version.startsWith("4.")) { + releaseAreaUrl = "https://dist.apache.org/repos/dist/release/maven/maven-4/"; + } else { + releaseAreaUrl = "https://dist.apache.org/repos/dist/release/maven/maven-3/"; + } + + // List existing releases + ProcessResult listResult = runCommandSimple("svn", "list", releaseAreaUrl); + if (!listResult.isSuccess()) { + logWarning("Could not list existing releases for cleanup: " + listResult.error); + return; + } + + String[] dirs = listResult.output.split("\n"); + List<String> releaseDirs = Arrays.stream(dirs) + .filter(dir -> dir.trim().endsWith("/")) + .map(dir -> dir.trim().substring(0, dir.trim().length() - 1)) // Remove trailing / + .filter(dir -> !dir.isEmpty()) .sorted() .collect(java.util.stream.Collectors.toList()); - if (mavenDirs.size() > 3) { - List<String> dirsToRemove = mavenDirs.subList(0, mavenDirs.size() - 3); + logInfo("Found " + releaseDirs.size() + " existing releases"); + + if (releaseDirs.size() > 3) { + List<String> dirsToRemove = releaseDirs.subList(0, releaseDirs.size() - 3); + logInfo("Removing " + dirsToRemove.size() + " old releases: " + String.join(", ", dirsToRemove)); + + // Get SVN authentication + String svnUsername = System.getenv("APACHE_USERNAME"); + String svnPassword = System.getenv("APACHE_PASSWORD"); + for (String dir : dirsToRemove) { - if (!dir.trim().isEmpty()) { - pb = new ProcessBuilder("svn", "rm", dir.trim()); - pb.directory(distDir.toFile()); - pb.start().waitFor(); + String dirUrl = releaseAreaUrl + dir + "/"; + ProcessResult result; + + if (svnUsername != null && !svnUsername.isEmpty() && svnPassword != null && !svnPassword.isEmpty()) { + result = runCommandSimple("svnmucc", "-m", "Remove old Maven release " + dir, + "--non-interactive", "--trust-server-cert", + "--username", svnUsername, + "--password", svnPassword, + "rm", dirUrl); + } else { + result = runCommandSimple("svnmucc", "-m", "Remove old Maven release " + dir, + "rm", dirUrl); + } + + if (result.isSuccess()) { + logInfo("Removed old release: " + dir); + } else { + logWarning("Failed to remove old release " + dir + ": " + result.error); } } - - pb = new ProcessBuilder("svn", "commit", "-m", "Remove old Maven releases (keeping only latest 3)"); - pb.directory(distDir.toFile()); - pb.start().waitFor(); + } else { + logInfo("No old releases to clean up (3 or fewer releases found)"); } - - logSuccess("Apache distribution finalized"); } static void deployWebsite(String version) throws Exception { @@ -2058,17 +3069,86 @@ static void deployWebsite(String version) throws Exception { String svnpubsub = "https://svn.apache.org/repos/asf/maven/website/components"; - ProcessResult result = runCommandSimple("svnmucc", "-m", "Publish Maven " + version + " documentation", - "-U", svnpubsub, - "cp", "HEAD", "maven-archives/maven-LATEST", "maven-archives/maven-" + version, - "rm", "maven/maven", - "cp", "HEAD", "maven-archives/maven-" + version, "maven/maven"); + // Get SVN authentication + String svnUsername = System.getenv("APACHE_USERNAME"); + String svnPassword = System.getenv("APACHE_PASSWORD"); - if (!result.isSuccess()) { - throw new RuntimeException("Failed to deploy website: " + result.error); + // Determine the correct paths based on Maven version + if (version.startsWith("4.")) { + // Maven 4.x uses ref/4-LATEST -> ref/4.x.x pattern + String sourcePath = "ref/4-LATEST"; + String targetPath = "ref/" + version; + logInfo("Detected Maven 4.x - using ref/ path structure"); + logInfo("Moving website from " + sourcePath + " to " + targetPath); + + ProcessResult result; + if (svnUsername != null && !svnUsername.isEmpty() && svnPassword != null && !svnPassword.isEmpty()) { + result = runCommandSimple("svnmucc", "-m", "Publish Maven " + version + " documentation", + "--non-interactive", "--trust-server-cert", + "--username", svnUsername, + "--password", svnPassword, + "-U", svnpubsub, + "cp", "HEAD", sourcePath, targetPath); + } else { + logWarning("SVN credentials not available - you may need to authenticate interactively"); + result = runCommandSimple("svnmucc", "-m", "Publish Maven " + version + " documentation", + "-U", svnpubsub, + "cp", "HEAD", sourcePath, targetPath); + } + + if (!result.isSuccess()) { + if (result.error.contains("Authentication failed")) { + logError("SVN authentication failed. Please ensure APACHE_USERNAME and APACHE_PASSWORD are set:"); + logError(" export APACHE_USERNAME=your-apache-id"); + logError(" export APACHE_PASSWORD=your-apache-password"); + logError("Or run the command manually:"); + logError(" svnmucc -m 'Publish Maven " + version + " documentation' --username your-apache-id -U " + svnpubsub + " cp HEAD " + sourcePath + " " + targetPath); + } + throw new RuntimeException("Failed to deploy website: " + result.error); + } + + logSuccess("Website documentation deployed for version " + version); + logInfo("Documentation available at: " + targetPath); + } else { + // Maven 3.x and older use maven-archives/maven-LATEST -> maven-archives/maven-x.x.x pattern + String sourcePath = "maven-archives/maven-LATEST"; + String targetPath = "maven-archives/maven-" + version; + logInfo("Detected Maven 3.x or older - using maven-archives/ path structure"); + logInfo("Moving website from " + sourcePath + " to " + targetPath); + + ProcessResult result; + if (svnUsername != null && !svnUsername.isEmpty() && svnPassword != null && !svnPassword.isEmpty()) { + result = runCommandSimple("svnmucc", "-m", "Publish Maven " + version + " documentation", + "--non-interactive", "--trust-server-cert", + "--username", svnUsername, + "--password", svnPassword, + "-U", svnpubsub, + "cp", "HEAD", sourcePath, targetPath, + "rm", "maven/maven", + "cp", "HEAD", targetPath, "maven/maven"); + } else { + logWarning("SVN credentials not available - you may need to authenticate interactively"); + result = runCommandSimple("svnmucc", "-m", "Publish Maven " + version + " documentation", + "-U", svnpubsub, + "cp", "HEAD", sourcePath, targetPath, + "rm", "maven/maven", + "cp", "HEAD", targetPath, "maven/maven"); + } + + if (!result.isSuccess()) { + if (result.error.contains("Authentication failed")) { + logError("SVN authentication failed. Please ensure APACHE_USERNAME and APACHE_PASSWORD are set:"); + logError(" export APACHE_USERNAME=your-apache-id"); + logError(" export APACHE_PASSWORD=your-apache-password"); + logError("Or run the command manually:"); + logError(" svnmucc -m 'Publish Maven " + version + " documentation' --username your-apache-id -U " + svnpubsub + " cp HEAD " + sourcePath + " " + targetPath + " rm maven/maven cp HEAD " + targetPath + " maven/maven"); + } + throw new RuntimeException("Failed to deploy website: " + result.error); + } + + logSuccess("Website documentation deployed for version " + version); + logInfo("Documentation available at: " + targetPath); } - - logSuccess("Website documentation deployed"); } static void updateGitHubTracking(String version, String milestoneInfo) throws Exception { @@ -2143,6 +3223,72 @@ static String calculateNextVersion(String version) { static void publishGitHubRelease(String version) throws Exception { logStep("Publishing GitHub release..."); + // First, check the current branch in the local repository + ProcessResult currentBranchResult = runCommandSimple("git", "branch", "--show-current"); + String currentBranch = "master"; // default fallback + if (currentBranchResult.isSuccess() && !currentBranchResult.output.trim().isEmpty()) { + currentBranch = currentBranchResult.output.trim(); + logInfo("Current local branch: " + currentBranch); + } else { + logWarning("Could not determine current branch, using master as fallback"); + } + + // Check if the tag exists + ProcessResult tagCheckResult = runCommandSimple("gh", "api", "repos/apache/maven/git/refs/tags/maven-" + version); + boolean tagExists = tagCheckResult.isSuccess(); + String targetCommitish = currentBranch; // use current branch as default + + if (tagExists) { + logInfo("Tag maven-" + version + " exists in the repository"); + + // Get the commit SHA that the tag points to + ProcessResult tagInfoResult = runCommandSimple("gh", "api", "repos/apache/maven/git/refs/tags/maven-" + version, + "--jq", ".object.sha"); + + if (tagInfoResult.isSuccess()) { + String tagCommitSha = tagInfoResult.output.trim().replace("\"", ""); + logInfo("Tag points to commit: " + tagCommitSha); + + // Check if the current branch exists on GitHub + ProcessResult branchCheckResult = runCommandSimple("gh", "api", "repos/apache/maven/branches/" + currentBranch); + if (branchCheckResult.isSuccess()) { + targetCommitish = currentBranch; + logInfo("Using current branch as target: " + targetCommitish); + } else { + // Fall back to using the commit SHA directly + targetCommitish = tagCommitSha; + logInfo("Current branch not found on GitHub, using commit SHA: " + targetCommitish); + } + } else { + logWarning("Could not get tag commit info, using current branch as target"); + targetCommitish = currentBranch; + } + } else { + logWarning("Tag maven-" + version + " does not exist in the repository"); + logWarning("This is expected for release candidates that are staged but not yet tagged"); + + // Check if current branch exists on GitHub + ProcessResult branchCheckResult = runCommandSimple("gh", "api", "repos/apache/maven/branches/" + currentBranch); + if (branchCheckResult.isSuccess()) { + targetCommitish = currentBranch; + logInfo("Will use current branch as target: " + targetCommitish); + } else { + targetCommitish = "master"; + logWarning("Current branch not found on GitHub, falling back to master"); + } + + logWarning("You can create the GitHub release manually after the tag is created, or"); + logWarning("skip this step for now and create it later"); + System.out.println(); + System.out.print("Do you want to skip GitHub release creation for now? (y/N): "); + Scanner scanner = new Scanner(System.in); + String response = scanner.nextLine(); + if (response.equalsIgnoreCase("y")) { + logInfo("Skipping GitHub release creation - you can create it manually later"); + return; + } + } + // Find draft release ProcessResult draftResult = runCommandSimple("gh", "api", "repos/apache/maven/releases", "--jq", ".[] | select(.draft == true and (.tag_name == \"maven-" + version + @@ -2150,20 +3296,35 @@ static void publishGitHubRelease(String version) throws Exception { if (!draftResult.output.trim().isEmpty()) { String releaseId = draftResult.output.trim(); + logInfo("Found existing draft release with ID: " + releaseId); + + logInfo("Using target commitish: " + targetCommitish); // Update tag if needed and publish ProcessResult result = runCommandSimple("gh", "api", "repos/apache/maven/releases/" + releaseId, "--method", "PATCH", "--field", "tag_name=maven-" + version, - "--field", "target_commitish=maven-" + version, + "--field", "target_commitish=" + targetCommitish, "--field", "draft=false"); if (result.isSuccess()) { logSuccess("GitHub release published from draft"); } else { + logError("Failed to publish GitHub release from draft: " + result.error); + if (result.error.contains("target_commitish is invalid")) { + logError("The target commit/branch/tag is invalid. This could mean:"); + logError("1. The tag maven-" + version + " doesn't exist yet"); + logError("2. The tag points to a commit not on the main branch"); + logError("3. There's a permissions issue"); + logError("You can create the release manually at: https://github.com/apache/maven/releases"); + } throw new RuntimeException("Failed to publish GitHub release: " + result.error); } } else { + logInfo("No draft release found, creating new release"); + + logInfo("Using target commitish: " + targetCommitish); + // Create new release String releaseNotes = "Apache Maven " + version + "\n\n" + "For detailed information about this release, see:\n" + @@ -2173,16 +3334,104 @@ static void publishGitHubRelease(String version) throws Exception { ProcessResult result = runCommandSimple("gh", "release", "create", "maven-" + version, "--title", "Apache Maven " + version, "--notes", releaseNotes, - "--target", "maven-" + version); + "--target", targetCommitish); if (result.isSuccess()) { logSuccess("New GitHub release created"); } else { + logError("Failed to create new GitHub release: " + result.error); + if (result.error.contains("target_commitish is invalid")) { + logError("The target commit/branch/tag is invalid. This could mean:"); + logError("1. The tag maven-" + version + " doesn't exist yet"); + logError("2. The main branch is not accessible"); + logError("3. There's a permissions issue"); + logError("You can create the release manually at: https://github.com/apache/maven/releases"); + } throw new RuntimeException("Failed to create GitHub release: " + result.error); } } } + static void generateCloseVoteEmail(String version) throws Exception { + logStep("Generating close-vote email..."); + + StringBuilder email = new StringBuilder(); + email.append("To: dev@maven.apache.org\n"); + email.append("Subject: [RESULT][VOTE] Release Apache Maven ").append(version).append("\n\n"); + + email.append("Hi,\n\n"); + email.append("The vote has passed with the following result:\n\n"); + email.append("+1 (binding): [Please list PMC member votes]\n"); + email.append("+1 (non-binding): [Please list committer/user votes]\n"); + email.append("0: [Please list neutral votes if any]\n"); + email.append("-1: [Please list negative votes if any]\n\n"); + + email.append("I will promote the artifacts to the central repo.\n\n"); + email.append("Thanks,\n"); + email.append("[Your name]\n"); + + // Save email to file + Path emailFile = PROJECT_ROOT.resolve("close-vote-email-" + version + ".txt"); + Files.writeString(emailFile, email.toString()); + logSuccess("Close-vote email generated: " + emailFile.getFileName()); + + // Send email if Gmail is configured + if (GMAIL_USERNAME != null && !GMAIL_USERNAME.isEmpty() && + GMAIL_APP_PASSWORD != null && !GMAIL_APP_PASSWORD.isEmpty()) { + System.out.println(); + System.out.print("Do you want to send the close-vote email now? (y/N): "); + Scanner scanner = new Scanner(System.in); + String response = scanner.nextLine(); + if (response.equalsIgnoreCase("y")) { + sendCloseVoteEmail(version); + } else { + logInfo("Close-vote email not sent - you can send it manually later"); + } + } else { + logInfo("Gmail not configured - please send close-vote email manually: " + emailFile.getFileName()); + } + } + + static void sendCloseVoteEmail(String version) { + try { + logStep("Sending close-vote email..."); + + Path emailFile = PROJECT_ROOT.resolve("close-vote-email-" + version + ".txt"); + if (!Files.exists(emailFile)) { + logError("Close-vote email file not found: " + emailFile); + return; + } + + // Parse email file + List<String> lines = Files.readAllLines(emailFile); + String subject = ""; + StringBuilder body = new StringBuilder(); + boolean inBody = false; + + for (String line : lines) { + if (line.startsWith("Subject: ")) { + subject = line.substring(9); + } else if (line.trim().isEmpty() && !inBody) { + inBody = true; + } else if (inBody) { + body.append(line).append("\n"); + } + } + + boolean emailSent = sendEmailWithResult("dev@maven.apache.org", "", subject, body.toString()); + + if (emailSent) { + logSuccess("Close-vote email sent successfully!"); + } else { + logWarning("Close-vote email was NOT sent automatically"); + logInfo("Please send the email manually: " + emailFile.getFileName()); + } + + } catch (Exception e) { + logError("Failed to send close-vote email: " + e.getMessage()); + } + } + static void generateAnnouncement(String version) throws Exception { logStep("Generating announcement email..."); @@ -2410,11 +3659,98 @@ static void cleanupGitRelease(String version) { } } + // Utility methods for version detection + static String detectVersionFromStagingFiles() { + try { + if (!Files.exists(TARGET_DIR)) { + return null; + } + + // Look for staging-repo files to detect versions + List<String> versions = Files.list(TARGET_DIR) + .filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .filter(name -> name.startsWith("staging-repo-")) + .map(name -> name.substring("staging-repo-".length())) + .filter(version -> !version.isEmpty()) + .sorted((v1, v2) -> v2.compareTo(v1)) // Sort descending (newest first) + .collect(java.util.stream.Collectors.toList()); + + if (versions.isEmpty()) { + return null; + } + + // Return the most recent version (first in sorted list) + String detectedVersion = versions.get(0); + + // Verify that this version has completed the release process + if (isStepCompleted(detectedVersion, ReleaseStep.COMPLETED) || + isStepCompleted(detectedVersion, ReleaseStep.SAVE_INFO)) { + return detectedVersion; + } + + // If the most recent version isn't complete, check others + for (String version : versions) { + if (isStepCompleted(version, ReleaseStep.COMPLETED) || + isStepCompleted(version, ReleaseStep.SAVE_INFO)) { + return version; + } + } + + // If no completed versions found, return the most recent anyway + return detectedVersion; + + } catch (Exception e) { + logWarning("Failed to detect version from staging files: " + e.getMessage()); + return null; + } + } + + static void listAvailableVersions() { + try { + if (!Files.exists(TARGET_DIR)) { + System.out.println("No target directory found - no previous releases detected"); + return; + } + + List<String> versions = Files.list(TARGET_DIR) + .filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .filter(name -> name.startsWith("staging-repo-")) + .map(name -> name.substring("staging-repo-".length())) + .filter(version -> !version.isEmpty()) + .sorted((v1, v2) -> v2.compareTo(v1)) // Sort descending (newest first) + .collect(java.util.stream.Collectors.toList()); + + if (versions.isEmpty()) { + System.out.println("No staging files found - no previous releases detected"); + return; + } + + System.out.println(); + System.out.println("Available versions with staging files:"); + for (String version : versions) { + String status = ""; + if (isStepCompleted(version, ReleaseStep.COMPLETED)) { + status = " (ready for publish)"; + } else if (isStepCompleted(version, ReleaseStep.SAVE_INFO)) { + status = " (vote started)"; + } else { + status = " (incomplete)"; + } + System.out.println(" " + version + status); + } + + } catch (Exception e) { + logWarning("Failed to list available versions: " + e.getMessage()); + } + } + // Publish Command @Command(name = "publish", description = "Publish release after successful vote (Click 2)") static class PublishCommand implements Callable<Integer> { - @Parameters(index = "0", description = "Release version") + @Parameters(index = "0", description = "Release version (optional - will auto-detect if not provided)", arity = "0..1") private String version; @Parameters(index = "1", description = "Staging repository ID (optional)", arity = "0..1") @@ -2422,9 +3758,26 @@ static class PublishCommand implements Callable<Integer> { @Override public Integer call() { - System.out.println("🎉 Publishing Maven release " + version); - try { + // Auto-detect version if not provided + if (version == null || version.isEmpty()) { + version = detectVersionFromStagingFiles(); + if (version == null || version.isEmpty()) { + logError("No version provided and could not auto-detect from staging files"); + System.out.println("Available options:"); + System.out.println("1. Provide version explicitly: jbang Release.java publish <version>"); + System.out.println("2. Ensure you've run 'stage <version>' first to create staging files"); + listAvailableVersions(); + return 1; + } + logInfo("Auto-detected version: " + version); + } + + System.out.println("🎉 Publishing Maven release " + version); + + // Initialize logging for publish process + initializeLogging(version); + // Load staging repo from saved file if not provided if (stagingRepo == null || stagingRepo.isEmpty()) { stagingRepo = loadStagingRepo(version); @@ -2442,65 +3795,156 @@ public Integer call() { // Load milestone info String milestoneInfo = loadMilestoneInfo(version); - // Confirm vote results - if (!confirmVoteResults()) { - return 1; - } - - // Promote staging repository - promoteStagingRepo(stagingRepo); - - // Finalize distribution - finalizeDistribution(version); - - // Add to Apache Committee Report Helper - System.out.println(); - logStep("Adding to Apache Committee Report Helper..."); - System.out.println("Please manually add the release at: https://reporter.apache.org/addrelease.html?maven"); - System.out.println("Full Version Name: Apache Maven " + version); - System.out.println("Date of Release: " + java.time.LocalDate.now()); - System.out.println(); - System.out.print("Press Enter when done..."); - new Scanner(System.in).nextLine(); - - // Deploy website - deployWebsite(version); - - // Update GitHub tracking - updateGitHubTracking(version, milestoneInfo); - - // Publish GitHub release - publishGitHubRelease(version); - - // Generate announcement - generateAnnouncement(version); - - // Wait for Maven Central sync - System.out.println(); - logStep("Waiting for Maven Central sync..."); - System.out.println("The sync to Maven Central occurs every 4 hours."); - System.out.println("Check: https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/" + version + "/"); - System.out.println(); - System.out.print("Press Enter when artifacts are available in Maven Central..."); - new Scanner(System.in).nextLine(); - - // Send announcement email if Gmail is configured - if (GMAIL_USERNAME != null && !GMAIL_USERNAME.isEmpty() && - GMAIL_APP_PASSWORD != null && !GMAIL_APP_PASSWORD.isEmpty()) { - System.out.println(); - System.out.print("Do you want to send the announcement email now? (y/N): "); - String response = new Scanner(System.in).nextLine(); - if (response.equalsIgnoreCase("y")) { - sendAnnouncementEmail(version); - } else { - logInfo("Announcement email not sent - you can send it manually later"); + // Step 1: Confirm vote results + if (!isStepCompleted(version, ReleaseStep.PUBLISH_VOTE_CONFIRM)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_VOTE_CONFIRM); + if (!confirmVoteResults()) { + return 1; } + logToFile(version, "PUBLISH_VOTE_CONFIRM", "Vote results confirmed"); + markStepCompleted(version, ReleaseStep.PUBLISH_VOTE_CONFIRM); } else { - logInfo("Gmail not configured - please send announcement email manually: announcement-" + version + ".txt"); + logInfo("Skipping vote confirmation (already completed)"); } - // Clean up - cleanupStagingInfo(version); + // Step 2: Generate and send close-vote email + if (!isStepCompleted(version, ReleaseStep.PUBLISH_CLOSE_VOTE)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_CLOSE_VOTE); + generateCloseVoteEmail(version); + logToFile(version, "PUBLISH_CLOSE_VOTE", "Close-vote email generated and sent"); + markStepCompleted(version, ReleaseStep.PUBLISH_CLOSE_VOTE); + } else { + logInfo("Skipping close-vote email (already completed)"); + } + + // Step 3: Promote staging repository + if (!isStepCompleted(version, ReleaseStep.PUBLISH_PROMOTE_STAGING)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_PROMOTE_STAGING); + promoteStagingRepo(version, stagingRepo); + logToFile(version, "PUBLISH_PROMOTE_STAGING", "Staging repository promoted"); + markStepCompleted(version, ReleaseStep.PUBLISH_PROMOTE_STAGING); + } else { + logInfo("Skipping staging repository promotion (already completed)"); + } + + // Step 4: Finalize distribution + if (!isStepCompleted(version, ReleaseStep.PUBLISH_FINALIZE_DIST)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_FINALIZE_DIST); + finalizeDistribution(version); + logToFile(version, "PUBLISH_FINALIZE_DIST", "Distribution finalized"); + markStepCompleted(version, ReleaseStep.PUBLISH_FINALIZE_DIST); + } else { + logInfo("Skipping distribution finalization (already completed)"); + } + + // Step 5: Add to Apache Committee Report Helper + if (!isStepCompleted(version, ReleaseStep.PUBLISH_COMMITTEE_REPORT)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_COMMITTEE_REPORT); + System.out.println(); + logStep("Adding to Apache Committee Report Helper..."); + System.out.println("Please manually add the release at: https://reporter.apache.org/addrelease.html?maven"); + System.out.println("Full Version Name: Apache Maven " + version); + System.out.println("Date of Release: " + java.time.LocalDate.now()); + System.out.println(); + System.out.print("Press Enter when done..."); + new Scanner(System.in).nextLine(); + logToFile(version, "PUBLISH_COMMITTEE_REPORT", "Added to Apache Committee Report Helper"); + markStepCompleted(version, ReleaseStep.PUBLISH_COMMITTEE_REPORT); + } else { + logInfo("Skipping Apache Committee Report Helper (already completed)"); + } + + // Step 6: Deploy website + if (!isStepCompleted(version, ReleaseStep.PUBLISH_DEPLOY_WEBSITE)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_DEPLOY_WEBSITE); + deployWebsite(version); + logToFile(version, "PUBLISH_DEPLOY_WEBSITE", "Website deployed"); + markStepCompleted(version, ReleaseStep.PUBLISH_DEPLOY_WEBSITE); + } else { + logInfo("Skipping website deployment (already completed)"); + } + + // Step 7: Update GitHub tracking + if (!isStepCompleted(version, ReleaseStep.PUBLISH_UPDATE_GITHUB)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_UPDATE_GITHUB); + updateGitHubTracking(version, milestoneInfo); + logToFile(version, "PUBLISH_UPDATE_GITHUB", "GitHub tracking updated"); + markStepCompleted(version, ReleaseStep.PUBLISH_UPDATE_GITHUB); + } else { + logInfo("Skipping GitHub tracking update (already completed)"); + } + + // Step 8: Publish GitHub release + if (!isStepCompleted(version, ReleaseStep.PUBLISH_GITHUB_RELEASE)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_GITHUB_RELEASE); + publishGitHubRelease(version); + logToFile(version, "PUBLISH_GITHUB_RELEASE", "GitHub release published"); + markStepCompleted(version, ReleaseStep.PUBLISH_GITHUB_RELEASE); + } else { + logInfo("Skipping GitHub release publication (already completed)"); + } + + // Step 9: Generate announcement + if (!isStepCompleted(version, ReleaseStep.PUBLISH_GENERATE_ANNOUNCEMENT)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_GENERATE_ANNOUNCEMENT); + generateAnnouncement(version); + logToFile(version, "PUBLISH_GENERATE_ANNOUNCEMENT", "Announcement email generated"); + markStepCompleted(version, ReleaseStep.PUBLISH_GENERATE_ANNOUNCEMENT); + } else { + logInfo("Skipping announcement generation (already completed)"); + } + + // Step 10: Wait for Maven Central sync + if (!isStepCompleted(version, ReleaseStep.PUBLISH_MAVEN_CENTRAL_SYNC)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_MAVEN_CENTRAL_SYNC); + System.out.println(); + logStep("Waiting for Maven Central sync..."); + System.out.println("The sync to Maven Central occurs every 4 hours."); + System.out.println("Check: https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/" + version + "/"); + System.out.println(); + System.out.print("Press Enter when artifacts are available in Maven Central..."); + new Scanner(System.in).nextLine(); + logToFile(version, "PUBLISH_MAVEN_CENTRAL_SYNC", "Maven Central sync confirmed"); + markStepCompleted(version, ReleaseStep.PUBLISH_MAVEN_CENTRAL_SYNC); + } else { + logInfo("Skipping Maven Central sync wait (already completed)"); + } + + // Step 11: Send announcement email + if (!isStepCompleted(version, ReleaseStep.PUBLISH_SEND_ANNOUNCEMENT)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_SEND_ANNOUNCEMENT); + // Send announcement email if Gmail is configured + if (GMAIL_USERNAME != null && !GMAIL_USERNAME.isEmpty() && + GMAIL_APP_PASSWORD != null && !GMAIL_APP_PASSWORD.isEmpty()) { + System.out.println(); + System.out.print("Do you want to send the announcement email now? (y/N): "); + String response = new Scanner(System.in).nextLine(); + if (response.equalsIgnoreCase("y")) { + sendAnnouncementEmail(version); + } else { + logInfo("Announcement email not sent - you can send it manually later"); + } + } else { + logInfo("Gmail not configured - please send announcement email manually: announcement-" + version + ".txt"); + } + logToFile(version, "PUBLISH_SEND_ANNOUNCEMENT", "Announcement email handled"); + markStepCompleted(version, ReleaseStep.PUBLISH_SEND_ANNOUNCEMENT); + } else { + logInfo("Skipping announcement email (already completed)"); + } + + // Step 12: Clean up + if (!isStepCompleted(version, ReleaseStep.PUBLISH_CLEANUP)) { + saveCurrentStep(version, ReleaseStep.PUBLISH_CLEANUP); + cleanupStagingInfo(version); + logToFile(version, "PUBLISH_CLEANUP", "Staging info cleaned up"); + markStepCompleted(version, ReleaseStep.PUBLISH_CLEANUP); + } else { + logInfo("Skipping cleanup (already completed)"); + } + + // Mark publish as completed + saveCurrentStep(version, ReleaseStep.PUBLISH_COMPLETED); System.out.println(); logSuccess("Release " + version + " published successfully!"); @@ -2604,7 +4048,7 @@ public Integer call() { System.out.println(); System.out.println("Next steps:"); System.out.println("1. Fix the issues that caused the cancellation"); - System.out.println("2. Start a new release vote when ready"); + System.out.println("2. Stage a new release when ready"); return 0; @@ -2616,6 +4060,270 @@ public Integer call() { } } + // Test Step Command + @Command(name = "test-step", description = "Execute a single release step for testing") + static class TestStepCommand implements Callable<Integer> { + + @Parameters(index = "0", description = "Step name to execute") + private String stepName; + + @Parameters(index = "1", description = "Release version (e.g., 4.0.0-rc-4)") + private String version; + + @Option(names = {"-h", "--help"}, usageHelp = true, description = "Display help message") + private boolean helpRequested = false; + + @Override + public Integer call() throws Exception { + if (helpRequested) { + System.out.println("Available steps for testing:"); + System.out.println(" stage-website - Update maven-site repository for release"); + System.out.println(" create-release-notes - Create release notes file only"); + System.out.println(" update-history - Update history.md.vm file only"); + System.out.println(" copy-xsd-files - Copy XSD files only"); + System.out.println(" update-htaccess - Update .htaccess file only"); + System.out.println(" update-pom - Update pom.xml version properties only"); + System.out.println(" finalize-dist - Test distribution finalization (dry-run)"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" jbang MavenRelease.java test-step stage-website 4.0.0-rc-4"); + System.out.println(" jbang MavenRelease.java test-step update-pom 4.0.0-rc-4"); + return 0; + } + + if (stepName == null || stepName.isEmpty()) { + logError("Step name is required"); + return 1; + } + + if (version == null || version.isEmpty()) { + logError("Version is required"); + return 1; + } + + logInfo("Testing step: " + stepName + " for version: " + version); + + try { + switch (stepName.toLowerCase()) { + case "help": + System.out.println("Available steps for testing:"); + System.out.println(" stage-website - Update maven-site repository for release"); + System.out.println(" create-release-notes - Create release notes file only"); + System.out.println(" update-history - Update history.md.vm file only"); + System.out.println(" copy-xsd-files - Copy XSD files only"); + System.out.println(" update-htaccess - Update .htaccess file only"); + System.out.println(" update-pom - Update pom.xml version properties only"); + System.out.println(" finalize-dist - Test distribution finalization (dry-run)"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" jbang MavenRelease.java test-step stage-website 4.0.0-rc-4"); + System.out.println(" jbang MavenRelease.java test-step update-pom 4.0.0-rc-4"); + return 0; + case "stage-website": + updateWebsiteForRelease(version); + break; + case "create-release-notes": + testCreateReleaseNotes(version); + break; + case "update-history": + testUpdateHistory(version); + break; + case "copy-xsd-files": + testCopyXsdFiles(version); + break; + case "update-htaccess": + testUpdateHtaccess(version); + break; + case "update-pom": + testUpdatePom(version); + break; + case "finalize-dist": + testFinalizeDistribution(version); + break; + default: + logError("Unknown step: " + stepName); + logError("Use 'help' as step name or --help to see available steps"); + return 1; + } + + logSuccess("Step '" + stepName + "' completed successfully for version " + version); + return 0; + + } catch (Exception e) { + logError("Step '" + stepName + "' failed: " + e.getMessage()); + e.printStackTrace(); + return 1; + } + } + } + + // Helper methods for testing individual components + static void testCreateReleaseNotes(String version) throws Exception { + Path tempDir = Files.createTempDirectory("maven-site-test"); + try { + logInfo("Testing release notes creation in: " + tempDir); + createReleaseNotes(tempDir, version); + logInfo("Release notes created at: " + tempDir.resolve("content/markdown/docs/" + version + "/release-notes.md")); + } finally { + // Clean up + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(path -> { + try { + Files.delete(path); + } catch (Exception ignored) {} + }); + } + } + + static void testUpdateHistory(String version) throws Exception { + Path tempDir = Files.createTempDirectory("maven-site-test"); + try { + logInfo("Testing history file update in: " + tempDir); + updateHistoryFile(tempDir, version); + logInfo("History file updated at: " + tempDir.resolve("content/markdown/docs/history.md.vm")); + } finally { + // Clean up + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(path -> { + try { + Files.delete(path); + } catch (Exception ignored) {} + }); + } + } + + static void testCopyXsdFiles(String version) throws Exception { + Path tempDir = Files.createTempDirectory("maven-site-test"); + try { + logInfo("Testing XSD files creation in: " + tempDir); + copyXsdFiles(tempDir, version); + logInfo("XSD files created at: " + tempDir.resolve("content/resources/xsd/")); + } finally { + // Clean up + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(path -> { + try { + Files.delete(path); + } catch (Exception ignored) {} + }); + } + } + + static void testUpdateHtaccess(String version) throws Exception { + Path tempDir = Files.createTempDirectory("maven-site-test"); + try { + logInfo("Testing .htaccess file update in: " + tempDir); + updateHtaccessFile(tempDir, version); + logInfo(".htaccess file updated at: " + tempDir.resolve("content/resources/xsd/.htaccess")); + } finally { + // Clean up + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(path -> { + try { + Files.delete(path); + } catch (Exception ignored) {} + }); + } + } + + static void testUpdatePom(String version) throws Exception { + Path tempDir = Files.createTempDirectory("maven-site-test"); + try { + logInfo("Testing pom.xml version property update in: " + tempDir); + + // Create a sample pom.xml file + Path pomFile = tempDir.resolve("pom.xml"); + String samplePom = createSamplePomXml(); + Files.writeString(pomFile, samplePom); + + logInfo("Created sample pom.xml with current4xVersion property"); + + // Test the update + updatePomVersionProperties(tempDir, version); + + // Show the result + String updatedContent = Files.readString(pomFile); + logInfo("Updated pom.xml content:"); + System.out.println("----------------------------------------"); + System.out.println(updatedContent); + System.out.println("----------------------------------------"); + + } finally { + // Clean up + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(path -> { + try { + Files.delete(path); + } catch (Exception ignored) {} + }); + } + } + + static String createSamplePomXml() { + return """ + <?xml version="1.0" encoding="UTF-8"?> + <project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <groupId>org.apache.maven</groupId> + <artifactId>maven-site</artifactId> + <version>1.0-SNAPSHOT</version> + + <properties> + <current4xVersion>4.0.0-rc-3</current4xVersion> + <current3xVersion>3.9.9</current3xVersion> + </properties> + + </project> + """; + } + + static void testFinalizeDistribution(String version) throws Exception { + logInfo("Testing distribution finalization (dry-run) for version: " + version); + + // Determine the correct paths based on Maven version + String stagingPath, releasePath; + if (version.startsWith("4.")) { + stagingPath = "https://dist.apache.org/repos/dist/dev/maven/maven-4/" + version + "/"; + releasePath = "https://dist.apache.org/repos/dist/release/maven/maven-4/" + version + "/"; + } else { + stagingPath = "https://dist.apache.org/repos/dist/dev/maven/maven-3/" + version + "/"; + releasePath = "https://dist.apache.org/repos/dist/release/maven/maven-3/" + version + "/"; + } + + logInfo("Would move from staging: " + stagingPath); + logInfo("Would move to release: " + releasePath); + + // Test SVN authentication + String svnUsername = System.getenv("APACHE_USERNAME"); + String svnPassword = System.getenv("APACHE_PASSWORD"); + + if (svnUsername != null && !svnUsername.isEmpty() && svnPassword != null && !svnPassword.isEmpty()) { + logInfo("SVN credentials available for: " + svnUsername); + } else { + logWarning("SVN credentials not available - would require interactive authentication"); + logWarning("Set APACHE_USERNAME and APACHE_PASSWORD environment variables"); + } + + // Show the command that would be executed + logInfo("Command that would be executed:"); + if (svnUsername != null && !svnUsername.isEmpty() && svnPassword != null && !svnPassword.isEmpty()) { + logInfo(" svnmucc -m 'Release Apache Maven " + version + "' --non-interactive --trust-server-cert --username " + svnUsername + " --password [HIDDEN] mv " + stagingPath + " " + releasePath); + } else { + logInfo(" svnmucc -m 'Release Apache Maven " + version + "' mv " + stagingPath + " " + releasePath); + } + + logSuccess("Distribution finalization test completed (dry-run)"); + logInfo("In actual release, this would move the artifacts from staging to final release area"); + } + // Status Command @Command(name = "status", description = "Check release status and logs") static class StatusCommand implements Callable<Integer> { @@ -2642,19 +4350,43 @@ public Integer call() { // Show step progress System.out.println(); - System.out.println("📋 Release Steps Progress:"); - for (ReleaseStep step : ReleaseStep.values()) { - if (step == ReleaseStep.COMPLETED) continue; - String status; - if (isStepCompleted(version, step)) { - status = GREEN + "✅ COMPLETED" + NC; - } else if (step == currentStep) { - status = YELLOW + "🔄 IN PROGRESS" + NC; - } else { - status = "⏳ PENDING"; + // Determine if we're in staging or publish phase + boolean inPublishPhase = isStepCompleted(version, ReleaseStep.COMPLETED) || + currentStep.getStepName().startsWith("publish-"); + + if (inPublishPhase) { + System.out.println("📋 Publish Steps Progress:"); + for (ReleaseStep step : ReleaseStep.values()) { + if (!step.getStepName().startsWith("publish-")) continue; + if (step == ReleaseStep.PUBLISH_COMPLETED) continue; + + String status; + if (isStepCompleted(version, step)) { + status = GREEN + "✅ COMPLETED" + NC; + } else if (step == currentStep) { + status = YELLOW + "🔄 IN PROGRESS" + NC; + } else { + status = "⏳ PENDING"; + } + System.out.println(" " + step.getStepName() + ": " + status); } - System.out.println(" " + step.getStepName() + ": " + status); + } else { + System.out.println("📋 Staging Steps Progress:"); + for (ReleaseStep step : ReleaseStep.values()) { + if (step.getStepName().startsWith("publish-")) continue; + if (step == ReleaseStep.COMPLETED) continue; + + String status; + if (isStepCompleted(version, step)) { + status = GREEN + "✅ COMPLETED" + NC; + } else if (step == currentStep) { + status = YELLOW + "🔄 IN PROGRESS" + NC; + } else { + status = "⏳ PENDING"; + } + System.out.println(" " + step.getStepName() + ": " + status); + } } // Check for log files @@ -2711,7 +4443,7 @@ public Integer call() { System.out.println(" View full log: cat " + logFile); System.out.println(" View logs directory: ls -la " + LOGS_DIR); if (currentStep != ReleaseStep.COMPLETED) { - System.out.println(" Resume release: jbang " + MavenRelease.class.getSimpleName() + ".java start-vote " + version); + System.out.println(" Resume staging: jbang " + MavenRelease.class.getSimpleName() + ".java stage " + version); } System.out.println(" Cancel release: jbang " + MavenRelease.class.getSimpleName() + ".java cancel " + version);