.NET 11 build-time OpenAPI can now start an ASP.NET Core application under an environment chosen explicitly for document generation. That closes a subtle CI gap: an API may register endpoints, services, or OpenAPI transformers differently in Development, Staging, and Production, while the build previously generated a document under whichever environment happened to be present on the runner.
The new OpenApiGenerationEnvironment MSBuild property makes the choice part of the build contract. Use it with a deliberate default, override it visibly in CI, write each environment’s document to a separate directory, and compare the generated paths before promotion. The goal is not to make every environment identical. It is to make any difference intentional, reviewable, and reproducible.
Table of Contents
Why OpenAPI drift appears during a build
Build-time OpenAPI generation does more than inspect static attributes. The document generator starts the application far enough to discover endpoints and run the same OpenAPI pipeline that the app configures. That means the host environment can change the result.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id) =>
Results.Ok(new { id, status = "ready" }));
if (app.Environment.IsDevelopment())
{
app.MapGet("/_debug/config", () =>
Results.Ok(new { environment = app.Environment.EnvironmentName }));
}
app.MapOpenApi();
app.Run();
A document generated as Development contains /_debug/config; a Production document should not. The same issue appears when an environment controls endpoint groups, authentication schemes, schema transformers, server URLs, feature flags, or service implementations that contribute metadata.
Without an explicit build property, the result can depend on runner configuration. One job inherits DOTNET_ENVIRONMENT=Development, another sets ASPNETCORE_ENVIRONMENT=Staging, and a local build uses a launch profile that CI never sees. The JSON diff then looks like application drift even though the source revision is identical.
How .NET 11 build-time OpenAPI selects the environment
.NET 11 RC1 adds OpenApiGenerationEnvironment to Microsoft.Extensions.ApiDescription.Server. When the property is non-empty, the package passes it to dotnet-getdocument as --environment. The tool forwards that value to the application host as the environment setting—the same hosting concept used by ASPNETCORE_ENVIRONMENT and DOTNET_ENVIRONMENT.
- It controls document-generation startup: the app host used to discover OpenAPI runs under the selected environment.
- It does not deploy the app: setting the property in a build job does not change the environment of a later container, App Service, or Kubernetes deployment.
- It does not merge environments: one generation run has one environment name.
- It does not make environment-specific APIs safe: the application still owns endpoint exposure, authorization, and configuration.
This distinction matters in a release pipeline. A Production OpenAPI artifact proves what the source generates when the host identifies itself as Production. It does not prove that the deployed service received the same configuration, secrets, feature flags, or routing rules. Runtime smoke tests remain necessary.
Pin a safe project default
Make the default visible in the project file instead of relying on ambient runner state. Production is usually the conservative choice because it avoids accidentally documenting development-only endpoints when a developer runs a normal build.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<!-- CI can override this with /p:OpenApiGenerationEnvironment=... -->
<OpenApiGenerationEnvironment
Condition="'$(OpenApiGenerationEnvironment)' == ''">Production</OpenApiGenerationEnvironment>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.AspNetCore.OpenApi"
Version="11.0.0-rc.1.*" />
<PackageReference
Include="Microsoft.Extensions.ApiDescription.Server"
Version="11.0.0-rc.1.*"
PrivateAssets="all" />
</ItemGroup>
</Project>
Replace wildcard versions with the exact builds approved by your repository. The example highlights the package roles; a release pipeline should pin the SDK and packages so a restore cannot silently change the generator between two supposedly comparable runs.
The conditional default is important. An unconditional Production value would ignore a deliberate command-line override. The condition supplies a safe local default while preserving an explicit CI selection.
Generate isolated documents in CI
Do not let two environment runs write into the same intermediate directory. Give each invocation its own output path, remove stale output first, and record the selected property in the job log.
#!/usr/bin/env bash
set -euo pipefail
project="src/Catalog.Api/Catalog.Api.csproj"
root="$PWD/artifacts/openapi"
rm -rf "$root"
mkdir -p "$root/production" "$root/staging"
dotnet --info
dotnet build "$project" --configuration Release /p:OpenApiGenerationEnvironment=Production /p:OpenApiDocumentsDirectory="$root/production"
dotnet build "$project" --configuration Release /p:OpenApiGenerationEnvironment=Staging /p:OpenApiDocumentsDirectory="$root/staging"
find "$root" -type f -name '*.json' -print -exec sha256sum {} \;
OpenApiDocumentsDirectory is interpreted relative to the project directory unless it is already absolute. The script uses an absolute path so the artifact location does not change when CI invokes the project from a different working directory.
Keep the two artifacts only when both environments are intentional products. Many teams should generate one contract—the environment their client generator and API review actually target. A second environment build is most useful as a controlled drift check, not as another artifact to publish automatically.
Verify paths, schemas, and the environment
A successful build proves that generation completed; it does not prove that the correct endpoints are present. Normalize the JSON, list its paths, and assert the few environment boundaries that matter.
prod="$(find artifacts/openapi/production -name '*.json' -print -quit)"
stage="$(find artifacts/openapi/staging -name '*.json' -print -quit)"
test -n "$prod"
test -n "$stage"
jq -S . "$prod" > artifacts/openapi/production.normalized.json
jq -S . "$stage" > artifacts/openapi/staging.normalized.json
jq -r '.paths | keys[]' "$prod" > artifacts/openapi/production.paths
jq -r '.paths | keys[]' "$stage" > artifacts/openapi/staging.paths
if grep -Fxq '/_debug/config' artifacts/openapi/production.paths; then
echo "Development-only endpoint leaked into Production OpenAPI." >&2
exit 31
fi
diff -u artifacts/openapi/production.normalized.json artifacts/openapi/staging.normalized.json > artifacts/openapi/environment.diff || true
The final diff is evidence, not automatically a failure. Fail on differences your architecture says are forbidden: a diagnostic path in Production, a missing public route, a weaker security requirement, or an unexpected schema. Allow reviewed differences such as an environment-specific server URL only when the contract policy explicitly permits them.
- Assert that exactly the expected document files were generated.
- Normalize JSON before comparing it so formatting and property order do not create noise.
- Compare paths, operations, security requirements, and component schemas separately.
- Fail on sensitive or development-only operations in the Production document.
- Store the selected SDK, package lock, environment property, hashes, and diff with the build.
- Run the client generator only after the Production contract passes these checks.
If a transformer adds timestamps, build numbers, or machine-specific URLs, remove that volatility at the source or normalize the field deliberately. Ignoring the entire document because one field changes defeats the purpose of contract review.
Keep build-time startup safe
Document generation starts application code. Treat that startup path as untrusted automation: it must not mutate production systems, run migrations, publish messages, or require secrets that do not contribute to the API description.
- Database migrations: never run them as a side effect of host construction. Move them to an explicit deployment step.
- External clients: registration is safe; network calls during registration or singleton construction are not. Defer I/O until a request or an explicit startup task.
- Secrets: schema generation should not require live production credentials. A Production environment name is not permission to fetch or print them.
- Feature flags: if remote flags control endpoint discovery, capture the intended flag set or replace it with deterministic build configuration. Otherwise the same commit can generate different contracts minutes apart.
- Hosted services: make startup work cancellable and separate from endpoint registration. A background loop should not be necessary to describe an operation.
Do not add a broad “OpenAPI build mode” that changes application behavior just to make the generator pass. If build-time startup exposes architectural side effects, fix those boundaries. The generated document is valuable partly because it exercises the real endpoint and transformer configuration.
Roll out without breaking client generation
Introduce the property in a pull request that changes no public API intentionally. Generate the old and new contract from the same commit, explain every difference, and obtain API-owner approval before replacing the client-generation input.
# Example contract gate after the approved baseline is committed.
dotnet build src/Catalog.Api/Catalog.Api.csproj -c Release /p:OpenApiGenerationEnvironment=Production /p:OpenApiDocumentsDirectory="$PWD/artifacts/openapi/current"
current="$(find artifacts/openapi/current -name '*.json' -print -quit)"
jq -S . "$current" > artifacts/openapi/current.normalized.json
jq -S . contracts/catalog-api.json > artifacts/openapi/baseline.normalized.json
diff -u artifacts/openapi/baseline.normalized.json artifacts/openapi/current.normalized.json
A nonzero diff should open a review decision, not trigger an automatic baseline rewrite. Regenerating and committing the baseline in the same unattended job can hide a removed operation or weakened security requirement.
Rollback is straightforward: restore the previous approved contract and client artifacts, then revert the project or CI property change. Do not “fix” a surprising document by setting the environment back to Development unless Development is genuinely the contract consumers use. The environment name must reflect the artifact’s purpose.
The practical value of .NET 11 build-time OpenAPI is control. Pin the host environment, isolate outputs, assert sensitive boundaries, preserve the diff, and keep runtime verification. The new property removes ambient CI state from one important input; the pipeline still has to make the resulting contract accountable.
References
- ASP.NET Core in .NET 11 RC1 release notes: select an environment for build-time OpenAPI
- dotnet/aspnetcore PR #63856: environment support for API description document generation
Microsoft.Extensions.ApiDescription.Server.props: property definitions and output directoryMicrosoft.Extensions.ApiDescription.Server.targets: forwarding the environment todotnet-getdocument
Found this useful? Support more practical developer content.