diff --git a/Aspire.slnx b/Aspire.slnx
index 1210f9a2c11..2bcb3d8dc11 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -371,7 +371,6 @@
-
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj
index 58f07f7074a..1a36efc151b 100644
--- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.ApiService/SqlServerEndToEnd.ApiService.csproj
@@ -1,4 +1,4 @@
-
+
$(DefaultTargetFramework)
@@ -10,6 +10,10 @@
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs
index e09603eaa67..093d79c12f9 100644
--- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/AppHost.cs
@@ -11,19 +11,28 @@
var sql2 = builder.AddAzureSqlServer("sql2");
var db2 = sql2.AddDatabase("db2");
-var dbsetup = builder.AddProject("dbsetup")
- .WithReference(db1).WaitFor(sql1)
- .WithReference(db2).WaitFor(sql2);
+var api = builder.AddProject("api")
+ .WithExternalHttpEndpoints();
-// Add EF migrations resource for the dbsetup project
+// Add EF migrations resource for the api project
// This adds dashboard commands for managing EF migrations
-var dbMigrations = dbsetup.AddEFMigrations("db-migrations");
-
-builder.AddProject("api")
- .WithExternalHttpEndpoints()
- .WithReference(db1).WaitFor(db1)
- .WithReference(db2).WaitFor(db2)
- .WaitForCompletion(dbsetup);
+var db1Migrations = api.AddEFMigrations("db1-migrations", "MyDb1Context")
+ .WithMigrationsProject()
+ .WithMigrationOutputDirectory("Db1Migrations")
+ .RunDatabaseUpdateOnStart() // Note that this only works during local development. The migrations resource is not deployed.
+ .PublishAsMigrationBundle(publishContainer: true)
+ .WithReference(db1).WaitFor(db1);
+
+var db2Migrations = api.AddEFMigrations("db2-migrations", "MyDb2Context")
+ .WithMigrationsProject()
+ .WithMigrationOutputDirectory("Db2Migrations")
+ .RunDatabaseUpdateOnStart() // Note that this only works during local development. The migrations resource is not deployed.
+ .PublishAsMigrationBundle(publishContainer: true)
+ .WithReference(db2).WaitFor(db2);
+
+api
+ .WaitForCompletion(db1Migrations)
+ .WaitForCompletion(db2Migrations);
#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj
index 45753dda360..f59bf76a23a 100644
--- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.AppHost/SqlServerEndToEnd.AppHost.csproj
@@ -20,7 +20,7 @@
-
+
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs
new file mode 100644
index 00000000000..a7af874f2de
--- /dev/null
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.Designer.cs
@@ -0,0 +1,41 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SqlServerEndToEnd.Common;
+
+#nullable disable
+
+namespace SqlServerEndToEnd.Common.Db1
+{
+ [DbContext(typeof(MyDb1Context))]
+ [Migration("20260511233127_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.26")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.ToTable("Entries");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs
new file mode 100644
index 00000000000..18ba7041a48
--- /dev/null
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/20260511233127_Initial.cs
@@ -0,0 +1,31 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SqlServerEndToEnd.Common.Db1;
+
+///
+public partial class Initial : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Entries",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Entries", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Entries");
+ }
+}
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs
new file mode 100644
index 00000000000..5e4c4b3aa2f
--- /dev/null
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db1Migrations/MyDb1ContextModelSnapshot.cs
@@ -0,0 +1,38 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SqlServerEndToEnd.Common;
+
+#nullable disable
+
+namespace SqlServerEndToEnd.Common.Db1
+{
+ [DbContext(typeof(MyDb1Context))]
+ partial class MyDb1ContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.26")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.ToTable("Entries");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs
new file mode 100644
index 00000000000..4b7d66dadcd
--- /dev/null
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.Designer.cs
@@ -0,0 +1,41 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SqlServerEndToEnd.Common;
+
+#nullable disable
+
+namespace SqlServerEndToEnd.Common.Db2
+{
+ [DbContext(typeof(MyDb2Context))]
+ [Migration("20260511233305_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.26")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.ToTable("Entries");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs
new file mode 100644
index 00000000000..0cd121ab200
--- /dev/null
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/20260511233305_Initial.cs
@@ -0,0 +1,31 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SqlServerEndToEnd.Common.Db2;
+
+///
+public partial class Initial : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Entries",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Entries", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Entries");
+ }
+}
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs
new file mode 100644
index 00000000000..0ead2221a85
--- /dev/null
+++ b/playground/SqlServerEndToEnd/SqlServerEndToEnd.Common/Db2Migrations/MyDb2ContextModelSnapshot.cs
@@ -0,0 +1,38 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SqlServerEndToEnd.Common;
+
+#nullable disable
+
+namespace SqlServerEndToEnd.Common.Db2
+{
+ [DbContext(typeof(MyDb2Context))]
+ partial class MyDb2ContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.26")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("SqlServerEndToEnd.Common.Entry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.ToTable("Entries");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs
deleted file mode 100644
index 5ff3dc07e66..00000000000
--- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Program.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using Microsoft.EntityFrameworkCore;
-using SqlServerEndToEnd.Common;
-
-var builder = WebApplication.CreateBuilder(args);
-builder.AddSqlServerDbContext("db1");
-builder.AddSqlServerDbContext("db2");
-using var app = builder.Build();
-using var scope = app.Services.CreateScope();
-using var db1 = scope.ServiceProvider.GetRequiredService();
-using var db2 = scope.ServiceProvider.GetRequiredService();
-
-foreach (var db in new DbContext[] { db1, db2 })
-{
- var created = await db.Database.EnsureCreatedAsync();
- if (created)
- {
- Console.WriteLine("Database schema created!");
- }
-}
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json
deleted file mode 100644
index 53f1987f4dd..00000000000
--- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/Properties/launchSettings.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "profiles": {
- "SqlServerEndToEnd.DbSetup": {
- "commandName": "Project",
- "launchBrowser": true,
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- },
- "applicationUrl": "https://localhost:49994;http://localhost:49995"
- }
- }
-}
diff --git a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj b/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj
deleted file mode 100644
index 502d75191d9..00000000000
--- a/playground/SqlServerEndToEnd/SqlServerEndToEnd.DbSetup/SqlServerEndToEnd.DbSetup.csproj
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
- Exe
- $(DefaultTargetFramework)
- enable
- enable
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs
index 552077dc3e6..e00abd5babc 100644
--- a/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs
+++ b/src/Aspire.Hosting.EntityFrameworkCore/EFCoreOperationExecutor.cs
@@ -45,6 +45,20 @@ internal sealed class EFCoreOperationExecutor : IDisposable
private const string DataPrefix = "data: ";
private const string VerbosePrefix = "verbose: ";
+ // Process-wide fallback that serializes every `dotnet-ef` invocation driven by this AppHost.
+ // The pipeline-step factory already chains migration generate steps via DependsOnSteps so two
+ // dotnet-ef processes won't run concurrently during `aspire publish`. This semaphore is a
+ // defense-in-depth backstop for:
+ // - run-mode resource commands (Update Database / Reset Database / ...) that the user can
+ // trigger from the dashboard on different migration resources at the same time, each on
+ // its own DotnetToolResource,
+ // - shared per-user state every dotnet-ef invocation touches: the `dotnet tool exec` cache,
+ // NuGet restore caches, and MSBuild node-reuse state under %USERPROFILE%, which are not
+ // safe under concurrent `dotnet ef` runs even when the projects don't overlap.
+ // Held only for the duration of a single dotnet-ef execution; never awaits any other pipeline
+ // step or command, so it cannot deadlock with the pipeline scheduler.
+ private static readonly SemaphoreSlim s_globalDotnetEfLock = new(1, 1);
+
public EFCoreOperationExecutor(
ProjectResource startupProjectResource,
string? targetProjectPath,
@@ -180,7 +194,7 @@ private void ParseBuildSettingsFromPath(string assemblyPath)
return null;
}
- private async Task ExecuteEfCommandAsync(string command, string subCommand, Dictionary? additionalArgs = null)
+ private async Task ExecuteEfCommandAsync(string command, string subCommand, Dictionary? additionalArgs = null, bool noBuild = true)
{
var initResult = EnsurePathsInitialized();
if (!initResult.Success)
@@ -188,8 +202,19 @@ private async Task ExecuteEfCommandAsync(string command, stri
return initResult;
}
- // Build the EF command arguments (these go after the -- in dotnet tool exec)
- var efArgs = new List { command, subCommand, "--no-build", "--no-color", "--prefix-output" };
+ // Build the EF command arguments (these go after the -- in dotnet tool exec).
+ // `--no-build` is normally added because all interactive run-mode commands assume the
+ // project was already built by the AppHost. Bundle generation during `aspire publish`
+ // intentionally omits it: the publish pipeline doesn't pre-build the startup project,
+ // and `dotnet ef migrations bundle` needs the migrations and startup projects compiled
+ // (and matching the requested target runtime) before it can package the bundle.
+ var efArgs = new List { command, subCommand };
+ if (noBuild)
+ {
+ efArgs.Add("--no-build");
+ }
+ efArgs.Add("--no-color");
+ efArgs.Add("--prefix-output");
if (_logger.IsEnabled(LogLevel.Debug))
{
@@ -242,6 +267,13 @@ private async Task ExecuteEfCommandAsync(string command, stri
_logger.LogDebug("Executing dotnet tool exec dotnet-ef --yes -- {Args}", string.Join(" ", efArgs));
+ // Acquire the global dotnet-ef lock outside the try/catch that maps exceptions to a failed
+ // EFOperationResult: an OperationCanceledException from WaitAsync must propagate so the
+ // caller observes cancellation rather than a generic failure result. Released in `finally`
+ // only when we actually acquired it (lockAcquired is set true after WaitAsync returns).
+ var lockAcquired = false;
+ await s_globalDotnetEfLock.WaitAsync(_cancellationToken).ConfigureAwait(false);
+ lockAcquired = true;
try
{
// Get required services
@@ -317,8 +349,8 @@ await notificationService.WaitForResourceAsync(
// Check if the command succeeded
var snapshot = resourceEvent.Snapshot;
- var exitCode = snapshot.Properties.FirstOrDefault(p => p.Name == "ExitCode")?.Value?.ToString();
- if ((exitCode != null && exitCode != "0") || snapshot.State?.Text == KnownResourceStates.FailedToStart)
+ var exitCode = snapshot.ExitCode;
+ if ((exitCode != null && exitCode.Value != 0) || snapshot.State?.Text == KnownResourceStates.FailedToStart)
{
return new EFOperationResult
{
@@ -339,6 +371,13 @@ await notificationService.WaitForResourceAsync(
{
return new EFOperationResult { Success = false, ErrorMessage = $"dotnet-ef command failed: {ex.Message}" };
}
+ finally
+ {
+ if (lockAcquired)
+ {
+ s_globalDotnetEfLock.Release();
+ }
+ }
}
private static string GetToolStartCommandName(DotnetToolResource toolResource)
@@ -699,7 +738,7 @@ public async Task GenerateMigrationBundleAsync(string? output
if (!string.IsNullOrEmpty(targetRuntime))
{
- args["--runtime"] = targetRuntime;
+ args["--target-runtime"] = targetRuntime;
}
if (selfContained)
@@ -710,7 +749,9 @@ public async Task GenerateMigrationBundleAsync(string? output
// Overwrite existing bundle
args["--force"] = null;
- return await ExecuteEfCommandAsync("migrations", "bundle", args).ConfigureAwait(false);
+ // The bundle command compiles the migrations + startup project for the target runtime,
+ // so `--no-build` would defeat the purpose; let dotnet-ef drive the build itself.
+ return await ExecuteEfCommandAsync("migrations", "bundle", args, noBuild: false).ConfigureAwait(false);
}
public void Dispose()
diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs
index 4f9f4b754bb..fd5f70c5d18 100644
--- a/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting.EntityFrameworkCore/EFMigrationResourceBuilderExtensions.cs
@@ -23,9 +23,9 @@ public static class EFMigrationResourceBuilderExtensions
/// The resource builder for chaining.
///
///
- /// When enabled, migrations will be applied during AppHost startup.
- /// This only affects local run-mode execution. The migrations resource is not deployed with the app,
- /// so the command has no effect during publish or deployment.
+ /// When enabled, migrations are applied during AppHost startup. This only affects local
+ /// run-mode execution. The migrations resource is not deployed with the app, so this method
+ /// has no effect during publish or deployment.
///
///
/// A health check is automatically registered for this resource, allowing other resources to use
@@ -113,12 +113,12 @@ public static IResourceBuilder PublishAsMigrationScript(
///
/// The target runtime identifier for the bundle (e.g., linux-x64, win-x64).
/// If and is ,
- /// defaults to linux-x64 so the bundle can run inside a Linux container image. When
- /// is the current runtime is used.
+ /// defaults to linux-x64 to match the default Linux base container image used for the
+ /// generated Dockerfile. If and
+ /// is , the bundle targets the runtime hosting aspire publish.
///
///
/// If , creates a self-contained bundle that includes the .NET runtime.
- /// Never defaulted by — user-specified value is always respected.
///
///
/// If , the bundle is published as a container image that applies migrations
@@ -141,10 +141,17 @@ public static IResourceBuilder PublishAsMigrationScript(
/// under the efmigrations folder. When is
/// , Aspire also generates a Dockerfile that packages the bundle into
/// a container image; the container reads the connection string from a
- /// ConnectionStrings__<name> environment variable provided by the referenced database
- /// resource (call .WithReference(db) on the migration builder, or the connection string is
- /// injected automatically for every that the migration
- /// resource .WaitFors).
+ /// ConnectionStrings__<name> environment variable injected automatically for a
+ /// that the migration resource references or waits on.
+ ///
+ ///
+ /// The startup project (the project on which AddEFMigrations was invoked) and the
+ /// migrations project (configured via
+ /// or , if
+ /// different) must both list the target runtime in their <RuntimeIdentifiers> MSBuild property.
+ /// If = , by default this means
+ /// adding at minimum <RuntimeIdentifiers>linux-x64</RuntimeIdentifiers> to
+ /// both projects.
///
///
[AspireExport]
@@ -184,8 +191,8 @@ public static IResourceBuilder PublishAsMigrationBundle(
/// The output directory path relative to the project root.
/// The resource builder for chaining.
///
- /// If not specified, migrations will be placed in the default 'Migrations' directory.
- /// Example: "Data/Migrations" or "Infrastructure/Migrations".
+ /// If not specified, migrations will be placed in the default Migrations directory.
+ /// Example: Data/Migrations or Infrastructure/Migrations.
///
[AspireExport]
public static IResourceBuilder WithMigrationOutputDirectory(this IResourceBuilder builder, string outputDirectory)
@@ -203,7 +210,7 @@ public static IResourceBuilder WithMigrationOutputDirectory
/// The resource builder for chaining.
///
/// If not specified, the namespace will be derived from the project's default namespace.
- /// Example: "MyApp.Data.Migrations" or "MyApp.Infrastructure.Migrations".
+ /// Example: MyApp.Data.Migrations or MyApp.Infrastructure.Migrations.
///
[AspireExport]
public static IResourceBuilder WithMigrationNamespace(this IResourceBuilder builder, string @namespace)
@@ -223,7 +230,8 @@ public static IResourceBuilder WithMigrationNamespace(this
///
/// Use this method when the migrations are in a different project than the startup project.
/// The target project's path will be used for migration operations while the startup project
- /// remains the original project.
+ /// remains the original project. The project resource on which AddEFMigrations is invoked
+ /// should be the startup project (the project that contains the DbContext configuration).
///
///
[AspireExportIgnore(Reason = "Polyglot app hosts use the internal withMigrationsProject dispatcher export.")]
@@ -246,14 +254,9 @@ public static IResourceBuilder WithMigrationsProject(this I
///
/// Use this method when the migrations are in a different project than the startup project.
/// The target project's path will be used for migration operations while the startup project
- /// remains the original project.
+ /// remains the original project. The project resource on which AddEFMigrations is invoked
+ /// should be the startup project (the project that contains the DbContext configuration).
///
- ///
- ///
- /// var migrations = project.AddEFMigrations<MyDbContext>("migrations")
- /// .WithMigrationsProject<Projects.MyMigrationsProject>();
- ///
- ///
///
[AspireExportIgnore(Reason = "Uses IProjectMetadata generic constraint which is a .NET-specific type. Polyglot app hosts use the internal withMigrationsProject dispatcher export.")]
public static IResourceBuilder WithMigrationsProject(this IResourceBuilder builder)
@@ -297,6 +300,10 @@ internal static IResourceBuilder WithMigrationsProjectForPo
private const string WindowsImageTagSuffix = "-nanoserver-ltsc2022";
private const string ConnectionStringEnvVarPrefix = "ConnectionStrings__";
+ // Mirrors Aspire.Dashboard.Model.KnownRelationshipTypes.Reference, which is internal to
+ // Aspire.Hosting and not visible from this project. Kept in sync with that constant.
+ private const string ReferenceRelationshipType = "Reference";
+
private static void ConfigureBundleContainer(IResourceBuilder builder)
{
var migrationResource = builder.Resource;
@@ -318,12 +325,13 @@ private static void ConfigureBundleContainer(IResourceBuilder for the bundle
- // container the same way it does for any other compute resource.
+ // dependencies the user declared via WithReference or WaitFor.
+ // Forward them through the standard environment callback so the compute environment
+ // injects ConnectionStrings__ for the bundle container the same way it does for
+ // any other compute resource.
builder.ApplicationBuilder.Eventing.Subscribe((@event, _) =>
{
- var connectionStringResource = GetSingleWaitedOnConnectionStringResource(migrationResource);
+ var connectionStringResource = GetSingleConnectionStringResource(migrationResource);
var envVar = connectionStringResource.ConnectionStringEnvironmentVariable
?? ConnectionStringEnvVarPrefix + connectionStringResource.Name;
@@ -350,30 +358,27 @@ private static string ResolvePipelineOutputDirectory(IResourceBuilder();
- if (migrationResource.TryGetAnnotationsOfType(out var waitAnnotations))
+ // Prefer explicit references declared via .WithReference(): those are the user's
+ // explicit statement of which connection string the bundle should target. Only when no
+ // such reference exists do we fall back to inferring it from .WaitFor() dependencies.
+ var candidates = CollectConnectionStringCandidates(
+ migrationResource,
+ annotation => annotation.Type == ReferenceRelationshipType ? annotation.Resource : null);
+
+ if (candidates.Count == 0)
{
- foreach (var wait in waitAnnotations)
- {
- if (wait.Resource is IResourceWithConnectionString connectionStringResource
- && !candidates.Any(c => ReferenceEquals(c, connectionStringResource)))
- {
- candidates.Add(connectionStringResource);
- }
- }
+ candidates = CollectConnectionStringCandidates(
+ migrationResource,
+ annotation => annotation.Resource);
}
if (candidates.Count == 0)
{
throw new InvalidOperationException(
$"Cannot publish migration bundle '{migrationResource.Name}' as a container: add " +
- $"'.WaitFor()' with a database resource that exposes a connection string.");
+ $"'.WithReference()' and/or '.WaitFor()' with a database resource that exposes a connection string.");
}
// Drop any candidate that is an ancestor (via IResourceWithParent) of another candidate.
@@ -391,9 +396,30 @@ private static IResourceWithConnectionString GetSingleWaitedOnConnectionStringRe
var unrelated = string.Join(", ", leaves.Select(l => $"'{l.Name}'"));
throw new InvalidOperationException(
$"Cannot publish migration bundle '{migrationResource.Name}' as a container: multiple " +
- $"unrelated waited-on resources expose a connection string ({unrelated}). A migration " +
- $"bundle targets exactly one database — only call '.WaitFor' with a single " +
- $"IResourceWithConnectionString, or waited-on resources that share a parent chain.");
+ $"resources expose a connection string ({unrelated}). A migration " +
+ $"bundle targets exactly one database — only reference or wait on a single " +
+ $"IResourceWithConnectionString, or resources that share a parent chain.");
+ }
+
+ private static List CollectConnectionStringCandidates(
+ EFMigrationResource migrationResource,
+ Func resourceSelector)
+ where TAnnotation : IResourceAnnotation
+ {
+ var candidates = new List();
+ if (migrationResource.TryGetAnnotationsOfType(out var annotations))
+ {
+ foreach (var annotation in annotations)
+ {
+ if (resourceSelector(annotation) is IResourceWithConnectionString connectionStringResource
+ && !candidates.Any(c => ReferenceEquals(c, connectionStringResource)))
+ {
+ candidates.Add(connectionStringResource);
+ }
+ }
+ }
+
+ return candidates;
}
private static bool IsAncestorOf(IResource candidate, IResource descendant)
@@ -490,7 +516,7 @@ private static bool TryExtractVersion(string? tfm, out string version)
internal static string GenerateDockerfile(EFMigrationResource migrationResource)
{
- var primary = GetSingleWaitedOnConnectionStringResource(migrationResource);
+ var primary = GetSingleConnectionStringResource(migrationResource);
var envVarName = primary.ConnectionStringEnvironmentVariable
?? ConnectionStringEnvVarPrefix + primary.Name;
diff --git a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs
index 68ac5a2f7b0..aba9e3130e9 100644
--- a/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting.EntityFrameworkCore/EFResourceBuilderExtensions.cs
@@ -31,7 +31,7 @@ private static string GetShortTypeName(string? fullTypeName)
}
///
- /// Adds EF Core migration management for a specific DbContext type identified by name.
+ /// Adds EF Core migration management for a specific DbContext type.
///
/// The resource builder for the project.
/// The name of the migration resource.
@@ -39,14 +39,8 @@ private static string GetShortTypeName(string? fullTypeName)
/// An EF migration resource builder for chaining additional configuration.
/// Thrown if migrations for this context type have already been added.
///
- ///
/// Multiple calls to this method with different context types are supported, allowing you to manage
/// migrations for multiple DbContexts in the same project.
- ///
- ///
- /// This overload is useful when the DbContext type is not available at compile time, such as when
- /// using runtime-discovered context types.
- ///
///
[AspireExportIgnore(Reason = "Polyglot app hosts use the internal addEFMigrations dispatcher export.")]
public static IResourceBuilder AddEFMigrations(
@@ -62,7 +56,7 @@ public static IResourceBuilder AddEFMigrations(
}
///
- /// Adds EF Core migration management for a specific DbContext type identified by name.
+ /// Adds EF Core migration management for a specific DbContext type.
///
/// The resource builder for the project.
/// The name of the migration resource.
@@ -71,14 +65,8 @@ public static IResourceBuilder AddEFMigrations(
/// An EF migration resource builder for chaining additional configuration.
/// Thrown if migrations for this context type have already been added.
///
- ///
/// Multiple calls to this method with different context types are supported, allowing you to manage
/// migrations for multiple DbContexts in the same project.
- ///
- ///
- /// This overload is useful when the DbContext type is not available at compile time, such as when
- /// using runtime-discovered context types.
- ///
///
[AspireExportIgnore(Reason = "Action> callbacks are not ATS-compatible.")]
public static IResourceBuilder AddEFMigrations(
@@ -95,11 +83,12 @@ public static IResourceBuilder AddEFMigrations(
}
///
- /// Adds EF Core migration management for auto-detected DbContext types.
+ /// Adds EF Core migration management for the only DbContext type in the target project.
///
/// The resource builder for the project.
/// The name of the migration resource.
/// An EF migration resource builder for chaining additional configuration.
+ /// Thrown if migrations have already been added for any DbContext type on this project.
[AspireExportIgnore(Reason = "Polyglot app hosts use the internal addEFMigrations dispatcher export.")]
public static IResourceBuilder AddEFMigrations(
this IResourceBuilder builder,
@@ -132,12 +121,13 @@ internal static IResourceBuilder AddEFMigrationsForPolyglot
}
///
- /// Adds EF Core migration management for auto-detected DbContext types.
+ /// Adds EF Core migration management for the only DbContext type in the target project.
///
/// The resource builder for the project.
/// The name of the migration resource.
/// Optional callback to configure the dotnet-ef tool resource used for migrations.
/// An EF migration resource builder for chaining additional configuration.
+ /// Thrown if migrations have already been added for any DbContext type on this project.
[AspireExportIgnore(Reason = "Action> callbacks are not ATS-compatible.")]
public static IResourceBuilder AddEFMigrations(
this IResourceBuilder builder,
@@ -156,33 +146,35 @@ private static IResourceBuilder AddEFMigrationsCore(
string? dbContextTypeName,
Action>? configureToolResource)
{
- // Check for duplicate context types and null/non-null conflicts
- var existingMigrations = builder.ApplicationBuilder.Resources
+ var existingMigrationResources = builder.ApplicationBuilder.Resources
.OfType()
.Where(r => r.ProjectResource == builder.Resource)
.ToList();
if (dbContextTypeName != null)
{
- if (existingMigrations.Any(r => r.DbContextTypeName == dbContextTypeName))
+ if (existingMigrationResources.Any(r => r.DbContextTypeName == dbContextTypeName))
{
throw new InvalidOperationException(
$"The DbContext type '{GetShortTypeName(dbContextTypeName)}' has already been registered for EF migrations on resource '{builder.Resource.Name}'.");
}
- if (existingMigrations.Any(r => r.DbContextTypeName == null))
+ if (existingMigrationResources.Any(r => r.DbContextTypeName == null))
{
throw new InvalidOperationException(
- $"Cannot add migrations for a specific DbContext type when auto-detected migrations have already been registered on resource '{builder.Resource.Name}'.");
+ $"Cannot register a specific DbContext type for migrations when they have already been registered without a context type on resource '{builder.Resource.Name}'.");
}
}
- else
+ else if (existingMigrationResources.Count != 0)
{
- if (existingMigrations.Any())
+ if (existingMigrationResources.Any(r => r.DbContextTypeName == null))
{
throw new InvalidOperationException(
- $"Cannot add auto-detected migrations when migrations for specific DbContext types have already been registered on resource '{builder.Resource.Name}'.");
+ $"Cannot register migrations without a context type when they have already been registered without a context type on resource '{builder.Resource.Name}'.");
}
+
+ throw new InvalidOperationException(
+ $"Cannot register migrations without a context type when they have already been registered for specific DbContext types on resource '{builder.Resource.Name}'.");
}
var migrationResource = new EFMigrationResource(name, builder.Resource, dbContextTypeName)
@@ -222,13 +214,38 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt
? $"{migrationResource.Name}-generate-migration-script"
: null;
+ var bundleStepName = migrationResource.PublishAsMigrationBundle
+ ? $"{migrationResource.Name}-generate-migration-bundle"
+ : null;
+
+ // Serialize publish-time generate steps across every EFMigrationResource in the model.
+ // Each `dotnet-ef` invocation triggers a `dotnet build` (the bundle step explicitly runs
+ // without `--no-build` because the bundle command needs the build to target a specific
+ // runtime). Two concurrent `dotnet-ef` runs can race on:
+ // - the shared obj/bin output when two migrations target the same startup project,
+ // - the per-user `dotnet tool exec` cache (NuGet install + extract) used by every
+ // DotnetToolResource regardless of project, and
+ // - the per-user MSBuild node-reuse / NuGet restore caches under %USERPROFILE%.
+ // None of those are safe under concurrent `dotnet-ef` invocations, so we chain ALL
+ // migration generate steps in the model — not just the ones sharing a startup project.
+ //
+ // The chain is built by deterministically ordering sibling migrations by name and pointing
+ // the first step of each migration at the last step of the previous migration. The
+ // graph is therefore: -script -> -bundle -> -script -> -bundle -> ...
+ // which is acyclic (the per-migration script -> bundle edge already exists and the
+ // cross-migration edge only flows forward in the deterministic ordering).
+ var crossMigrationPredecessor = GetPreviousMigrationLastStepName(context.PipelineContext.Model, migrationResource);
+
if (migrationResource.PublishAsMigrationScript)
{
+ List scriptDependsOn = crossMigrationPredecessor is not null ? [crossMigrationPredecessor] : [];
+
steps.Add(new PipelineStep
{
Name = scriptStepName!,
Description = $"Generate EF Core migration SQL script for {migrationResource.Name}",
Resource = migrationResource,
+ DependsOnSteps = scriptDependsOn,
RequiredBySteps = [WellKnownPipelineSteps.Publish],
Action = stepContext => ExecutePublishPipelineOperationAsync(
stepContext, migrationResource, "migration script",
@@ -247,19 +264,32 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt
if (migrationResource.PublishAsMigrationBundle)
{
- var generateStepName = $"{migrationResource.Name}-generate-migration-bundle";
- var publishesContainer = migrationResource.PublishBundleContainer;
+ var publishesContainer = migrationResource.PublishBundleContainer
+ && context.PipelineContext.ExecutionContext.IsPublishMode;
List requiredBy = publishesContainer
? [WellKnownPipelineSteps.Publish, $"build-{migrationResource.Name}"]
: [WellKnownPipelineSteps.Publish];
+ // Prefer the per-migration script step as the dependency when present (the cross-migration
+ // edge is already attached to the script step in that case). Only attach the cross-migration
+ // edge directly to the bundle step when this migration produces no script step.
+ List bundleDependsOn = [];
+ if (scriptStepName is not null)
+ {
+ bundleDependsOn.Add(scriptStepName);
+ }
+ else if (crossMigrationPredecessor is not null)
+ {
+ bundleDependsOn.Add(crossMigrationPredecessor);
+ }
+
steps.Add(new PipelineStep
{
- Name = generateStepName,
+ Name = bundleStepName!,
Description = $"Generate EF Core migration bundle for {migrationResource.Name}",
Resource = migrationResource,
- DependsOnSteps = scriptStepName is not null ? [scriptStepName] : [], // Make sure these don't run in parallel as the underlying tool resource is not thread safe
+ DependsOnSteps = bundleDependsOn,
RequiredBySteps = requiredBy,
Action = stepContext => ExecutePublishPipelineOperationAsync(
stepContext, migrationResource, "migration bundle",
@@ -279,6 +309,47 @@ internal static IEnumerable CreateMigrationPipelineStep(PipelineSt
return steps;
}
+ // Returns the name of the last publish-time step produced by the migration that immediately
+ // precedes in a stable ordering of all migrations in the model.
+ // Returns null when is the first such migration (no predecessor)
+ // or the only one.
+ private static string? GetPreviousMigrationLastStepName(DistributedApplicationModel model, EFMigrationResource current)
+ {
+ EFMigrationResource? predecessor = null;
+ foreach (var sibling in model.Resources.OfType())
+ {
+ if (ReferenceEquals(sibling, current) ||
+ (!sibling.PublishAsMigrationScript && !sibling.PublishAsMigrationBundle))
+ {
+ continue;
+ }
+
+ // Stable ordinal ordering by resource name keeps the chain deterministic regardless
+ // of model traversal order. Only siblings whose name sorts before this one can
+ // possibly act as a predecessor.
+ if (StringComparer.Ordinal.Compare(sibling.Name, current.Name) >= 0)
+ {
+ continue;
+ }
+
+ if (predecessor is null || StringComparer.Ordinal.Compare(sibling.Name, predecessor.Name) > 0)
+ {
+ predecessor = sibling;
+ }
+ }
+
+ if (predecessor is null)
+ {
+ return null;
+ }
+
+ // The bundle step always follows the script step within the same migration, so it is the
+ // last step when present.
+ return predecessor.PublishAsMigrationBundle
+ ? $"{predecessor.Name}-generate-migration-bundle"
+ : $"{predecessor.Name}-generate-migration-script";
+ }
+
private static async Task ExecutePublishPipelineOperationAsync(
PipelineStepContext stepContext,
EFMigrationResource migrationResource,
@@ -343,7 +414,6 @@ private static async Task StartEfToolResourceAsync(Execute
try
{
-
var executableAnnotation = toolResource.Annotations.OfType().LastOrDefault();
if (executableAnnotation is null)
{
@@ -461,7 +531,8 @@ await notificationService.PublishUpdateAsync(toolResource, s => s with
await notificationService.PublishUpdateAsync(toolResource, s => s with
{
State = finalState,
- StopTimeStamp = DateTime.UtcNow
+ StopTimeStamp = DateTime.UtcNow,
+ ExitCode = process.ExitCode
}).ConfigureAwait(false);
if (process.ExitCode != 0)
diff --git a/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs b/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs
index 539a99bbcd9..ed841973579 100644
--- a/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs
+++ b/tests/Aspire.Hosting.EntityFrameworkCore.Tests/EFMigrationPipelineTests.cs
@@ -78,6 +78,135 @@ public async Task NoPublishOptionsProducesNoSteps()
Assert.Empty(steps);
}
+ [Fact]
+ public async Task MultipleBundlesOnSameProjectAreSerializedByChainingSteps()
+ {
+ // Two migration resources targeting the same startup project must not generate their
+ // bundles in parallel: each `dotnet-ef migrations bundle` drives a `dotnet build` of
+ // that shared project, and concurrent builds corrupt the project's obj/bin output.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var project = builder.AddProject("myproject");
+ var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!)
+ .PublishAsMigrationBundle();
+ var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!)
+ .PublishAsMigrationBundle();
+
+ var m1Steps = await CreateStepsAsync(builder, m1.Resource);
+ var m2Steps = await CreateStepsAsync(builder, m2.Resource);
+
+ var m1Bundle = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-bundle");
+ var m2Bundle = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-bundle");
+
+ Assert.Empty(m1Bundle.DependsOnSteps);
+ Assert.Contains("aaa-migrations-generate-migration-bundle", m2Bundle.DependsOnSteps);
+ }
+
+ [Fact]
+ public async Task MultipleMigrationsOnSameProjectChainScriptAndBundleAcrossResources()
+ {
+ // The successor migration's first step (its script step when present, otherwise its bundle
+ // step) must depend on the predecessor's last step (its bundle step when present, otherwise
+ // its script step). Within a single migration the bundle already depends on the script, so
+ // we only need to attach the cross-migration edge to the first step.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var project = builder.AddProject("myproject");
+ var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!)
+ .PublishAsMigrationScript()
+ .PublishAsMigrationBundle();
+ var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!)
+ .PublishAsMigrationScript()
+ .PublishAsMigrationBundle();
+
+ var m1Steps = await CreateStepsAsync(builder, m1.Resource);
+ var m2Steps = await CreateStepsAsync(builder, m2.Resource);
+
+ var m1Script = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-script");
+ var m1Bundle = Assert.Single(m1Steps, s => s.Name == "aaa-migrations-generate-migration-bundle");
+ var m2Script = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-script");
+ var m2Bundle = Assert.Single(m2Steps, s => s.Name == "bbb-migrations-generate-migration-bundle");
+
+ Assert.Empty(m1Script.DependsOnSteps);
+ Assert.Contains(m1Script.Name, m1Bundle.DependsOnSteps);
+ Assert.Contains(m1Bundle.Name, m2Script.DependsOnSteps);
+ Assert.Contains(m2Script.Name, m2Bundle.DependsOnSteps);
+ Assert.DoesNotContain(m1Bundle.Name, m2Bundle.DependsOnSteps);
+ }
+
+ [Fact]
+ public async Task MigrationsOnDifferentProjectsAreAlsoChained()
+ {
+ // Migrations on different projects must still be serialized: every `dotnet-ef` invocation
+ // touches per-user state (`dotnet tool exec` cache, NuGet restore caches, MSBuild
+ // node-reuse) that is not safe under concurrent `dotnet-ef` runs even when the projects
+ // don't overlap. The chain spans the whole model, not just one project.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var projectA = builder.AddProject("projecta");
+ var projectB = builder.AddProject("projectb");
+ var m1 = projectA.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!)
+ .PublishAsMigrationBundle();
+ var m2 = projectB.AddEFMigrations("bbb-migrations", typeof(TestDbContext).FullName!)
+ .PublishAsMigrationBundle();
+
+ var m1Steps = await CreateStepsAsync(builder, m1.Resource);
+ var m2Steps = await CreateStepsAsync(builder, m2.Resource);
+
+ var m1Bundle = Assert.Single(m1Steps);
+ var m2Bundle = Assert.Single(m2Steps);
+
+ Assert.Empty(m1Bundle.DependsOnSteps);
+ Assert.Contains("aaa-migrations-generate-migration-bundle", m2Bundle.DependsOnSteps);
+ }
+
+ [Fact]
+ public async Task MixedScriptOnlyAndBundleOnlyMigrationsAreChained()
+ {
+ // A predecessor with only a script step and a successor with only a bundle step should
+ // still produce a single serialized chain: bundle -> script via DependsOnSteps.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var project = builder.AddProject("myproject");
+ var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!)
+ .PublishAsMigrationScript();
+ var m2 = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!)
+ .PublishAsMigrationBundle();
+
+ var m1Steps = await CreateStepsAsync(builder, m1.Resource);
+ var m2Steps = await CreateStepsAsync(builder, m2.Resource);
+
+ var m1Script = Assert.Single(m1Steps);
+ var m2Bundle = Assert.Single(m2Steps);
+
+ Assert.Equal("aaa-migrations-generate-migration-script", m1Script.Name);
+ Assert.Equal("bbb-migrations-generate-migration-bundle", m2Bundle.Name);
+ Assert.Contains(m1Script.Name, m2Bundle.DependsOnSteps);
+ }
+
+ [Fact]
+ public async Task MigrationsWithoutPublishOptionsAreSkippedFromChain()
+ {
+ // A sibling migration that opted out of publish-time generation produces no pipeline
+ // steps, so it must not appear in the chain — otherwise the successor would depend on
+ // a non-existent step name and pipeline execution would fail.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var project = builder.AddProject("myproject");
+ var m1 = project.AddEFMigrations("aaa-migrations", typeof(TestDbContext).FullName!)
+ .PublishAsMigrationBundle();
+ // bbb-migrations does NOT call PublishAsMigrationBundle/Script — it has no pipeline steps.
+ _ = project.AddEFMigrations("bbb-migrations", typeof(AnotherDbContext).FullName!);
+ var m3 = project.AddEFMigrations("ccc-migrations", typeof(ThirdDbContext).FullName!)
+ .PublishAsMigrationBundle();
+
+ var m1Steps = await CreateStepsAsync(builder, m1.Resource);
+ var m3Steps = await CreateStepsAsync(builder, m3.Resource);
+
+ var m1Bundle = Assert.Single(m1Steps);
+ var m3Bundle = Assert.Single(m3Steps);
+
+ Assert.Contains("aaa-migrations-generate-migration-bundle", m3Bundle.DependsOnSteps);
+ Assert.DoesNotContain("bbb-migrations-generate-migration-bundle", m3Bundle.DependsOnSteps);
+ Assert.DoesNotContain("bbb-migrations-generate-migration-script", m3Bundle.DependsOnSteps);
+ Assert.Empty(m1Bundle.DependsOnSteps);
+ }
+
[Fact]
public async Task PublishBundleContainerProducesNoStepsInRunMode()
{
@@ -446,12 +575,88 @@ public void GeneratedDockerfileFailsWhenMultipleUnrelatedWaitedOnConnectionStrin
var ex = Assert.Throws(() =>
EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource));
- Assert.Contains("multiple", ex.Message);
- Assert.Contains("unrelated", ex.Message);
+ Assert.Contains("multiple resources", ex.Message);
+ Assert.Contains("'db1'", ex.Message);
+ Assert.Contains("'db2'", ex.Message);
+ }
+
+ [Fact]
+ public void GeneratedDockerfileUsesReferencedConnectionStringResource()
+ {
+ // .WithReference(db) is the explicit way for the user to declare which connection
+ // string the bundle should target. It must be honored even when no .WaitFor is set.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var db = builder.AddResource(new TestDatabaseResource("mydb"));
+ var project = builder.AddProject("myproject");
+ var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!)
+ .WithReference(db)
+ .PublishAsMigrationBundle(publishContainer: true);
+
+ var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource);
+
+ Assert.Contains("ConnectionStrings__mydb", dockerfile);
+ }
+
+ [Fact]
+ public void GeneratedDockerfilePrefersReferencedResourceOverWaitedOnResource()
+ {
+ // When the user explicitly references one database via .WithReference(db) and waits on
+ // another via .WaitFor(other), the explicit reference is the user's stated target and
+ // must win — the waited-on resource is just an ordering signal, not a target selection.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var referenced = builder.AddResource(new TestDatabaseResource("referenced"));
+ var waited = builder.AddResource(new TestDatabaseResource("waited"));
+ var project = builder.AddProject("myproject");
+ var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!)
+ .WithReference(referenced)
+ .WaitFor(waited)
+ .PublishAsMigrationBundle(publishContainer: true);
+
+ var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource);
+
+ Assert.Contains("ConnectionStrings__referenced", dockerfile);
+ Assert.DoesNotContain("ConnectionStrings__waited", dockerfile);
+ }
+
+ [Fact]
+ public void GeneratedDockerfileFailsWhenMultipleUnrelatedReferencedConnectionStringResources()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var db1 = builder.AddResource(new TestDatabaseResource("db1"));
+ var db2 = builder.AddResource(new TestDatabaseResource("db2"));
+ var project = builder.AddProject("myproject");
+ var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!)
+ .WithReference(db1)
+ .WithReference(db2)
+ .PublishAsMigrationBundle(publishContainer: true);
+
+ var ex = Assert.Throws(() =>
+ EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource));
+ Assert.Contains("multiple resources", ex.Message);
Assert.Contains("'db1'", ex.Message);
Assert.Contains("'db2'", ex.Message);
}
+ [Fact]
+ public void GeneratedDockerfilePrefersLeafWhenReferencedChildAndParent()
+ {
+ // Mirrors the WaitFor leaf-vs-ancestor test: when the user .WithReference's both a
+ // child database and its parent server, the leaf (child) is the one whose connection
+ // string targets the actual database, so it wins.
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: null);
+ var server = builder.AddResource(new TestDatabaseResource("sql"));
+ var database = builder.AddResource(new TestChildDatabaseResource("sqldata", server.Resource));
+ var project = builder.AddProject("myproject");
+ var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!)
+ .WithReference(server)
+ .WithReference(database)
+ .PublishAsMigrationBundle(publishContainer: true);
+
+ var dockerfile = EFMigrationResourceBuilderExtensions.GenerateDockerfile(migrations.Resource);
+
+ Assert.Contains("ConnectionStrings__sqldata", dockerfile);
+ }
+
[Fact]
public void GeneratedDockerfilePrefersLeafWhenWaitForChildAlsoWaitsOnParent()
{
@@ -548,6 +753,8 @@ private static async Task> CreateStepsAsync(
// Test classes for DbContext types
private sealed class TestDbContext { }
+ private sealed class AnotherDbContext { }
+ private sealed class ThirdDbContext { }
///
/// A minimal test resource that implements IResourceWithConnectionString and IResourceWithWaitSupport.