diff --git a/Directory.Packages.props b/Directory.Packages.props
index 668a4938a..5a00caca0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,6 +9,7 @@
+
@@ -130,4 +131,4 @@
-
+
\ No newline at end of file
diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.ApiApp/Properties/launchSettings.json b/examples/java/CommunityToolkit.Aspire.Hosting.Java.ApiApp/Properties/launchSettings.json
index aead87517..ddb61c69c 100644
--- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.ApiApp/Properties/launchSettings.json
+++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.ApiApp/Properties/launchSettings.json
@@ -1,13 +1,5 @@
{
- "$schema": "http://json.schemastore.org/launchsettings.json",
- "iisSettings": {
- "windowsAuthentication": false,
- "anonymousAuthentication": true,
- "iisExpress": {
- "applicationUrl": "http://localhost:5050",
- "sslPort": 5051
- }
- },
+ "$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
@@ -28,14 +20,6 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
- },
- "IIS Express": {
- "commandName": "IISExpress",
- "launchBrowser": true,
- "launchUrl": "swagger",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
}
}
}
\ No newline at end of file
diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj
index 6b1d993eb..674809a89 100644
--- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj
+++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj
@@ -15,4 +15,8 @@
+
+
+
+
diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs
index aa0bc1b0d..7c414327c 100644
--- a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs
+++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/Program.cs
@@ -1,17 +1,22 @@
var builder = DistributedApplication.CreateBuilder(args);
-var apiapp = builder.AddProject("apiapp");
+builder.AddDockerComposeEnvironment("env");
+
+var apiapp = builder.AddProject("apiapp")
+ .WithExternalHttpEndpoints();
var containerapp = builder.AddJavaContainerApp("containerapp", image: "docker.io/aliencube/aspire-spring-maven-sample")
.WithOtelAgent("/agents/opentelemetry-javaagent.jar")
- .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT");
+ .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT")
+ .WithExternalHttpEndpoints();
var executableapp = builder.AddJavaApp("executableapp",
workingDirectory: "../CommunityToolkit.Aspire.Hosting.Java.Spring.Maven")
.WithMavenGoal("spring-boot:run")
.WithOtelAgent("../../../agents/opentelemetry-javaagent.jar")
.WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT")
- .WithHttpHealthCheck("/health");
+ .WithHttpHealthCheck("/health")
+ .WithExternalHttpEndpoints();
var webapp = builder.AddProject("webapp")
.WithExternalHttpEndpoints()
diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env
new file mode 100644
index 000000000..fd6d20986
--- /dev/null
+++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env
@@ -0,0 +1,15 @@
+# Container image name for apiapp
+APIAPP_IMAGE=
+
+# Default container port for apiapp
+APIAPP_PORT=
+
+# Container image name for executableapp
+EXECUTABLEAPP_IMAGE=
+
+# Container image name for webapp
+WEBAPP_IMAGE=
+
+# Default container port for webapp
+WEBAPP_PORT=
+
diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env.Production b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env.Production
new file mode 100644
index 000000000..17f52f115
--- /dev/null
+++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/.env.Production
@@ -0,0 +1,15 @@
+# Container image name for apiapp
+APIAPP_IMAGE=apiapp:aspire-deploy-20260320212742
+
+# Default container port for apiapp
+APIAPP_PORT=8080
+
+# Container image name for executableapp
+EXECUTABLEAPP_IMAGE=executableapp:aspire-deploy-20260320212742
+
+# Container image name for webapp
+WEBAPP_IMAGE=webapp:aspire-deploy-20260320212742
+
+# Default container port for webapp
+WEBAPP_PORT=8080
+
diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/docker-compose.yaml b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/docker-compose.yaml
new file mode 100644
index 000000000..aa2c96477
--- /dev/null
+++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire-output/docker-compose.yaml
@@ -0,0 +1,71 @@
+services:
+ env-dashboard:
+ image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest"
+ ports:
+ - "18888"
+ expose:
+ - "18889"
+ - "18890"
+ networks:
+ - "aspire"
+ restart: "always"
+ apiapp:
+ image: "${APIAPP_IMAGE}"
+ environment:
+ OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory"
+ ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
+ HTTP_PORTS: "${APIAPP_PORT}"
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard:18889"
+ OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
+ OTEL_SERVICE_NAME: "apiapp"
+ ports:
+ - "${APIAPP_PORT}"
+ networks:
+ - "aspire"
+ containerapp:
+ image: "docker.io/aliencube/aspire-spring-maven-sample:latest"
+ environment:
+ JAVA_TOOL_OPTIONS: "-javaagent:/agents/opentelemetry-javaagent.jar"
+ SERVER_PORT: "8080"
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard:18889"
+ OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
+ OTEL_SERVICE_NAME: "containerapp"
+ ports:
+ - "8080"
+ networks:
+ - "aspire"
+ executableapp:
+ image: "${EXECUTABLEAPP_IMAGE}"
+ environment:
+ JAVA_TOOL_OPTIONS: "-javaagent:../../../agents/opentelemetry-javaagent.jar"
+ SERVER_PORT: "8080"
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard:18889"
+ OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
+ OTEL_SERVICE_NAME: "executableapp"
+ ports:
+ - "8080"
+ networks:
+ - "aspire"
+ webapp:
+ image: "${WEBAPP_IMAGE}"
+ environment:
+ OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory"
+ ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
+ HTTP_PORTS: "${WEBAPP_PORT}"
+ CONTAINERAPP_HTTP: "http://containerapp:8080"
+ services__containerapp__http__0: "http://containerapp:8080"
+ EXECUTABLEAPP_HTTP: "http://executableapp:8080"
+ services__executableapp__http__0: "http://executableapp:8080"
+ APIAPP_HTTP: "http://apiapp:${APIAPP_PORT}"
+ services__apiapp__http__0: "http://apiapp:${APIAPP_PORT}"
+ APIAPP_HTTPS: "https://apiapp:${APIAPP_PORT}"
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://env-dashboard:18889"
+ OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
+ OTEL_SERVICE_NAME: "webapp"
+ ports:
+ - "${WEBAPP_PORT}"
+ networks:
+ - "aspire"
+networks:
+ aspire:
+ driver: "bridge"
diff --git a/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire.config.json b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire.config.json
new file mode 100644
index 000000000..cec127f4d
--- /dev/null
+++ b/examples/java/CommunityToolkit.Aspire.Hosting.Java.AppHost/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "CommunityToolkit.Aspire.Hosting.Java.AppHost.csproj"
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs
index 5214b8654..99857a6c5 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Executable.cs
@@ -84,7 +84,8 @@ public static IResourceBuilder AddJavaApp(this IDistr
context.Args.Add("-jar");
context.Args.Add(resource.JarPath);
}
- });
+ })
+ .PublishAsJavaDockerfile();
return resourceBuilder;
}
@@ -116,12 +117,14 @@ public static IResourceBuilder AddJavaApp(this IDistr
workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory));
var resource = new JavaAppExecutableResource(name, "java", workingDirectory);
+ resource.JarPath = options.ApplicationName;
return builder.AddResource(resource)
.WithJavaDefaults(options)
.WithIconName("DrinkCoffee")
.WithHttpEndpoint(port: options.Port, name: JavaAppContainerResource.HttpEndpointName, isProxied: false)
- .WithArgs(allArgs);
+ .WithArgs(allArgs)
+ .PublishAsJavaDockerfile();
}
///
@@ -179,11 +182,14 @@ public static IResourceBuilder WithMavenBuild(
? wrapper.WrapperPath
: Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultMavenWrapper));
- return builder.WithJavaBuildStep(
- buildResourceName: $"{builder.Resource.Name}-maven-build",
- createResource: (name, wrapperScript, workingDirectory) => new MavenBuildResource(name, wrapperScript, workingDirectory),
- wrapperPath: resolvedWrapper,
- buildArgs: args.Length > 0 ? args : ["clean", "package"]);
+ string[] resolvedBuildArgs = args.Length > 0 ? args : ["clean", "package"];
+
+ return builder.WithPublishBuildAnnotation(JavaBuildTool.Maven, resolvedBuildArgs)
+ .WithJavaBuildStep(
+ buildResourceName: $"{builder.Resource.Name}-maven-build",
+ createResource: (name, wrapperScript, workingDirectory) => new MavenBuildResource(name, wrapperScript, workingDirectory),
+ wrapperPath: resolvedWrapper,
+ buildArgs: resolvedBuildArgs);
}
///
@@ -205,11 +211,14 @@ public static IResourceBuilder WithGradleBuild(
? wrapper.WrapperPath
: Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultGradleWrapper));
- return builder.WithJavaBuildStep(
- buildResourceName: $"{builder.Resource.Name}-gradle-build",
- createResource: (name, wrapperScript, workingDirectory) => new GradleBuildResource(name, wrapperScript, workingDirectory),
- wrapperPath: resolvedWrapper,
- buildArgs: args.Length > 0 ? args : ["clean", "build"]);
+ string[] resolvedBuildArgs = args.Length > 0 ? args : ["clean", "build"];
+
+ return builder.WithPublishBuildAnnotation(JavaBuildTool.Gradle, resolvedBuildArgs)
+ .WithJavaBuildStep(
+ buildResourceName: $"{builder.Resource.Name}-gradle-build",
+ createResource: (name, wrapperScript, workingDirectory) => new GradleBuildResource(name, wrapperScript, workingDirectory),
+ wrapperPath: resolvedWrapper,
+ buildArgs: resolvedBuildArgs);
}
private static IResourceBuilder WithJavaBuildStep(
@@ -235,6 +244,20 @@ private static IResourceBuilder WithJavaBuildStep WithPublishBuildAnnotation(
+ this IResourceBuilder builder,
+ JavaBuildTool tool,
+ string[] buildArgs)
+ {
+ string? wrapperPath = builder.Resource.TryGetLastAnnotation(out var wrapper)
+ ? wrapper.WrapperPath
+ : null;
+
+ builder.Resource.Annotations.Add(new JavaPublishBuildAnnotation(tool, wrapperPath, buildArgs));
+
+ return builder;
+ }
+
///
/// Configures the Java application to run using a Maven goal (e.g., spring-boot:run).
/// In run mode, the resource command is changed from java to the Maven wrapper.
@@ -264,7 +287,8 @@ public static IResourceBuilder WithMavenGoal(
: Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultMavenWrapper));
builder.Resource.Annotations.Add(
- new JavaBuildToolAnnotation(resolvedWrapper, args is { Length: > 0 } ? [goal, .. args] : [goal]));
+ new JavaBuildToolAnnotation(JavaBuildTool.Maven, resolvedWrapper, args is { Length: > 0 } ? [goal, .. args] : [goal]));
+ builder.WithPublishBuildAnnotation(JavaBuildTool.Maven, ["package"]);
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
@@ -303,7 +327,8 @@ public static IResourceBuilder WithGradleTask(
: Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, DefaultGradleWrapper));
builder.Resource.Annotations.Add(
- new JavaBuildToolAnnotation(resolvedWrapper, args is { Length: > 0 } ? [task, .. args] : [task]));
+ new JavaBuildToolAnnotation(JavaBuildTool.Gradle, resolvedWrapper, args is { Length: > 0 } ? [task, .. args] : [task]));
+ builder.WithPublishBuildAnnotation(JavaBuildTool.Gradle, ["build"]);
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Publishing.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Publishing.cs
new file mode 100644
index 000000000..9eaeb3ee2
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaAppHostingExtension.Publishing.cs
@@ -0,0 +1,134 @@
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.ApplicationModel.Docker;
+
+namespace Aspire.Hosting;
+
+public static partial class JavaAppHostingExtension
+{
+ private const string DefaultJavaBuildImage = "eclipse-temurin:21-jdk";
+ private const string DefaultJavaRuntimeImage = "eclipse-temurin:21-jre";
+
+#pragma warning disable ASPIREDOCKERFILEBUILDER001
+ private static IResourceBuilder PublishAsJavaDockerfile(
+ this IResourceBuilder builder)
+ {
+ return builder.PublishAsDockerFile(publish =>
+ {
+ if (File.Exists(Path.Combine(builder.Resource.WorkingDirectory, "Dockerfile")))
+ {
+ return;
+ }
+
+ publish.WithDockerfileBuilder(builder.Resource.WorkingDirectory, context =>
+ {
+ JavaAppExecutableResource resource = (JavaAppExecutableResource)context.Resource;
+ context.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
+
+ string buildImage = baseImageAnnotation?.BuildImage ?? DefaultJavaBuildImage;
+ string runtimeImage = baseImageAnnotation?.RuntimeImage ?? DefaultJavaRuntimeImage;
+ resource.TryGetLastAnnotation(out var publishBuildAnnotation);
+
+ var buildStage = context.Builder
+ .From(buildImage, "builder")
+ .WorkDir("/workspace")
+ .Copy(".", "./");
+
+ if (publishBuildAnnotation is not null)
+ {
+ buildStage.Run(GetPublishBuildCommand(resource, publishBuildAnnotation));
+ }
+
+ buildStage.Run(GetPublishArtifactCommand(resource, publishBuildAnnotation));
+
+ context.Builder
+ .From(runtimeImage, "app")
+ .WorkDir("/app")
+ .CopyFrom(buildStage.StageName!, "/out/app.jar", "/app/app.jar")
+ .Entrypoint(["java", "-jar", "/app/app.jar"]);
+ });
+ });
+ }
+#pragma warning restore ASPIREDOCKERFILEBUILDER001
+
+ private static string GetPublishBuildCommand(
+ JavaAppExecutableResource resource,
+ JavaPublishBuildAnnotation publishBuildAnnotation)
+ {
+ string wrapperPath = publishBuildAnnotation.WrapperPath is not null
+ ? GetPathRelativeToWorkingDirectory(resource.WorkingDirectory, publishBuildAnnotation.WrapperPath, "wrapper script")
+ : publishBuildAnnotation.Tool switch
+ {
+ JavaBuildTool.Maven => "mvnw",
+ JavaBuildTool.Gradle => "gradlew",
+ _ => throw new InvalidOperationException($"Unsupported Java build tool '{publishBuildAnnotation.Tool}'.")
+ };
+
+ if (wrapperPath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) ||
+ wrapperPath.EndsWith(".bat", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException($"Java publish requires a Unix-compatible wrapper path, but '{wrapperPath}' points to a Windows wrapper.");
+ }
+
+ if (!wrapperPath.StartsWith("./", StringComparison.Ordinal) &&
+ !wrapperPath.StartsWith("/", StringComparison.Ordinal))
+ {
+ wrapperPath = $"./{wrapperPath}";
+ }
+
+ string quotedWrapperPath = QuoteShellArgument(wrapperPath);
+ IEnumerable quotedArgs = publishBuildAnnotation.Args.Select(QuoteShellArgument);
+
+ return $"chmod +x {quotedWrapperPath} && {quotedWrapperPath} {string.Join(" ", quotedArgs)}";
+ }
+
+ private static string GetPublishArtifactCommand(
+ JavaAppExecutableResource resource,
+ JavaPublishBuildAnnotation? publishBuildAnnotation)
+ {
+ if (resource.JarPath is not null)
+ {
+ string jarPath = GetPathRelativeToWorkingDirectory(resource.WorkingDirectory, resource.JarPath, "JAR path");
+ return $"mkdir -p /out && cp {QuoteShellArgument(jarPath)} /out/app.jar";
+ }
+
+ if (publishBuildAnnotation is null)
+ {
+ throw new InvalidOperationException($"Java publish for resource '{resource.Name}' requires either a JAR path or a Maven/Gradle build configuration.");
+ }
+
+ string searchDirectory = publishBuildAnnotation.Tool switch
+ {
+ JavaBuildTool.Maven => "target",
+ JavaBuildTool.Gradle => "build/libs",
+ _ => throw new InvalidOperationException($"Unsupported Java build tool '{publishBuildAnnotation.Tool}'.")
+ };
+
+ return
+ $"mkdir -p /out && artifact=$(find {QuoteShellArgument(searchDirectory)} -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' ! -name '*-tests.jar' ! -name '*-plain.jar' ! -name 'original-*.jar' | sort | head -n 1) && test -n \"$artifact\" && cp \"$artifact\" /out/app.jar";
+ }
+
+ private static string GetPathRelativeToWorkingDirectory(
+ string workingDirectory,
+ string path,
+ string description)
+ {
+ string fullPath = Path.IsPathRooted(path)
+ ? Path.GetFullPath(path)
+ : Path.GetFullPath(Path.Combine(workingDirectory, path));
+
+ string relativePath = Path.GetRelativePath(workingDirectory, fullPath);
+
+ if (relativePath.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativePath))
+ {
+ throw new InvalidOperationException($"Java publish requires the {description} '{path}' to be inside the application's working directory.");
+ }
+
+ return NormalizeContainerPath(relativePath);
+ }
+
+ private static string NormalizeContainerPath(string path) =>
+ path.Replace('\\', '/');
+
+ private static string QuoteShellArgument(string value) =>
+ $"'{value.Replace("'", "'\"'\"'")}'";
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs
index 62763bc01..d38edca05 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaBuildToolAnnotation.cs
@@ -1,12 +1,24 @@
namespace Aspire.Hosting.ApplicationModel;
+internal enum JavaBuildTool
+{
+ Maven,
+ Gradle
+}
+
///
/// Represents a metadata annotation that specifies the build tool used to run the Java application.
///
+/// The build tool used to run the Java application.
/// The full path to the build tool wrapper script.
/// The arguments to pass to the build tool (e.g., the goal or task name).
-internal sealed class JavaBuildToolAnnotation(string wrapperPath, string[] args) : IResourceAnnotation
+internal sealed class JavaBuildToolAnnotation(JavaBuildTool tool, string wrapperPath, string[] args) : IResourceAnnotation
{
+ ///
+ /// The build tool used to run the Java application.
+ ///
+ public JavaBuildTool Tool { get; } = tool;
+
///
/// The full path to the build tool wrapper script (e.g., mvnw or gradlew).
///
diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/JavaPublishBuildAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Java/JavaPublishBuildAnnotation.cs
new file mode 100644
index 000000000..4d7b4c684
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Java/JavaPublishBuildAnnotation.cs
@@ -0,0 +1,25 @@
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents publish-time metadata for building a Java application inside a container image.
+///
+/// The build tool used during publish.
+/// The configured wrapper path, or null to use the default wrapper.
+/// The arguments to pass to the publish build command.
+internal sealed class JavaPublishBuildAnnotation(JavaBuildTool tool, string? wrapperPath, string[] args) : IResourceAnnotation
+{
+ ///
+ /// The build tool used during publish.
+ ///
+ public JavaBuildTool Tool { get; } = tool;
+
+ ///
+ /// The configured wrapper path, or null to use the default wrapper for the tool.
+ ///
+ public string? WrapperPath { get; } = wrapperPath;
+
+ ///
+ /// The arguments to pass to the publish build command.
+ ///
+ public string[] Args { get; } = args;
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Java/README.md b/src/CommunityToolkit.Aspire.Hosting.Java/README.md
index 65d7155c0..e31893966 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Java/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.Java/README.md
@@ -59,6 +59,16 @@ var app = builder.AddJavaApp("app", "../java-project", "target/app.jar")
.WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT");
```
+## Publish as a container
+
+When you run `aspire publish`, executable Java apps are published as a multi-stage Dockerfile-based container image.
+
+- `AddJavaApp(..., jarPath)` copies the configured JAR into the runtime image. If that JAR is produced during publish, pair it with `WithMavenBuild` or `WithGradleBuild`.
+- Apps configured with `WithMavenGoal` or `WithGradleTask` keep using those commands in run mode. During publish, the integration switches to a packaging command in the container build so the produced JAR can be copied into the runtime image.
+- By default, the generated Dockerfile uses `eclipse-temurin:21-jdk` for the build stage and `eclipse-temurin:21-jre` for the runtime stage.
+- If the application directory already contains a `Dockerfile`, publish uses that file instead of generating one.
+- If you customize the wrapper path, keep the wrapper script inside the application's working directory so it is available in the Docker build context.
+
## Add a containerized Java application
To run a Java application from a container image, use `AddJavaContainerApp`:
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/PublishResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/PublishResourceCreationTests.cs
new file mode 100644
index 000000000..85c0e7d0f
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Java.Tests/PublishResourceCreationTests.cs
@@ -0,0 +1,107 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Utils;
+
+namespace CommunityToolkit.Aspire.Hosting.Java.Tests;
+
+public class PublishResourceCreationTests
+{
+ [Fact]
+ public void AddJavaAppAddsManifestPublishingAnnotationInPublishMode()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var java = builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var publishedResource = Assert.Single(appModel.Resources, resource => resource.Name == "java");
+ Assert.Contains("ExecutableContainerResource", publishedResource.GetType().Name);
+
+ Assert.True(java.Resource.TryGetAnnotationsOfType(out var annotations));
+ Assert.NotEmpty(annotations);
+ }
+
+ [Fact]
+ public void MavenBuildUsesPublishAnnotationWithoutCreatingBuildResourceInPublishMode()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var java = builder.AddJavaApp("java", Environment.CurrentDirectory, "target/app.jar")
+ .WithMavenBuild();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ Assert.Empty(appModel.Resources.OfType());
+ Assert.False(java.Resource.TryGetAnnotationsOfType(out _));
+ Assert.True(java.Resource.TryGetLastAnnotation(out var publishAnnotation));
+ Assert.Equal(JavaBuildTool.Maven, publishAnnotation.Tool);
+ Assert.Null(publishAnnotation.WrapperPath);
+ Assert.Equal(["clean", "package"], publishAnnotation.Args);
+ }
+
+ [Fact]
+ public void MavenGoalKeepsJavaCommandAndAddsPublishBuildInPublishMode()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var java = builder.AddJavaApp("java", Environment.CurrentDirectory)
+ .WithMavenGoal("spring-boot:run");
+
+ using var app = builder.Build();
+
+ Assert.Equal("java", java.Resource.Command);
+ Assert.True(java.Resource.TryGetLastAnnotation(out var runAnnotation));
+ Assert.Equal(JavaBuildTool.Maven, runAnnotation.Tool);
+ Assert.Equal(["spring-boot:run"], runAnnotation.Args);
+
+ Assert.True(java.Resource.TryGetLastAnnotation(out var publishAnnotation));
+ Assert.Equal(JavaBuildTool.Maven, publishAnnotation.Tool);
+ Assert.Null(publishAnnotation.WrapperPath);
+ Assert.Equal(["package"], publishAnnotation.Args);
+ }
+
+ [Fact]
+ public void GradleTaskKeepsJavaCommandAndAddsPublishBuildInPublishMode()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var java = builder.AddJavaApp("java", Environment.CurrentDirectory)
+ .WithGradleTask("bootRun");
+
+ using var app = builder.Build();
+
+ Assert.Equal("java", java.Resource.Command);
+ Assert.True(java.Resource.TryGetLastAnnotation(out var runAnnotation));
+ Assert.Equal(JavaBuildTool.Gradle, runAnnotation.Tool);
+ Assert.Equal(["bootRun"], runAnnotation.Args);
+
+ Assert.True(java.Resource.TryGetLastAnnotation(out var publishAnnotation));
+ Assert.Equal(JavaBuildTool.Gradle, publishAnnotation.Tool);
+ Assert.Null(publishAnnotation.WrapperPath);
+ Assert.Equal(["build"], publishAnnotation.Args);
+ }
+
+ [Fact]
+ public void CustomWrapperPathIsPreservedForPublishMetadata()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ var java = builder.AddJavaApp("java", Environment.CurrentDirectory)
+ .WithWrapperPath("tools/mvnw")
+ .WithMavenGoal("spring-boot:run");
+
+ using var app = builder.Build();
+
+ string expectedWrapper = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "tools/mvnw"));
+
+ Assert.True(java.Resource.TryGetLastAnnotation(out var runAnnotation));
+ Assert.Equal(expectedWrapper, runAnnotation.WrapperPath);
+
+ Assert.True(java.Resource.TryGetLastAnnotation(out var publishAnnotation));
+ Assert.Equal(expectedWrapper, publishAnnotation.WrapperPath);
+ }
+}