From f8d4ee859a9cdb5da387996c6ad720fb717996a9 Mon Sep 17 00:00:00 2001 From: Indio Giles <33137578+rwsigiles@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:51:44 +0100 Subject: [PATCH 1/7] Add multitargetting support .NET 8, .NET 10 - Moved build props to root folder with which --- .github/workflows/build.yml | 6 +++-- Directory.Build.props | 24 +++++++++++++++++++ .../dotnet/Directory.Build.props | 1 + .../dotnet/build/build.proj | 1 + .../Tridion.Dxa.Framework.DataModel.csproj | 1 - .../dotnet/Directory.Build.props | 1 + dxa-framework-mvc-net/dotnet/build/build.proj | 1 + .../Tridion.Dxa.Framework.csproj | 17 +++++++------ .../dotnet/Directory.Build.props | 1 + dxa-module-core-net/dotnet/build/build.proj | 1 + .../Tridion.Dxa.Module.Core.csproj | 3 +-- .../dotnet/Directory.Build.props | 1 + .../dotnet/build/build.proj | 1 + ...ion.Dxa.Module.DynamicDocumentation.csproj | 3 +-- .../dotnet/Directory.Build.props | 1 + dxa-module-search-net/dotnet/build/build.proj | 1 + .../Tridion.Dxa.Module.Search.csproj | 3 +-- .../dotnet/Directory.Build.props | 1 + dxa-pca-client-net/dotnet/build/build.proj | 3 ++- .../Tridion.CodeGen/Tridion.CodeGen.csproj | 1 - .../Tridion.Dxa.Api.Client.csproj | 1 - .../dotnet/Directory.Build.props | 1 + .../Tridion.Dxa.Example.WebApp.csproj | 8 ++----- 23 files changed, 55 insertions(+), 27 deletions(-) create mode 100644 Directory.Build.props diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d0bac56..71efb75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,10 +16,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET 8 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: | + 8.0.x + 10.0.x - name: Setup MSBuild uses: microsoft/setup-msbuild@v2 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..864825f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,24 @@ + + + net8.0;net10.0 + + + 8.0.19 + 10.0.0 + + 8.0.0 + 10.0.0 + + 8.0.2 + 10.0.0 + + 8.0.1 + 10.0.0 + + 8.0.1 + 10.0.0 + + 8.0.0 + 10.0.0 + + diff --git a/dxa-framework-datamodel/dotnet/Directory.Build.props b/dxa-framework-datamodel/dotnet/Directory.Build.props index 977866a..49f969a 100644 --- a/dxa-framework-datamodel/dotnet/Directory.Build.props +++ b/dxa-framework-datamodel/dotnet/Directory.Build.props @@ -1,4 +1,5 @@ + 2.4.0 a-local-123456789012 diff --git a/dxa-framework-datamodel/dotnet/build/build.proj b/dxa-framework-datamodel/dotnet/build/build.proj index 83f5340..90a9758 100644 --- a/dxa-framework-datamodel/dotnet/build/build.proj +++ b/dxa-framework-datamodel/dotnet/build/build.proj @@ -75,6 +75,7 @@ + diff --git a/dxa-framework-datamodel/dotnet/src/Tridion.Dxa.Framework.DataModel/Tridion.Dxa.Framework.DataModel.csproj b/dxa-framework-datamodel/dotnet/src/Tridion.Dxa.Framework.DataModel/Tridion.Dxa.Framework.DataModel.csproj index f37cd41..a7c7e73 100644 --- a/dxa-framework-datamodel/dotnet/src/Tridion.Dxa.Framework.DataModel/Tridion.Dxa.Framework.DataModel.csproj +++ b/dxa-framework-datamodel/dotnet/src/Tridion.Dxa.Framework.DataModel/Tridion.Dxa.Framework.DataModel.csproj @@ -1,6 +1,5 @@  - net8.0 True ..\..\Shared\SigningServerTridionPublicKey.snk True diff --git a/dxa-framework-mvc-net/dotnet/Directory.Build.props b/dxa-framework-mvc-net/dotnet/Directory.Build.props index 977866a..49f969a 100644 --- a/dxa-framework-mvc-net/dotnet/Directory.Build.props +++ b/dxa-framework-mvc-net/dotnet/Directory.Build.props @@ -1,4 +1,5 @@ + 2.4.0 a-local-123456789012 diff --git a/dxa-framework-mvc-net/dotnet/build/build.proj b/dxa-framework-mvc-net/dotnet/build/build.proj index 8486046..ec9869e 100644 --- a/dxa-framework-mvc-net/dotnet/build/build.proj +++ b/dxa-framework-mvc-net/dotnet/build/build.proj @@ -75,6 +75,7 @@ + diff --git a/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj b/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj index 138a491..bf3696e 100644 --- a/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj +++ b/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj @@ -1,6 +1,5 @@  - net8.0 True ..\..\Shared\SigningServerTridionPublicKey.snk True @@ -19,16 +18,16 @@ - - - - - - - + + + + + + + - + diff --git a/dxa-module-core-net/dotnet/Directory.Build.props b/dxa-module-core-net/dotnet/Directory.Build.props index 977866a..49f969a 100644 --- a/dxa-module-core-net/dotnet/Directory.Build.props +++ b/dxa-module-core-net/dotnet/Directory.Build.props @@ -1,4 +1,5 @@ + 2.4.0 a-local-123456789012 diff --git a/dxa-module-core-net/dotnet/build/build.proj b/dxa-module-core-net/dotnet/build/build.proj index f0910bf..520b340 100644 --- a/dxa-module-core-net/dotnet/build/build.proj +++ b/dxa-module-core-net/dotnet/build/build.proj @@ -75,6 +75,7 @@ + diff --git a/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj b/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj index e08d3b2..eaeee80 100644 --- a/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj +++ b/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj @@ -1,6 +1,5 @@  - net8.0 true true true @@ -30,7 +29,7 @@ - + diff --git a/dxa-module-dynamicdocumentation-net/dotnet/Directory.Build.props b/dxa-module-dynamicdocumentation-net/dotnet/Directory.Build.props index 977866a..49f969a 100644 --- a/dxa-module-dynamicdocumentation-net/dotnet/Directory.Build.props +++ b/dxa-module-dynamicdocumentation-net/dotnet/Directory.Build.props @@ -1,4 +1,5 @@ + 2.4.0 a-local-123456789012 diff --git a/dxa-module-dynamicdocumentation-net/dotnet/build/build.proj b/dxa-module-dynamicdocumentation-net/dotnet/build/build.proj index db7aa57..0fc3b54 100644 --- a/dxa-module-dynamicdocumentation-net/dotnet/build/build.proj +++ b/dxa-module-dynamicdocumentation-net/dotnet/build/build.proj @@ -75,6 +75,7 @@ + diff --git a/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj b/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj index 3c0d99d..a2185f2 100644 --- a/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj +++ b/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj @@ -1,6 +1,5 @@  - net8.0 True ..\..\Shared\SigningServerTridionPublicKey.snk True @@ -27,7 +26,7 @@ - + diff --git a/dxa-module-search-net/dotnet/Directory.Build.props b/dxa-module-search-net/dotnet/Directory.Build.props index 977866a..49f969a 100644 --- a/dxa-module-search-net/dotnet/Directory.Build.props +++ b/dxa-module-search-net/dotnet/Directory.Build.props @@ -1,4 +1,5 @@ + 2.4.0 a-local-123456789012 diff --git a/dxa-module-search-net/dotnet/build/build.proj b/dxa-module-search-net/dotnet/build/build.proj index 8e52aed..7ba8c99 100644 --- a/dxa-module-search-net/dotnet/build/build.proj +++ b/dxa-module-search-net/dotnet/build/build.proj @@ -75,6 +75,7 @@ + diff --git a/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj b/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj index 61352d2..597a0a3 100644 --- a/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj +++ b/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj @@ -1,6 +1,5 @@  - net8.0 true true true @@ -31,7 +30,7 @@ - + diff --git a/dxa-pca-client-net/dotnet/Directory.Build.props b/dxa-pca-client-net/dotnet/Directory.Build.props index 977866a..49f969a 100644 --- a/dxa-pca-client-net/dotnet/Directory.Build.props +++ b/dxa-pca-client-net/dotnet/Directory.Build.props @@ -1,4 +1,5 @@ + 2.4.0 a-local-123456789012 diff --git a/dxa-pca-client-net/dotnet/build/build.proj b/dxa-pca-client-net/dotnet/build/build.proj index c45b3a1..c14c9f4 100644 --- a/dxa-pca-client-net/dotnet/build/build.proj +++ b/dxa-pca-client-net/dotnet/build/build.proj @@ -84,7 +84,8 @@ - + + diff --git a/dxa-pca-client-net/dotnet/src/Tridion.CodeGen/Tridion.CodeGen.csproj b/dxa-pca-client-net/dotnet/src/Tridion.CodeGen/Tridion.CodeGen.csproj index 7479d4a..b12103f 100644 --- a/dxa-pca-client-net/dotnet/src/Tridion.CodeGen/Tridion.CodeGen.csproj +++ b/dxa-pca-client-net/dotnet/src/Tridion.CodeGen/Tridion.CodeGen.csproj @@ -2,7 +2,6 @@ Exe - net8.0 True ..\..\Shared\SigningServerTridionPublicKey.snk True diff --git a/dxa-pca-client-net/dotnet/src/Tridion.Dxa.Api.Client/Tridion.Dxa.Api.Client.csproj b/dxa-pca-client-net/dotnet/src/Tridion.Dxa.Api.Client/Tridion.Dxa.Api.Client.csproj index 3b392a2..67cc1e2 100644 --- a/dxa-pca-client-net/dotnet/src/Tridion.Dxa.Api.Client/Tridion.Dxa.Api.Client.csproj +++ b/dxa-pca-client-net/dotnet/src/Tridion.Dxa.Api.Client/Tridion.Dxa.Api.Client.csproj @@ -1,6 +1,5 @@ - net8.0 True ..\..\Shared\SigningServerTridionPublicKey.snk True diff --git a/dxa-web-application-mvc-net/dotnet/Directory.Build.props b/dxa-web-application-mvc-net/dotnet/Directory.Build.props index ee00f69..95b2287 100644 --- a/dxa-web-application-mvc-net/dotnet/Directory.Build.props +++ b/dxa-web-application-mvc-net/dotnet/Directory.Build.props @@ -1,4 +1,5 @@ + 3.0.0 a-local-123456789012 diff --git a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj index df88984..4300dd2 100644 --- a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj +++ b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj @@ -1,11 +1,7 @@  - - net8.0 - - - - + + From 840031a130c52190c44fceafd4034fd282069d49 Mon Sep 17 00:00:00 2001 From: Indio Giles <33137578+rwsigiles@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:52:34 +0100 Subject: [PATCH 2/7] Update readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6aa2377..bbf129b 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Build status Prerequisites ------------- -For building .NET 8 repositories you must have the following installed: -- Visual Studio 2022 -- .NET 8 +For building .NET 8 and .NET 10 repositories you must have the following installed: +- Visual Studio 2022 or higher +- .NET 8 and .NET 10 SDKs Build (single component) ------------------------ From ee22d61f1e08ef502daa5cda515313d6b155d12d Mon Sep 17 00:00:00 2001 From: Indio Giles <33137578+rwsigiles@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:59:04 +0100 Subject: [PATCH 3/7] Bump version to 2.4.1 --- .github/workflows/build.yml | 2 +- README.md | 10 +++++----- Release-Dxa.ps1 | 12 ++++++------ dxa-framework-datamodel/dotnet/Directory.Build.props | 2 +- dxa-framework-mvc-net/dotnet/Directory.Build.props | 2 +- .../Tridion.Dxa.Framework.csproj | 4 ++-- dxa-module-core-net/dotnet/Directory.Build.props | 2 +- .../Tridion.Dxa.Module.Core.csproj | 2 +- .../dotnet/Directory.Build.props | 2 +- .../Tridion.Dxa.Module.DynamicDocumentation.csproj | 2 +- dxa-module-search-net/dotnet/Directory.Build.props | 2 +- .../Tridion.Dxa.Module.Search.csproj | 2 +- dxa-pca-client-net/dotnet/Directory.Build.props | 2 +- .../Tridion.Dxa.Example.WebApp.csproj | 2 +- 14 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71efb75..a664473 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: ${{ runner.os }}-nuget- # Builds every DXA component in dependency order (datamodel -> pca-client -> framework-mvc), - # packs clean 2.4.0 nupkgs, and primes the shared LocalNugetStorage so downstream restores + # packs clean 2.4.1 nupkgs, and primes the shared LocalNugetStorage so downstream restores # resolve locally. -SkipPush keeps it a CI build (no Nexus push). -SkipSign avoids the # signing step which needs a cert that isn't on CI runners. - name: Build & pack all DXA packages diff --git a/README.md b/README.md index bbf129b..ac5c8b4 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The `Release-Dxa.ps1` script at the repo root drives a full multi-package releas Each package step: - `msbuild build.proj /t:Build` (uses the existing per-component build target) - `msbuild build.proj /t:SignAssemblies` (skip with `-SkipSign` on machines without a code-signing cert) -- `dotnet pack` with `/p:VersionSuffix=""` to produce a clean stable `2.4.0.nupkg` (the default `SignPackAndPushNuGetPackages` target stamps a `beta-{timestamp}` suffix; we bypass it) +- `dotnet pack` with `/p:VersionSuffix=""` to produce a clean stable `2.4.1.nupkg` (the default `SignPackAndPushNuGetPackages` target stamps a `beta-{timestamp}` suffix; we bypass it) - `dotnet nuget push` to the target feed (skip with `-SkipPush`) - Copies the produced `.nupkg` into the shared `LocalNugetStorage/` at the repo root, and the next component's restore picks it up via `/p:RestoreAdditionalProjectSources` (no Nexus propagation delay) @@ -57,7 +57,7 @@ Each package step: | Parameter | Default | Purpose | |---|---|---| -| `-Version` | `2.4.0` | Stable version. Becomes `VersionPrefix`; `VersionSuffix` is forced empty. | +| `-Version` | `2.4.1` | Stable version. Becomes `VersionPrefix`; `VersionSuffix` is forced empty. | | `-NuGetSource` | Internal Nexus URL | Target feed for `dotnet nuget push`. | | `-ApiKey` | `(from build.proj)` | API key for push. | | `-SkipSign` | `false` | Skip `SignAssemblies` target. | @@ -67,12 +67,12 @@ Each package step: ### After a successful release -1. Smoke-test a clean restore in a downstream consumer (e.g. `dxa-web-application-mvc-net`) to confirm `2.4.0` resolves from the target feed. +1. Smoke-test a clean restore in a downstream consumer (e.g. `dxa-web-application-mvc-net`) to confirm `2.4.1` resolves from the target feed. 2. Commit the csproj reference bumps the script made: ``` git add -- '**/*.csproj' - git commit -m 'Release 2.4.0' - git tag v2.4.0 + git commit -m 'Release 2.4.1' + git tag v2.4.1 git push origin develop --tags ``` 3. Update release notes. diff --git a/Release-Dxa.ps1 b/Release-Dxa.ps1 index e782105..bc3f665 100644 --- a/Release-Dxa.ps1 +++ b/Release-Dxa.ps1 @@ -4,8 +4,8 @@ .DESCRIPTION Bypasses build.proj's SignPackAndPushNuGetPackages target (which auto-stamps a - beta-{timestamp} suffix) so we get clean, stable version packages - e.g. 2.4.0 - instead of 2.4.0-beta-20260514153012. + beta-{timestamp} suffix) so we get clean, stable version packages - e.g. 2.4.1 + instead of 2.4.1-beta-20260514153012. Release order: 1. Tridion.Dxa.Framework.DataModel (from dxa-framework-datamodel) @@ -17,7 +17,7 @@ Each repo: msbuild /t:Build -> msbuild /t:SignAssemblies -> dotnet pack -> dotnet nuget push. .PARAMETER Version - Stable version, default 2.4.0. Goes into VersionPrefix; VersionSuffix is forced empty. + Stable version, default 2.4.1. Goes into VersionPrefix; VersionSuffix is forced empty. .PARAMETER NuGetSource Target NuGet feed URL. Defaults to internal Nexus. @@ -47,11 +47,11 @@ .EXAMPLE .\Release-Dxa.ps1 - Full release of 2.4.0 to internal Nexus, with confirmation between stages. + Full release of 2.4.1 to internal Nexus, with confirmation between stages. #> [CmdletBinding()] param( - [string]$Version = "2.4.0", + [string]$Version = "2.4.1", [string]$NuGetSource = "https://nexus.sdl.com/service/local/nuget/releases_dotnet/", [string]$ApiKey = "fed9a610-8898-3986-877e-1001ba1f858d", [switch]$SkipSign, @@ -195,7 +195,7 @@ function Release-Package($release) { # 2. Build (uses existing build.proj target - produces a-local-... nupkg for sanity). # /p:RestoreAdditionalProjectSources points NuGet at our shared LocalNugetStorage so - # refs to freshly-published 2.4.0 packages (datamodel, pca-client) resolve locally. + # refs to freshly-published 2.4.1 packages (datamodel, pca-client) resolve locally. # /p:RestoreForce=true /p:RestoreNoHttpCache=true bypasses NuGet's HTTP cache from any earlier failed restore. Invoke-Cmd "msbuild build.proj /t:Build /p:BuildConfiguration=Release /p:VersionPrefix=$Version /p:RestoreAdditionalProjectSources=`"$SharedLocalStorage`" /p:RestoreForce=true /p:RestoreNoHttpCache=true" $buildDir diff --git a/dxa-framework-datamodel/dotnet/Directory.Build.props b/dxa-framework-datamodel/dotnet/Directory.Build.props index 49f969a..57495ab 100644 --- a/dxa-framework-datamodel/dotnet/Directory.Build.props +++ b/dxa-framework-datamodel/dotnet/Directory.Build.props @@ -1,7 +1,7 @@ - 2.4.0 + 2.4.1 a-local-123456789012 diff --git a/dxa-framework-mvc-net/dotnet/Directory.Build.props b/dxa-framework-mvc-net/dotnet/Directory.Build.props index 49f969a..57495ab 100644 --- a/dxa-framework-mvc-net/dotnet/Directory.Build.props +++ b/dxa-framework-mvc-net/dotnet/Directory.Build.props @@ -1,7 +1,7 @@ - 2.4.0 + 2.4.1 a-local-123456789012 diff --git a/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj b/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj index bf3696e..4e76b1b 100644 --- a/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj +++ b/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj @@ -28,8 +28,8 @@ - - + + diff --git a/dxa-module-core-net/dotnet/Directory.Build.props b/dxa-module-core-net/dotnet/Directory.Build.props index 49f969a..57495ab 100644 --- a/dxa-module-core-net/dotnet/Directory.Build.props +++ b/dxa-module-core-net/dotnet/Directory.Build.props @@ -1,7 +1,7 @@ - 2.4.0 + 2.4.1 a-local-123456789012 diff --git a/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj b/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj index eaeee80..2dfb799 100644 --- a/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj +++ b/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj @@ -30,7 +30,7 @@ - + diff --git a/dxa-module-dynamicdocumentation-net/dotnet/Directory.Build.props b/dxa-module-dynamicdocumentation-net/dotnet/Directory.Build.props index 49f969a..57495ab 100644 --- a/dxa-module-dynamicdocumentation-net/dotnet/Directory.Build.props +++ b/dxa-module-dynamicdocumentation-net/dotnet/Directory.Build.props @@ -1,7 +1,7 @@ - 2.4.0 + 2.4.1 a-local-123456789012 diff --git a/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj b/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj index a2185f2..b7636f4 100644 --- a/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj +++ b/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj @@ -27,7 +27,7 @@ - + diff --git a/dxa-module-search-net/dotnet/Directory.Build.props b/dxa-module-search-net/dotnet/Directory.Build.props index 49f969a..57495ab 100644 --- a/dxa-module-search-net/dotnet/Directory.Build.props +++ b/dxa-module-search-net/dotnet/Directory.Build.props @@ -1,7 +1,7 @@ - 2.4.0 + 2.4.1 a-local-123456789012 diff --git a/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj b/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj index 597a0a3..3c60374 100644 --- a/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj +++ b/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj @@ -31,7 +31,7 @@ - + diff --git a/dxa-pca-client-net/dotnet/Directory.Build.props b/dxa-pca-client-net/dotnet/Directory.Build.props index 49f969a..57495ab 100644 --- a/dxa-pca-client-net/dotnet/Directory.Build.props +++ b/dxa-pca-client-net/dotnet/Directory.Build.props @@ -1,7 +1,7 @@ - 2.4.0 + 2.4.1 a-local-123456789012 diff --git a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj index 4300dd2..fe64b22 100644 --- a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj +++ b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj @@ -5,7 +5,7 @@ - + From e9cca41ca2d8b197bea476260c8f8898ef489148 Mon Sep 17 00:00:00 2001 From: Indio Giles <33137578+rwsigiles@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:08:59 +0100 Subject: [PATCH 4/7] Reverted WebApp target to net8.0 (he Dockerfile and publish profile remain aligned with net8.0) --- .../Tridion.Dxa.Example.WebApp.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj index fe64b22..de1949b 100644 --- a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj +++ b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj @@ -1,4 +1,8 @@  + + net8.0 + + From 8e4554a568ce2973af6f3a03cbc14624accf9181 Mon Sep 17 00:00:00 2001 From: Indio Giles <33137578+rwsigiles@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:08:21 +0100 Subject: [PATCH 5/7] Add net10 deploy CI coverage and Nexus-only preview NuGet releases Extend Release-Dxa.ps1 with -Preview to pack 2.4.1-preview-{timestamp} packages and push them to internal Nexus only (nuget.org refused). Use dotnet msbuild so net10 multi-targeting restores under the correct SDK. Build/pack modules in the release pipeline, prove WebApp publish for net8/net10 in CI, and add net10 Docker/ARM/publish-profile assets plus consumer migration docs in the README. --- .dockerignore | 16 +++ .github/workflows/build.yml | 30 ++++- Directory.Build.props | 12 +- README.md | 89 +++++++++++++- Release-Dxa.ps1 | 112 +++++++++++++----- dxa-framework-mvc-net/README.md | 6 +- .../Tridion.Dxa.Framework.csproj | 4 +- .../Tridion.Dxa.Module.Core.csproj | 2 +- ...ion.Dxa.Module.DynamicDocumentation.csproj | 2 +- .../Tridion.Dxa.Module.Search.csproj | 2 +- .../ARM.Dockerfile.net10.0 | 18 +++ .../src/Tridion.Dxa.Example.WebApp/Dockerfile | 27 +++-- .../Dockerfile.net10.0 | 52 ++++++++ .../FolderProfile.net10.0.pubxml | 21 ++++ .../Tridion.Dxa.Example.WebApp.csproj | 4 +- 15 files changed, 338 insertions(+), 59 deletions(-) create mode 100644 .dockerignore create mode 100644 dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ARM.Dockerfile.net10.0 create mode 100644 dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile.net10.0 create mode 100644 dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Properties/PublishProfiles/FolderProfile.net10.0.pubxml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..21d1eb8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +**/.vs +**/bin +**/obj +**/*.user +**/*.suo + +# Components not needed for Example.WebApp image +dxa-framework-datamodel +dxa-framework-mvc-net +dxa-module-dynamicdocumentation-net +dxa-module-search-net +dxa-pca-client-net + +# Keep LocalNugetStorage, Directory.Build.props, dxa-web-application-mvc-net, dxa-module-core-net diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a664473..f29a1c1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,10 +34,38 @@ jobs: restore-keys: | ${{ runner.os }}-nuget- - # Builds every DXA component in dependency order (datamodel -> pca-client -> framework-mvc), + # Builds every DXA component in dependency order (datamodel -> pca-client -> framework-mvc -> modules), # packs clean 2.4.1 nupkgs, and primes the shared LocalNugetStorage so downstream restores # resolve locally. -SkipPush keeps it a CI build (no Nexus push). -SkipSign avoids the # signing step which needs a cert that isn't on CI runners. - name: Build & pack all DXA packages shell: pwsh run: .\Release-Dxa.ps1 -SkipPush -SkipSign -NonInteractive + + # Prove modules + Example WebApp compile for both net8.0 and net10.0 against the packed packages. + - name: Build modules and Example WebApp + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $restoreSource = Join-Path $PWD 'LocalNugetStorage' + $projects = @( + 'dxa-module-core-net\dotnet\src\Tridion.Dxa.Module.Core\Tridion.Dxa.Module.Core.csproj', + 'dxa-module-search-net\dotnet\src\Tridion.Dxa.Module.Search\Tridion.Dxa.Module.Search.csproj', + 'dxa-module-dynamicdocumentation-net\dotnet\src\Tridion.Dxa.Module.DynamicDocumentation\Tridion.Dxa.Module.DynamicDocumentation.csproj', + 'dxa-web-application-mvc-net\dotnet\src\Tridion.Dxa.Example.WebApp\Tridion.Dxa.Example.WebApp.csproj' + ) + foreach ($p in $projects) { + Write-Host "==== Building $p ====" -ForegroundColor Cyan + dotnet build $p -c Release /p:RestoreAdditionalProjectSources="$restoreSource" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + + - name: Publish Example WebApp (net8.0 and net10.0) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $webapp = 'dxa-web-application-mvc-net\dotnet\src\Tridion.Dxa.Example.WebApp\Tridion.Dxa.Example.WebApp.csproj' + dotnet publish $webapp -c Release -f net8.0 -o artifacts/webapp-net8 --no-build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet publish $webapp -c Release -f net10.0 -o artifacts/webapp-net10 --no-build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/Directory.Build.props b/Directory.Build.props index 864825f..c7e6042 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,21 +4,21 @@ 8.0.19 - 10.0.0 + 10.0.11 8.0.0 - 10.0.0 + 10.0.11 8.0.2 - 10.0.0 + 10.0.11 8.0.1 - 10.0.0 + 10.0.11 8.0.1 - 10.0.0 + 10.0.11 8.0.0 - 10.0.0 + 10.0.11 diff --git a/README.md b/README.md index ac5c8b4..d6484c9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ The `Release-Dxa.ps1` script at the repo root drives a full multi-package releas 1. **Tridion.Dxa.Framework.DataModel** (from `dxa-framework-datamodel`) 2. **Tridion.Dxa.Api.Client** (from `dxa-pca-client-net`) 3. **Tridion.Dxa.Framework** (from `dxa-framework-mvc-net`) — package refs for #1 and #2 are auto-bumped before this builds. -4. Bumps the `Tridion.Dxa.Framework` `` in each consumer csproj: `dxa-module-core-net`, `dxa-module-dynamicdocumentation-net`, `dxa-module-search-net`, `dxa-web-application-mvc-net`. +4. **Tridion.Dxa.Module.Core**, **Tridion.Dxa.Module.Search**, **Tridion.Dxa.Module.DynamicDocumentation** — Framework package refs are auto-bumped before each module builds. +5. Bumps the `Tridion.Dxa.Framework` `` in the Example WebApp (`dxa-web-application-mvc-net`). Each package step: - `msbuild build.proj /t:Build` (uses the existing per-component build target) @@ -46,10 +47,13 @@ Each package step: # Local smoke test: builds & packs all components, doesn't push to any feed. .\Release-Dxa.ps1 -SkipPush -# Full release to internal Nexus. +# Full stable release of 2.4.1 to internal Nexus. .\Release-Dxa.ps1 -# After Nexus has been verified, re-publish to public NuGet.org. +# Preview release to internal Nexus only (e.g. 2.4.1-preview-20260821122600). +.\Release-Dxa.ps1 -Preview -NonInteractive + +# After Nexus has been verified, re-publish a stable build to public NuGet.org. .\Release-Dxa.ps1 -NuGetSource https://api.nuget.org/v3/index.json -ApiKey ``` @@ -57,7 +61,8 @@ Each package step: | Parameter | Default | Purpose | |---|---|---| -| `-Version` | `2.4.1` | Stable version. Becomes `VersionPrefix`; `VersionSuffix` is forced empty. | +| `-Version` | `2.4.1` | Version prefix (`VersionPrefix`). Stable packs with empty suffix; `-Preview` appends `preview-{timestamp}`. | +| `-Preview` | `false` | Pack/push `{Version}-preview-{yyyyMMddHHmmss}` to Nexus only (refuses nuget.org). | | `-NuGetSource` | Internal Nexus URL | Target feed for `dotnet nuget push`. | | `-ApiKey` | `(from build.proj)` | API key for push. | | `-SkipSign` | `false` | Skip `SignAssemblies` target. | @@ -77,6 +82,82 @@ Each package step: ``` 3. Update release notes. +Docker (Example WebApp) +----------------------- +The Example WebApp multi-targets `net8.0` and `net10.0`. Build **one image per TFM** from the repo root (after packing packages into `LocalNugetStorage/` via `.\Release-Dxa.ps1 -SkipPush -SkipSign -NonInteractive`): + +```powershell +# .NET 8 +docker build -f dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile -t dxa-example-webapp:net8 . + +# .NET 10 +docker build -f dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile.net10.0 -t dxa-example-webapp:net10 . +``` + +ARM runtime images (publish output copied in separately): + +- `ARM.Dockerfile` — ASP.NET 8.0 Alpine arm64v8 +- `ARM.Dockerfile.net10.0` — ASP.NET 10.0 Alpine arm64v8 + +Folder publish profiles: + +- `Properties/PublishProfiles/FolderProfile.pubxml` → `net8.0` +- `Properties/PublishProfiles/FolderProfile.net10.0.pubxml` → `net10.0` + +Migrating from .NET 8 to .NET 10 +------------------------------- + +### For consumers using published NuGet packages + +Use this path if your site references `Tridion.Dxa.*` from Nexus or NuGet.org and you do not build DXA from this repository. + +**Prerequisites** +- Install the [.NET 10 SDK](https://dotnet.microsoft.com/download) for builds; install the ASP.NET Core 10 runtime on deploy hosts. +- Use DXA packages **2.4.1 or later**. Those packages multi-target `net8.0` and `net10.0` (`lib/net8.0` and `lib/net10.0`). Packages built only for `net8.0` will not provide a net10 asset. + +**Steps** +1. In your web application `.csproj`, change the target framework to .NET 10: + ```xml + net10.0 + ``` + (Or multi-target `net8.0;net10.0` if you must support both during a transition.) +2. Update all `Tridion.Dxa.*` package references to **2.4.1+**, for example: + ```xml + + + ``` + Repeat for any other DXA modules you use (`Module.Search`, `Module.DynamicDocumentation`, etc.). Prefer: + ```powershell + dotnet add package Tridion.Dxa.Framework --version 2.4.1 + ``` +3. Bump Microsoft ASP.NET Core / Extensions packages your app references to **10.0.x** (match the shared framework). Do not keep AspNetCore **8.0.x** packages on a `net10.0` app. +4. Restore, build, and publish for net10: + ```powershell + dotnet restore + dotnet build -c Release -f net10.0 + dotnet publish -c Release -f net10.0 -o ./publish + ``` +5. Deploy onto hosts or containers that run the **ASP.NET Core 10** runtime (not the .NET 8 runtime image). + +NuGet resolves `lib/net10.0` from the DXA packages automatically when your project targets `net10.0`—no extra package IDs or TFM suffixes are required. + +**Smoke checklist** +- Restore succeeds against your feed (Nexus / NuGet.org) +- App builds and starts on ASP.NET Core 10 +- Pages render; DXA modules initialize as before + +**Known warnings (non-blocking)** +Building against net10 may surface ASP.NET deprecation warnings (for example `ASPDEPR003` Razor runtime compilation, `ASPDEPR005` `KnownNetworks`, `ASPDEPR006` `IActionContextAccessor`). They do not fail the build unless warnings-as-errors is enabled. + +### Building or deploying this repository + +For people working in `dxa-core` itself (not required for NuGet-only consumers): + +- Folder publish: `-f net10.0` or `FolderProfile.net10.0.pubxml` +- x64 Docker: `Dockerfile.net10.0` +- ARM: `ARM.Dockerfile.net10.0` +- Shared Microsoft package bands for net10 live in the root `Directory.Build.props` + About ----- The RWS Digital Experience Accelerator (DXA) is a reference implementation of RWS Tridion Sites 10+ intended to help you create, design and publish an RWS Tridion/Web-based website quickly. diff --git a/Release-Dxa.ps1 b/Release-Dxa.ps1 index bc3f665..b3b440b 100644 --- a/Release-Dxa.ps1 +++ b/Release-Dxa.ps1 @@ -7,17 +7,23 @@ beta-{timestamp} suffix) so we get clean, stable version packages - e.g. 2.4.1 instead of 2.4.1-beta-20260514153012. + With -Preview, packs prerelease packages as 2.4.1-preview-{yyyyMMddHHmmss} and + pushes them to internal Nexus only (nuget.org is refused). + Release order: 1. Tridion.Dxa.Framework.DataModel (from dxa-framework-datamodel) 2. Tridion.Dxa.Api.Client (from dxa-pca-client-net) 3. (update Tridion.Dxa.Framework.csproj package refs to new version) 4. Tridion.Dxa.Framework (from dxa-framework-mvc-net) - 5. (update consumer projects' refs - modules + web app - to new version) + 5. Tridion.Dxa.Module.Core / Search / DynamicDocumentation + (Framework package refs auto-bumped before each module builds) + 6. (update Example WebApp Framework package ref to new version) Each repo: msbuild /t:Build -> msbuild /t:SignAssemblies -> dotnet pack -> dotnet nuget push. .PARAMETER Version - Stable version, default 2.4.1. Goes into VersionPrefix; VersionSuffix is forced empty. + Version prefix, default 2.4.1. Becomes VersionPrefix. For stable releases VersionSuffix + is forced empty; with -Preview a preview-{timestamp} suffix is appended. .PARAMETER NuGetSource Target NuGet feed URL. Defaults to internal Nexus. @@ -37,6 +43,9 @@ .PARAMETER NonInteractive Skip the per-stage confirmation prompts. +.PARAMETER Preview + Pack and push prerelease packages as {Version}-preview-{yyyyMMddHHmmss} to Nexus only. + .EXAMPLE .\Release-Dxa.ps1 -DryRun Show every command without running anything. @@ -48,6 +57,10 @@ .EXAMPLE .\Release-Dxa.ps1 Full release of 2.4.1 to internal Nexus, with confirmation between stages. + +.EXAMPLE + .\Release-Dxa.ps1 -Preview -NonInteractive + Pack 2.4.1-preview-{timestamp} packages and push to internal Nexus only. #> [CmdletBinding()] param( @@ -57,7 +70,8 @@ param( [switch]$SkipSign, [switch]$SkipPush, [switch]$DryRun, - [switch]$NonInteractive + [switch]$NonInteractive, + [switch]$Preview ) $ErrorActionPreference = 'Stop' @@ -66,6 +80,18 @@ Set-StrictMode -Version Latest $RepoRoot = $PSScriptRoot if (-not $RepoRoot) { $RepoRoot = (Get-Location).Path } +if ($Preview -and ($NuGetSource -match 'nuget\.org')) { + throw "-Preview pushes are Nexus-only. Do not combine -Preview with a nuget.org NuGetSource." +} + +if ($Preview) { + $VersionSuffix = "preview-$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))" + $PackageVersion = "$Version-$VersionSuffix" +} else { + $VersionSuffix = "" + $PackageVersion = $Version +} + # ---- Release manifest ------------------------------------------------------- # Each entry describes one publishable package and how to build it. $releases = @( @@ -94,14 +120,35 @@ $releases = @( 'Tridion.Dxa.Framework.DataModel', 'Tridion.Dxa.Api.Client' ) + }, + @{ + Package = 'Tridion.Dxa.Module.Core' + RepoDir = 'dxa-module-core-net' + SolutionPath = 'dotnet\Tridion.Dxa.Module.Core.sln' + ProjectPath = 'dotnet\src\Tridion.Dxa.Module.Core\Tridion.Dxa.Module.Core.csproj' + BuildProj = 'dotnet\build\build.proj' + UpdateRefsBefore = @('Tridion.Dxa.Framework') + }, + @{ + Package = 'Tridion.Dxa.Module.Search' + RepoDir = 'dxa-module-search-net' + SolutionPath = 'dotnet\Tridion.Dxa.Module.Search.sln' + ProjectPath = 'dotnet\src\Tridion.Dxa.Module.Search\Tridion.Dxa.Module.Search.csproj' + BuildProj = 'dotnet\build\build.proj' + UpdateRefsBefore = @('Tridion.Dxa.Framework') + }, + @{ + Package = 'Tridion.Dxa.Module.DynamicDocumentation' + RepoDir = 'dxa-module-dynamicdocumentation-net' + SolutionPath = 'dotnet\Tridion.Dxa.Module.DynamicDocumentation.sln' + ProjectPath = 'dotnet\src\Tridion.Dxa.Module.DynamicDocumentation\Tridion.Dxa.Module.DynamicDocumentation.csproj' + BuildProj = 'dotnet\build\build.proj' + UpdateRefsBefore = @('Tridion.Dxa.Framework') } ) -# Consumer projects (modules + web app) - refs updated AFTER Tridion.Dxa.Framework is published. +# Example WebApp - Framework ref updated AFTER all packages (including modules) are published. $consumerProjects = @( - 'dxa-module-core-net\dotnet\src\Tridion.Dxa.Module.Core\Tridion.Dxa.Module.Core.csproj', - 'dxa-module-dynamicdocumentation-net\dotnet\src\Tridion.Dxa.Module.DynamicDocumentation\Tridion.Dxa.Module.DynamicDocumentation.csproj', - 'dxa-module-search-net\dotnet\src\Tridion.Dxa.Module.Search\Tridion.Dxa.Module.Search.csproj', 'dxa-web-application-mvc-net\dotnet\src\Tridion.Dxa.Example.WebApp\Tridion.Dxa.Example.WebApp.csproj' ) @@ -175,41 +222,41 @@ function Release-Package($release) { $repo = Join-Path $RepoRoot $release.RepoDir $buildDir = Join-Path $repo (Split-Path $release.BuildProj) $slnPath = Join-Path $repo $release.SolutionPath - $nupkgPath = Get-NupkgPath $release $Version + $nupkgPath = Get-NupkgPath $release $PackageVersion if (-not (Test-Path $repo)) { throw "Repo dir not found: $repo" } if (-not (Test-Path $slnPath)) { throw "Solution not found: $slnPath" } if (-not (Test-Path $buildDir)) { throw "Build dir not found: $buildDir" } - Write-Stage "Releasing $($release.Package) $Version (from $($release.RepoDir))" + Write-Stage "Releasing $($release.Package) $PackageVersion (from $($release.RepoDir))" - # 1. Bump references this project consumes (only relevant for framework-mvc). + # 1. Bump references this project consumes (only relevant for framework-mvc / modules). if ($release.ContainsKey('UpdateRefsBefore')) { - Write-Host "Updating package references in $($release.ProjectPath) -> $Version" -ForegroundColor Yellow + Write-Host "Updating package references in $($release.ProjectPath) -> $PackageVersion" -ForegroundColor Yellow foreach ($depPkg in $release.UpdateRefsBefore) { Update-PackageRef -csprojRelPath (Join-Path $release.RepoDir $release.ProjectPath) ` -packageId $depPkg ` - -version $Version + -version $PackageVersion } } # 2. Build (uses existing build.proj target - produces a-local-... nupkg for sanity). # /p:RestoreAdditionalProjectSources points NuGet at our shared LocalNugetStorage so - # refs to freshly-published 2.4.1 packages (datamodel, pca-client) resolve locally. + # refs to freshly-published packages resolve locally. # /p:RestoreForce=true /p:RestoreNoHttpCache=true bypasses NuGet's HTTP cache from any earlier failed restore. - Invoke-Cmd "msbuild build.proj /t:Build /p:BuildConfiguration=Release /p:VersionPrefix=$Version /p:RestoreAdditionalProjectSources=`"$SharedLocalStorage`" /p:RestoreForce=true /p:RestoreNoHttpCache=true" $buildDir + Invoke-Cmd "dotnet msbuild build.proj /t:Build /p:BuildConfiguration=Release /p:VersionPrefix=$Version /p:RestoreAdditionalProjectSources=`"$SharedLocalStorage`" /p:RestoreForce=true /p:RestoreNoHttpCache=true" $buildDir # 3. Sign the built assemblies (uses the existing target). if (-not $SkipSign) { - Invoke-Cmd "msbuild build.proj /t:SignAssemblies /p:BuildConfiguration=Release" $buildDir + Invoke-Cmd "dotnet msbuild build.proj /t:SignAssemblies /p:BuildConfiguration=Release" $buildDir } else { Write-Host "(SignAssemblies skipped)" -ForegroundColor Yellow } - # 4. Pack with an EMPTY VersionSuffix to produce the clean stable nupkg. - # --no-build reuses what we just compiled. /p:VersionSuffix="" overrides the - # default in Directory.Build.props. - Invoke-Cmd "dotnet pack `"$slnPath`" --configuration Release --no-build /p:VersionSuffix=`"`" /p:VersionPrefix=$Version /p:RestoreAdditionalProjectSources=`"$SharedLocalStorage`"" $repo + # 4. Pack: empty VersionSuffix for stable; preview-{timestamp} for -Preview. + # --no-build reuses what we just compiled. Overrides Directory.Build.props defaults. + $suffixArg = if ($null -eq $VersionSuffix -or $VersionSuffix -eq '') { '""' } else { "`"$VersionSuffix`"" } + Invoke-Cmd "dotnet pack `"$slnPath`" --configuration Release --no-build /p:VersionSuffix=$suffixArg /p:VersionPrefix=$Version /p:RestoreAdditionalProjectSources=`"$SharedLocalStorage`"" $repo # 5. Verify the package exists where we expect it. if (-not $DryRun) { @@ -219,7 +266,7 @@ function Release-Package($release) { Write-Host "Produced: $nupkgPath" -ForegroundColor Green } - # 5b. Copy the clean nupkg into the shared LocalNugetStorage so the next project's + # 5b. Copy the nupkg into the shared LocalNugetStorage so the next project's # restore picks it up via /p:RestoreAdditionalProjectSources (works fully offline with # -SkipPush, and avoids latency-induced "package not found" on real Nexus runs). Publish-LocalNupkg $nupkgPath @@ -231,16 +278,21 @@ function Release-Package($release) { Write-Host "(Push skipped)" -ForegroundColor Yellow } - Confirm-Continue "Released $($release.Package) $Version. Continue?" + Confirm-Continue "Released $($release.Package) $PackageVersion. Continue?" } # ---- Main flow -------------------------------------------------------------- -Write-Stage "DXA $Version release" +$releaseKind = if ($Preview) { "preview" } else { "stable" } +Write-Stage "DXA $PackageVersion $releaseKind release" Write-Host "Repo root : $RepoRoot" +Write-Host "Package version: $PackageVersion" +Write-Host "Version prefix : $Version" +Write-Host "Version suffix : $(if ($VersionSuffix) { $VersionSuffix } else { '(none)' })" Write-Host "NuGet source : $NuGetSource" Write-Host "Dry run : $DryRun" Write-Host "Skip sign : $SkipSign" Write-Host "Skip push : $SkipPush" +Write-Host "Preview : $Preview" Write-Host "Non-interactive: $NonInteractive" # Pre-create the shared LocalNugetStorage so the very first msbuild restore (which has @@ -251,29 +303,29 @@ if (-not (Test-Path $SharedLocalStorage)) { if (-not $DryRun) { New-Item -ItemType Directory -Path $SharedLocalStorage -Force | Out-Null } } -Confirm-Continue "Proceed with release of ${Version}?" +Confirm-Continue "Proceed with release of ${PackageVersion}?" foreach ($r in $releases) { Release-Package $r } -# Update consumer projects to reference the new Tridion.Dxa.Framework -Write-Stage "Updating consumer projects to Tridion.Dxa.Framework $Version" +# Update Example WebApp to reference the new Tridion.Dxa.Framework +Write-Stage "Updating Example WebApp to Tridion.Dxa.Framework $PackageVersion" foreach ($csproj in $consumerProjects) { - Update-PackageRef -csprojRelPath $csproj -packageId 'Tridion.Dxa.Framework' -version $Version + Update-PackageRef -csprojRelPath $csproj -packageId 'Tridion.Dxa.Framework' -version $PackageVersion } Write-Stage "Release complete" Write-Host @" Manual follow-ups: - 1. Smoke-test a clean restore of a consumer project to confirm $Version resolves from Nexus. + 1. Smoke-test a clean restore of a consumer project to confirm $PackageVersion resolves from Nexus. dotnet restore (in dxa-web-application-mvc-net\dotnet\src\Tridion.Dxa.Example.WebApp) - 2. If pushing to public NuGet.org, re-run with: + 2. If pushing a stable release to public NuGet.org, re-run without -Preview: .\Release-Dxa.ps1 -NuGetSource https://api.nuget.org/v3/index.json -ApiKey 3. Commit the csproj reference bumps: git -C add -- '*.csproj' - git -C commit -m 'Release $Version' - git -C tag 'v$Version' + git -C commit -m 'Release $PackageVersion' + git -C tag 'v$PackageVersion' git -C push origin develop --tags 4. Update release notes in each repo. "@ -ForegroundColor Cyan diff --git a/dxa-framework-mvc-net/README.md b/dxa-framework-mvc-net/README.md index 404cee6..9816ff9 100644 --- a/dxa-framework-mvc-net/README.md +++ b/dxa-framework-mvc-net/README.md @@ -4,8 +4,8 @@ DXA ASP.NET Core MVC Framework Prerequisites ------------- For building .NET repositories you must have the following installed: -- Visual Studio 2022 -- .NET Framework .Net Core 8.0 +- Visual Studio 2022 or higher +- .NET 8 and .NET 10 SDKs Build ----- @@ -18,4 +18,4 @@ dotnet add package Tridion.Dxa.Framework --version 1.0.0-beta1 --source C:\Local .\NuGet.exe setapikey fed9a610-8898-3986-877e-1001ba1f858d -source https://nexus.sdl.com/repository/releases_dotnet/ dotnet add package Tridion.Dxa.Api.Client --version 3.0.0-beta-20250402231954 --source https://nexus.sdl.com/service/local/nuget/releases_dotnet/ dotnet add package Tridion.Dxa.Framework.DataModel --version 3.0.0-beta-20250402224149 --source https://nexus.sdl.com/service/local/nuget/releases_dotnet/ -``` \ No newline at end of file +``` diff --git a/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj b/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj index 4e76b1b..4cdb484 100644 --- a/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj +++ b/dxa-framework-mvc-net/dotnet/src/Tridion.Dxa.Framework/Tridion.Dxa.Framework.csproj @@ -28,8 +28,8 @@ - - + + diff --git a/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj b/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj index 2dfb799..601c8f6 100644 --- a/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj +++ b/dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/Tridion.Dxa.Module.Core.csproj @@ -30,7 +30,7 @@ - + diff --git a/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj b/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj index b7636f4..882085e 100644 --- a/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj +++ b/dxa-module-dynamicdocumentation-net/dotnet/src/Tridion.Dxa.Module.DynamicDocumentation/Tridion.Dxa.Module.DynamicDocumentation.csproj @@ -27,7 +27,7 @@ - + diff --git a/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj b/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj index 3c60374..4f59f6b 100644 --- a/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj +++ b/dxa-module-search-net/dotnet/src/Tridion.Dxa.Module.Search/Tridion.Dxa.Module.Search.csproj @@ -31,7 +31,7 @@ - + diff --git a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ARM.Dockerfile.net10.0 b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ARM.Dockerfile.net10.0 new file mode 100644 index 0000000..6ceeb81 --- /dev/null +++ b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ARM.Dockerfile.net10.0 @@ -0,0 +1,18 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-alpine3.23-arm64v8 + +ARG source +WORKDIR /app +EXPOSE 80 +COPY ${source:-obj/Docker/publishARM} . + +RUN apk update && \ + apk upgrade && \ + apk add netcat; \ + apk add icu-libs; \ + apk add icu-data-full; \ + chmod +x ./containerBootstrap.sh + +# Disable the invariant mode (set in base image) +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false + +ENTRYPOINT ./containerBootstrap.sh diff --git a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile index d23b357..8862ea7 100644 --- a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile +++ b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile @@ -1,3 +1,6 @@ +# Build from repo root: +# docker build -f dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile -t dxa-example-webapp:net8 . + # Use official ASP.NET Core runtime as base image FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app @@ -14,20 +17,28 @@ RUN dotnet nuget add source https://nexus.sdl.com/repository/releases_dotnet/ind RUN dotnet nuget locals all --clear -COPY ["Tridion.Dxa.Example.WebApp.csproj", "./"] - -RUN dotnet restore Tridion.Dxa.Example.WebApp.csproj \ - --source https://api.nuget.org/v3/index.json \ - --source https://nexus.sdl.com/repository/releases_dotnet/index.json +COPY Directory.Build.props ./ +COPY dxa-web-application-mvc-net/dotnet/Directory.Build.props ./dxa-web-application-mvc-net/dotnet/ +COPY dxa-module-core-net/dotnet/Directory.Build.props ./dxa-module-core-net/dotnet/ +COPY dxa-module-core-net/dotnet/shared/ ./dxa-module-core-net/dotnet/Shared/ +COPY dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/ ./dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/ +COPY dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ ./dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ +COPY LocalNugetStorage/ ./LocalNugetStorage/ +ENV WEBAPP_CSPROJ=dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj -COPY . . +RUN dotnet restore "$WEBAPP_CSPROJ" \ + -p:TargetFrameworks=net8.0 \ + --source https://api.nuget.org/v3/index.json \ + --source https://nexus.sdl.com/repository/releases_dotnet/index.json \ + --source /src/LocalNugetStorage RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates && \ - dotnet dev-certs https --trust + (dotnet dev-certs https --trust || true) -RUN dotnet publish "Tridion.Dxa.Example.WebApp.csproj" -c Release -o /app/publish +RUN dotnet publish "$WEBAPP_CSPROJ" -c Release -f net8.0 -o /app/publish \ + --no-restore # Final stage FROM base AS final diff --git a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile.net10.0 b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile.net10.0 new file mode 100644 index 0000000..a4b82c0 --- /dev/null +++ b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile.net10.0 @@ -0,0 +1,52 @@ +# Build from repo root: +# docker build -f dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Dockerfile.net10.0 -t dxa-example-webapp:net10 . + +# Use official ASP.NET Core runtime as base image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 +EXPOSE 80 +EXPOSE 443 + +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Add the internal Nexus repository for restoring packages +RUN dotnet nuget add source https://nexus.sdl.com/repository/releases_dotnet/index.json --name "SDL-Nexus" + +RUN dotnet nuget locals all --clear + +COPY Directory.Build.props ./ +COPY dxa-web-application-mvc-net/dotnet/Directory.Build.props ./dxa-web-application-mvc-net/dotnet/ +COPY dxa-module-core-net/dotnet/Directory.Build.props ./dxa-module-core-net/dotnet/ +COPY dxa-module-core-net/dotnet/shared/ ./dxa-module-core-net/dotnet/Shared/ +COPY dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/ ./dxa-module-core-net/dotnet/src/Tridion.Dxa.Module.Core/ +COPY dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ ./dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ +COPY LocalNugetStorage/ ./LocalNugetStorage/ + +ENV WEBAPP_CSPROJ=dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj + +RUN dotnet restore "$WEBAPP_CSPROJ" \ + -p:TargetFrameworks=net10.0 \ + --source https://api.nuget.org/v3/index.json \ + --source https://nexus.sdl.com/repository/releases_dotnet/index.json \ + --source /src/LocalNugetStorage + +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates && \ + (dotnet dev-certs https --trust || true) + +RUN dotnet publish "$WEBAPP_CSPROJ" -c Release -f net10.0 -o /app/publish \ + --no-restore + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . + +# Set defaults +ENV Logging__LogLevel__Default=Warning + #URLs=http://*:80 + +ENTRYPOINT ["dotnet", "Tridion.Dxa.Example.WebApp.dll"] diff --git a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Properties/PublishProfiles/FolderProfile.net10.0.pubxml b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Properties/PublishProfiles/FolderProfile.net10.0.pubxml new file mode 100644 index 0000000..fe33ae0 --- /dev/null +++ b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Properties/PublishProfiles/FolderProfile.net10.0.pubxml @@ -0,0 +1,21 @@ + + + + + true + false + true + Release + Any CPU + FileSystem + bin\Release\net10.0\publish\ + FileSystem + <_TargetId>Folder + + net10.0 + 2dce743c-ebea-451f-9fa5-cd2f64f4e4a9 + false + + diff --git a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj index de1949b..8a59b02 100644 --- a/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj +++ b/dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/Tridion.Dxa.Example.WebApp.csproj @@ -1,6 +1,6 @@  - net8.0 + net8.0;net10.0 @@ -9,7 +9,7 @@ - + From 9a15aeac8a754b40f16fd11bcca13db56f1cf91b Mon Sep 17 00:00:00 2001 From: Indio Giles <33137578+rwsigiles@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:18:43 +0100 Subject: [PATCH 6/7] Add CM combined-stack test scripts under test/. Ignore generated Dockerfile*.cmtest files and document net10 ASP.NET deprecation warnings. --- .gitignore | 3 + README.md | 10 +- test/CmTestSetup.md | 210 ++++++ test/New-CmTestDockerfile.ps1 | 137 ++++ test/Open-CmTestFirewallPorts.ps1 | 104 +++ test/Start-CmTestWebApp.ps1 | 1042 +++++++++++++++++++++++++++++ test/Test-DxaRuntimePorts.ps1 | 394 +++++++++++ 7 files changed, 1899 insertions(+), 1 deletion(-) create mode 100644 test/CmTestSetup.md create mode 100644 test/New-CmTestDockerfile.ps1 create mode 100644 test/Open-CmTestFirewallPorts.ps1 create mode 100644 test/Start-CmTestWebApp.ps1 create mode 100644 test/Test-DxaRuntimePorts.ps1 diff --git a/.gitignore b/.gitignore index 966f887..902fea6 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ ScaffoldingReadMe.txt # Local NuGet feed folders (produced by Release-Dxa.ps1 and build.proj GenerateLocalNuGetPackages target) **/LocalNugetStorage/ +# Generated CM test Dockerfiles (New-CmTestDockerfile.ps1) +**/Dockerfile*.cmtest + # Others ~$* *~ diff --git a/README.md b/README.md index d6484c9..096d948 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,15 @@ NuGet resolves `lib/net10.0` from the DXA packages automatically when your proje - Pages render; DXA modules initialize as before **Known warnings (non-blocking)** -Building against net10 may surface ASP.NET deprecation warnings (for example `ASPDEPR003` Razor runtime compilation, `ASPDEPR005` `KnownNetworks`, `ASPDEPR006` `IActionContextAccessor`). They do not fail the build unless warnings-as-errors is enabled. +Building against net10 may surface ASP.NET deprecation warnings. They do not fail the build unless warnings-as-errors is enabled. Suggested resolutions: + +| ID | API | Where it appears in DXA | Resolution | +| --- | --- | --- | --- | +| `ASPDEPR003` | Razor runtime compilation (`AddRazorRuntimeCompilation`, `MvcRazorRuntimeCompilationOptions`) | Example web app `Startup.cs`; framework `AddDxaModule` (embedded module views) | Prefer build-time view compilation (`*.Views.dll` / `CompiledRazorAssemblyPart`, already used when a views assembly exists). In Development, use Hot Reload instead of runtime compilation. Restrict `AddRazorRuntimeCompilation` to Development only if you still need on-disk or embedded `.cshtml` edits without a rebuild. Runtime compilation is not recommended in production. | +| `ASPDEPR005` | `ForwardedHeadersOptions.KnownNetworks` / `Microsoft.AspNetCore.HttpOverrides.IPNetwork` | Example web app `LoggingMiddleware` (trace logging of forwarded-header options) | Log `KnownIPNetworks` and `System.Net.IPNetwork` instead of `KnownNetworks`. If you configure trusted proxy ranges, add them to `KnownIPNetworks` (prefix host bits must be zero). | +| `ASPDEPR006` | `IActionContextAccessor` / `ActionContextAccessor` | Framework `AddDxa` / `AddDxaWebApi` registration; `HtmlHelperExtensions.Action` nested action render | Microsoft’s replacement is `IHttpContextAccessor` plus `HttpContext.GetEndpoint()` for *reading* action metadata. DXA still *writes* a child `ActionContext` while rendering nested `Html.Action` calls, so a full drop-in is not available yet. Planned path: invoke via `IActionInvokerFactory` on an explicit `ActionContext`, set the child `HttpContext` (and endpoint metadata) on `IHttpContextAccessor`, and remove the accessor registration. Until then, keep the current usage; it remains functional on net10. | + +See also: [ASPDEPR003](https://aka.ms/aspnet/deprecate/003), [ASPDEPR005](https://aka.ms/aspnet/deprecate/005), [ASPDEPR006](https://aka.ms/aspnet/deprecate/006). ### Building or deploying this repository diff --git a/test/CmTestSetup.md b/test/CmTestSetup.md new file mode 100644 index 0000000..e056573 --- /dev/null +++ b/test/CmTestSetup.md @@ -0,0 +1,210 @@ +# Combined CM/CD test server (AWS) + +Use this when a **single** Tridion Sites Content Manager + Content Delivery host is running on AWS for DXA testing. The example web app lives in `dxa-web-application-mvc-net` and talks to CD over HTTP; it does not need the CME UI to render pages. + +Scripts for this setup live in `test/`. Run them from the **repository root**. + +Replace `cm-cd.example.internal` (or the instance public DNS / Elastic IP) everywhere you see a hostname. + +## Quick Start + +On the Windows CM/CD instance (elevated PowerShell), open Windows Firewall, then from your DXA machine verify ports, build the Example WebApp image, and start it: + +```powershell +# 1. On the CM/CD host (Administrator): open DXA runtime ports +.\test\Open-CmTestFirewallPorts.ps1 -Section DxaRuntime + +# 2. From the DXA / Docker machine: confirm CM/CD ports (replace with the instance IP) +.\test\Test-DxaRuntimePorts.ps1 -CmServerIp 192.0.2.10 + +# 3. Build a gitignored test image pointed at that CM IP (net8 or net10) +.\test\New-CmTestDockerfile.ps1 -Framework net10 -CmServerIp 192.0.2.10 + +# 4. Start the container and run site / CD / sitemap checks +.\test\Start-CmTestWebApp.ps1 -Framework net10 +``` + +Step 3 also runs step 2 for DXA runtime ports and skips the Docker build if they are closed. Attach AWS security group **DXA-CmTest** to each instance manually before step 2. Optional: `-Section Publishing` / `Optional` / `WebApp` in step 1. + +--- + +## Ports to open + +Open these on the AWS security group **DXA-CmTest** (and Windows Firewall on the instance) from the machine that runs the DXA web app. For a locked-down test box, restrict the source to your office/VPN CIDR, not `0.0.0.0/0`. + +**DXA-CmTest** has been added as an AWS security group with the ports listed below. Attach it to each EC2 instance yourself: AWS does not apply a new group to existing instances. In the EC2 console, select the instance → **Actions** → **Security** → **Change security groups** → add **DXA-CmTest** (keep the existing groups unless you intend to replace them). Repeat for every CM/CD (and DXA) instance that should be reachable for this test setup. + +On the Windows CM/CD (or DXA) host, open inbound TCP ports in Windows Defender Firewall by **section ID** (elevated PowerShell): + +| ID | Section | +|----|---------| +| `DxaRuntime` | Required for DXA runtime | +| `Publishing` | Required for publishing (CM on the same box) | +| `Optional` | Optional (open only if you use the feature) | +| `WebApp` | DXA web app listen port (local, not AWS) | + +```powershell +.\test\Open-CmTestFirewallPorts.ps1 -Section DxaRuntime +.\test\Open-CmTestFirewallPorts.ps1 -Section DxaRuntime,Publishing +.\test\Open-CmTestFirewallPorts.ps1 -Section Optional +.\test\Open-CmTestFirewallPorts.ps1 -Section WebApp +``` + +Rules are named `DXA CmTest - - TCP `. Existing rules with the same name are left unchanged. + +### Required for DXA runtime + +DXA only needs Discovery in config. After that it follows Discovery for Token, Content (GraphQL / Content Service), and optionally Session Content and search. Those follow-on ports still must be reachable. + +| Port | Protocol | Service | Why | +|------|----------|---------|-----| +| **8082** | TCP | Discovery Service (`/discovery.svc`) and Token Service (`/token.svc`) | First hop. OAuth token + capability URLs. | +| **8081** | TCP | Content Service (`/content.svc`, GraphQL) | Page/entity content. Capability URL is registered in Discovery. | +| **8083** | TCP | Session-enabled Content Service | Experience Manager / session preview. Open if you test XPM. | + +From the machine that will run DXA, test **all** CmTestSetup ports against the CM/CD **IP address**. Output is grouped by the section names below: + +```powershell +.\test\Test-DxaRuntimePorts.ps1 -CmServerIp 192.0.2.10 +``` + +If Discovery TCP (default **8082**) is open, the script calls Discovery (`/discovery.svc`) and Token (`/token.svc`, using Example WebApp OAuth `cduser`) and **lists each capability URL and port**. When a capability port differs from the CmTestSetup default, that default is **overridden** for the TCP checks that follow. Capability URLs that still say `localhost` are listed with a warning; the port test still uses the CM server IP. + +The script exits `0` if every port in **Required for DXA runtime** and **Required for publishing** (after any Discovery overrides) accepts a TCP connection. Closed ports in **Optional** are reported but do not fail the script. Port **8080** (DXA web app listen port) is local and is not tested against the CM server. + +### Required for publishing (CM on the same box) + +| Port | Protocol | Service | Why | +|------|----------|---------|-----| +| **80** / **443** | TCP | IIS (CME, Topology Manager if bound here, Core Service) | CME, Topology Manager, Core Service. Prefer 443 if TLS is configured. | +| **81** | TCP | Topology Manager (common alternate IIS binding) | Only if TTM is not on 80/443. Confirm the site binding on the instance. | +| **8084** | TCP | Content Deployer | Publish from CM to CD. Needed even on a combined box if the deployer listens on this port. | + +### Optional (open only if you use the feature) + +| Port | Protocol | Service | Why | +|------|----------|---------|-----| +| **8087** | TCP | Context Engine | Device/context claims. | +| **8097** | TCP | IQ Query | Search (DXA Search module / `IQSearchIndex` in appsettings). Confirm the IQ Query port on your Sites version if search fails. | +| **9200** | TCP | Elasticsearch / OpenSearch | Only if clients query the search engine directly (unusual for DXA). Prefer keeping this private. | +| **3389** | TCP | RDP | Windows admin. Restrict to your IP. | +| **1433** | TCP | SQL Server | Only if the database is on this instance **and** you connect from outside. Prefer leaving it closed and using RDP/VPN. | + +### DXA web app listen port (local, not AWS) + +The example app binds **8080** (`URLs` in `appsettings.json`). Open 8080 on the **web app host**, not on the CM/CD security group, unless you also host DXA on the AWS instance. + +--- + +## Discovery capability URLs on the CM/CD server + +After opening ports, Discovery must advertise **hostnames the DXA app can resolve**, not `localhost`. + +On the AWS instance, capabilities registered in Discovery (Content, Token, Session Content, Deployer, IQ Query) should use the public or private DNS you will call from DXA, for example: + +`http://cm-cd.example.internal:8081/content.svc` + +If capabilities still say `http://localhost:8081/...`, DXA will authenticate against Discovery successfully and then fail when it follows those URLs. + +Also register/update the **website Base URL** in Topology Manager so it matches the URL you use in the browser for the DXA site (for example `http://localhost:8080` or your test hostname). + +--- + +## Web app config to change + +File: `dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/appsettings.json` + +Use `appsettings.Development.json` for local overrides if you prefer not to edit the checked-in file. + +### 1. Discovery endpoint (required) + +Default: + +```json +"Dxa": { + "Services": { + "Discovery": "http://localhost:8082/discovery.svc" + } +} +``` + +Change `localhost` to the AWS host: + +```json +"Discovery": "http://cm-cd.example.internal:8082/discovery.svc" +``` + +Use `https://` and the TLS port if Discovery is bound with certificates. + +To test in Docker without editing the committed Dockerfile or `appsettings.json`, generate a local copy (gitignored) that rewrites Discovery to the CM IP: + +```powershell +.\test\New-CmTestDockerfile.ps1 -Framework net8 -CmServerIp 192.0.2.10 +.\test\New-CmTestDockerfile.ps1 -Framework net10 -CmServerIp 192.0.2.10 +``` + +The script first runs `Test-DxaRuntimePorts.ps1` for **Required for DXA runtime** (8082, 8081, 8083). If any of those ports are closed, it skips `docker build` and exits `1`. Otherwise it writes `Dockerfile.cmtest` or `Dockerfile.net10.0.cmtest` and builds (`dxa-example-webapp:net8-cmtest` or `dxa-example-webapp:net10-cmtest`). Do not commit `*.cmtest` files. + +Start the image and run basic site/CD checks (site `/system/health`, home page `/`, CD `/navigation.json`): + +```powershell +.\test\Start-CmTestWebApp.ps1 -Framework net8 +.\test\Start-CmTestWebApp.ps1 -Framework net10 +``` + +The script replaces any existing `dxa-cmtest` container, publishes the **Topology website port** (default **80**) to container 8080, and leaves it running on **success** (then opens `http://dxa.tridiondemo.com/` in the default browser — not `:8080`, which Topology does not map). If any check fails (or the script errors), it prints the last 80 log lines and **removes** the container. Use `-RemoveWhenDone` to delete it after a successful run as well. If Docker cannot bind port 80, stop IIS or whatever is using it. + +Discovery often advertises Token/Content URLs on a hostname such as `dxd.tridiondemo.com` even when you pointed DXA at the CM IP. The start script adds Docker `--add-host` for those CD hosts **and** for the Topology website host (`dxa.tridiondemo.com` by default). + +Page requests use Topology website **`http://dxa.tridiondemo.com`** (`Host` / `Origin`), not `dxd.tridiondemo.com` and not Docker port **8080**. Override with `-WebsiteUrl`. The start script adds `127.0.0.1 dxa.tridiondemo.com` to the local hosts file if that entry is missing (requires Administrator to write). The container sets `Dxa__PreferOriginHeaderForLocalizationResolver`. + +After a successful home page, the script opens `/sitemap.xml` (or a sitemap link on the home page), then GETs each sitemap URL and prints **URL** and **HTTP status**. Errors tied to `Search:Entity:SearchBox` are **WARN** only and do not fail the home page or sitemap. Other **A problem occurred while rendering this section** errors still fail and print page/container logs. Broken links fail the run. + +### 2. OAuth (required unless CD OAuth is disabled) + +Default: + +```json +"OAuth": { + "Enabled": true, + "ClientId": "cduser", + "ClientSecret": "CDUserP@ssw0rd" +} +``` + +Align `ClientId` / `ClientSecret` with the CD user in the Token Service on that environment (`cd_ambient_conf.xml` / Token Service config). If OAuth is off on this test CD, set `"Enabled": false`. + +### 3. App listen URL (optional) + +Default: + +```json +"URLs": "http://*:8080" +``` + +Change the port or host binding if IIS, a load balancer, or a conflict requires it. Topology Manager website mapping must match whatever URL you actually browse. + +### 4. Search (only if the Search module is used) + +`IQSearchIndex` defaults to `udp-index`. Change it if the AWS IQ / Elasticsearch index name is different. IQ Query itself is resolved via Discovery once port **8097** (or your actual IQ Query port) is open. + +### 5. Redis (leave as-is for a single test node) + +`SdlWebDelivery:Caching` defaults to in-memory handlers (`regularCache` / `longLivedCache`). Redis (`localhost:6379`) is only used if you switch regions to `regularDistributedCache` / `longLivedDistributedCache`. Do not open Redis on AWS unless you actually enable those handlers. + +### 6. Logging (optional for test) + +For troubleshooting CD connectivity, raise log levels in `appsettings.Development.json` (already `Debug` for `Default`) and check `logs/sites-*.log` under the web app. + +--- + +## Minimal change checklist + +1. Attach security group **DXA-CmTest** to each instance manually, then confirm **8082**, **8081**, plus **8083** if testing XPM, **8084** if you publish, **80/443** (and **81** if needed) for CME/TTM. +2. Discovery capabilities: no `localhost` URLs for services DXA will call. +3. `appsettings.json`: `Dxa:Services:Discovery` → AWS host; `Dxa:OAuth` matches Token Service. +4. Topology Manager: website base URL matches the DXA site URL. +5. From the web app host, verify: + - `http://:8082/discovery.svc` + - Token endpoint (usually `http://:8082/token.svc`) + - Content / GraphQL URL returned by Discovery diff --git a/test/New-CmTestDockerfile.ps1 b/test/New-CmTestDockerfile.ps1 new file mode 100644 index 0000000..9898511 --- /dev/null +++ b/test/New-CmTestDockerfile.ps1 @@ -0,0 +1,137 @@ +<# +.SYNOPSIS + Copies the Example WebApp Dockerfile (net8 or net10), points Discovery at a CM/CD IP, + then builds the image from the repo root if DXA runtime ports are open. + The generated Dockerfile is gitignored and must not be committed. + +.PARAMETER Framework + Target TFM Dockerfile: net8 (Dockerfile) or net10 (Dockerfile.net10.0). + +.PARAMETER CmServerIp + IPv4 or IPv6 address of the combined CM/CD host (not a hostname). + +.EXAMPLE + .\test\New-CmTestDockerfile.ps1 -Framework net8 -CmServerIp 192.0.2.10 + +.EXAMPLE + .\test\New-CmTestDockerfile.ps1 -Framework net10 -CmServerIp 192.0.2.10 +#> +[CmdletBinding()] +param( + [ValidateSet('net8', 'net10')] + [string]$Framework, + + [Parameter(Mandatory = $true)] + [string]$CmServerIp +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$RepoRoot = Split-Path -Parent $PSScriptRoot + +$frameworkValues = @('net8', 'net10') +if ([string]::IsNullOrWhiteSpace($Framework)) { + Write-Host ("Available Framework values: {0}" -f ($frameworkValues -join ', ')) -ForegroundColor Cyan + $Framework = Read-Host 'Framework' +} +if ($frameworkValues -notcontains $Framework) { + throw ("Framework must be one of: {0}. Got: '{1}'" -f ($frameworkValues -join ', '), $Framework) +} + +$parsedIp = $null +if (-not [System.Net.IPAddress]::TryParse($CmServerIp, [ref]$parsedIp)) { + throw "CmServerIp must be an IP address. Got: '$CmServerIp'" +} + +$testPorts = Join-Path $PSScriptRoot 'Test-DxaRuntimePorts.ps1' +Write-Host "Checking DXA runtime ports..." -ForegroundColor Cyan +$runtimePortsOpen = & $testPorts -CmServerIp $CmServerIp -DxaRuntimeOnly -PassThru +if (-not $runtimePortsOpen) { + Write-Host "Skipping docker build because DXA runtime ports are not open." -ForegroundColor Red + exit 1 +} + +$webAppDir = Join-Path $RepoRoot 'dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp' +$discoveryUrl = "http://${CmServerIp}:8082/discovery.svc" +$appsettingsRel = 'dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/appsettings.json' + +if ($Framework -eq 'net8') { + $sourceName = 'Dockerfile' + $destName = 'Dockerfile.cmtest' + $imageTag = 'dxa-example-webapp:net8-cmtest' +} +else { + $sourceName = 'Dockerfile.net10.0' + $destName = 'Dockerfile.net10.0.cmtest' + $imageTag = 'dxa-example-webapp:net10-cmtest' +} + +$sourcePath = Join-Path $webAppDir $sourceName +$destPath = Join-Path $webAppDir $destName + +if (-not (Test-Path -LiteralPath $sourcePath)) { + throw "Source Dockerfile not found: $sourcePath" +} + +$content = Get-Content -LiteralPath $sourcePath -Raw + +$generatedHeader = @" +# GENERATED by New-CmTestDockerfile.ps1 — do not commit +# Build from repo root: +# docker build -f dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/$destName -t $imageTag . + +"@ + +$copyNeedle = "COPY dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/ ./dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/" +$sedInsert = @" +$copyNeedle + +# Point Discovery at the CM/CD test host (replaces localhost in appsettings.json) +RUN sed -i 's#http://localhost:8082/discovery.svc#$discoveryUrl#g' $appsettingsRel +"@ + +if ($content -notlike "*$copyNeedle*") { + throw "Unexpected Dockerfile layout; could not find WebApp COPY line in $sourceName" +} + +$content = $content.Replace($copyNeedle, $sedInsert) + +$envNeedle = "ENV Logging__LogLevel__Default=Warning" +$envInsert = @" +$envNeedle +ENV Dxa__Services__Discovery=$discoveryUrl +"@ + +if ($content -notlike "*$envNeedle*") { + throw "Unexpected Dockerfile layout; could not find Logging ENV in $sourceName" +} + +$content = $content.Replace($envNeedle, $envInsert) + +# Drop the original "Build from repo root" comment block; generated header replaces it. +$content = $content -replace '(?s)^# Build from repo root:.*?(?=# Use official)', '' + +$utf8NoBom = New-Object System.Text.UTF8Encoding $false +[System.IO.File]::WriteAllText($destPath, $generatedHeader + $content.TrimStart(), $utf8NoBom) + +Write-Host "Wrote $destPath" -ForegroundColor Green +Write-Host "Discovery: $discoveryUrl" +Write-Host "" + +$dockerFileRel = "dxa-web-application-mvc-net/dotnet/src/Tridion.Dxa.Example.WebApp/$destName" +Write-Host "Running docker build from repo root..." -ForegroundColor Cyan +Write-Host " docker build -f $dockerFileRel -t $imageTag ." + +Push-Location $RepoRoot +try { + & docker build -f $dockerFileRel -t $imageTag . + if ($LASTEXITCODE -ne 0) { + throw "docker build failed with exit code $LASTEXITCODE" + } +} +finally { + Pop-Location +} + +Write-Host "Image tagged $imageTag" -ForegroundColor Green diff --git a/test/Open-CmTestFirewallPorts.ps1 b/test/Open-CmTestFirewallPorts.ps1 new file mode 100644 index 0000000..9dd8dbb --- /dev/null +++ b/test/Open-CmTestFirewallPorts.ps1 @@ -0,0 +1,104 @@ +<# +.SYNOPSIS + Opens inbound TCP ports in Windows Defender Firewall for CmTestSetup.md sections. + +.PARAMETER Section + One or more unique section IDs: DxaRuntime, Publishing, Optional, WebApp. + +.EXAMPLE + .\test\Open-CmTestFirewallPorts.ps1 -Section DxaRuntime + +.EXAMPLE + .\test\Open-CmTestFirewallPorts.ps1 -Section DxaRuntime,Publishing +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet('DxaRuntime', 'Publishing', 'Optional', 'WebApp')] + [string[]]$Section +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + throw "This script must be run in an elevated PowerShell session (Run as administrator)." +} + +# Sections match CmTestSetup.md (same ports as Test-DxaRuntimePorts.ps1). +$allSections = @( + @{ + Id = 'DxaRuntime' + Name = 'Required for DXA runtime' + Ports = @( + @{ Port = 8082; Service = 'Discovery Service (/discovery.svc) and Token Service (/token.svc)' } + @{ Port = 8081; Service = 'Content Service (/content.svc, GraphQL)' } + @{ Port = 8083; Service = 'Session-enabled Content Service (XPM preview)' } + ) + }, + @{ + Id = 'Publishing' + Name = 'Required for publishing (CM on the same box)' + Ports = @( + @{ Port = 80; Service = 'IIS (CME, Topology Manager if bound here, Core Service)' } + @{ Port = 443; Service = 'IIS HTTPS (CME, Topology Manager, Core Service)' } + @{ Port = 81; Service = 'Topology Manager (common alternate IIS binding)' } + @{ Port = 8084; Service = 'Content Deployer' } + ) + }, + @{ + Id = 'Optional' + Name = 'Optional (open only if you use the feature)' + Ports = @( + @{ Port = 8087; Service = 'Context Engine' } + @{ Port = 8097; Service = 'IQ Query' } + @{ Port = 9200; Service = 'Elasticsearch / OpenSearch' } + @{ Port = 3389; Service = 'RDP' } + @{ Port = 1433; Service = 'SQL Server' } + ) + }, + @{ + Id = 'WebApp' + Name = 'DXA web app listen port (local, not AWS)' + Ports = @( + @{ Port = 8080; Service = 'DXA Example WebApp (appsettings URLs)' } + ) + } +) + +Import-Module NetSecurity + +$selected = $allSections | Where-Object { $Section -contains $_.Id } +$created = 0 +$skipped = 0 + +foreach ($sec in $selected) { + Write-Host ("{0} - {1}" -f $sec.Id, $sec.Name) -ForegroundColor Yellow + foreach ($entry in $sec.Ports) { + $displayName = "DXA CmTest - $($sec.Id) - TCP $($entry.Port)" + $existing = Get-NetFirewallRule -DisplayName $displayName -ErrorAction SilentlyContinue + if ($existing) { + Write-Host (" EXISTS {0,-5} {1}" -f $entry.Port, $entry.Service) -ForegroundColor DarkGray + $skipped++ + continue + } + + New-NetFirewallRule ` + -DisplayName $displayName ` + -Description $entry.Service ` + -Direction Inbound ` + -Action Allow ` + -Protocol TCP ` + -LocalPort $entry.Port ` + -Profile Any | Out-Null + + Write-Host (" OPENED {0,-5} {1}" -f $entry.Port, $entry.Service) -ForegroundColor Green + $created++ + } + Write-Host "" +} + +Write-Host ("Created {0} inbound rule(s); {1} already present." -f $created, $skipped) -ForegroundColor Cyan +Write-Host 'This only updates Windows Defender Firewall on this machine. Also open the same ports on the AWS security group.' diff --git a/test/Start-CmTestWebApp.ps1 b/test/Start-CmTestWebApp.ps1 new file mode 100644 index 0000000..17c2dc6 --- /dev/null +++ b/test/Start-CmTestWebApp.ps1 @@ -0,0 +1,1042 @@ +<# +.SYNOPSIS + Starts the CM test DXA Docker image and checks that the site is up and can talk to CD. + +.PARAMETER Framework + Image TFM: net8 (dxa-example-webapp:net8-cmtest) or net10 (dxa-example-webapp:net10-cmtest). + +.PARAMETER CmServerIp + CM/CD IP used for Docker --add-host entries. Default: parsed from the image Dxa__Services__Discovery ENV. + +.PARAMETER WebsiteUrl + Topology website Base URL used for Host/Origin on page requests. Default http://dxa.tridiondemo.com (port 80). Not the CD host dxd.tridiondemo.com and not Docker port 8080. + +.PARAMETER HostPort + Host port published to container 8080. Default is the Topology website port (80 for http://dxa.tridiondemo.com). Must match Topology or the browser will fail localization. + +.PARAMETER StartupTimeoutSec + Seconds to wait for /system/health. Default 90. + +.PARAMETER RemoveWhenDone + Also remove the container after a successful run. On failure the container is always removed. + +.EXAMPLE + .\test\Start-CmTestWebApp.ps1 -Framework net8 + +.EXAMPLE + .\test\Start-CmTestWebApp.ps1 -Framework net10 -WebsiteUrl http://dxa.tridiondemo.com +#> +[CmdletBinding()] +param( + [ValidateSet('net8', 'net10')] + [string]$Framework, + + [string]$CmServerIp, + + [string]$WebsiteUrl = 'http://dxa.tridiondemo.com', + + [int]$HostPort, + + [int]$StartupTimeoutSec = 90, + + [switch]$RemoveWhenDone +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$frameworkValues = @('net8', 'net10') +if ([string]::IsNullOrWhiteSpace($Framework)) { + Write-Host ("Available Framework values: {0}" -f ($frameworkValues -join ', ')) -ForegroundColor Cyan + $Framework = Read-Host 'Framework' +} +if ($frameworkValues -notcontains $Framework) { + throw ("Framework must be one of: {0}. Got: '{1}'" -f ($frameworkValues -join ', '), $Framework) +} + +if ($Framework -eq 'net8') { + $imageTag = 'dxa-example-webapp:net8-cmtest' +} +else { + $imageTag = 'dxa-example-webapp:net10-cmtest' +} + +$containerName = 'dxa-cmtest' + +$websiteUri = $null +$browseUrl = $null +if (-not [string]::IsNullOrWhiteSpace($WebsiteUrl)) { + try { + $websiteUri = [Uri]$WebsiteUrl + } + catch { + throw "WebsiteUrl is not a valid URL: '$WebsiteUrl'" + } + if (-not $websiteUri.IsAbsoluteUri) { + throw "WebsiteUrl must be an absolute URL. Got: '$WebsiteUrl'" + } +} + +if (-not $PSBoundParameters.ContainsKey('HostPort')) { + if ($null -ne $websiteUri -and $websiteUri.Port -gt 0) { + $HostPort = $websiteUri.Port + } + else { + $HostPort = 80 + } +} + +$baseUrl = "http://127.0.0.1:$HostPort" +$browseUrl = $baseUrl +if ($null -ne $websiteUri) { + $browseBuilder = New-Object System.UriBuilder($websiteUri) + $browseBuilder.Path = '/' + $browseBuilder.Query = '' + $browseUrl = $browseBuilder.Uri.AbsoluteUri +} + +function Get-ImageDiscoveryUri { + param([string]$ImageTag) + + $inspectJson = & docker inspect $ImageTag + if ($LASTEXITCODE -ne 0) { + return $null + } + $inspect = $inspectJson | ConvertFrom-Json + foreach ($entry in @($inspect[0].Config.Env)) { + if ($entry -like 'Dxa__Services__Discovery=*') { + $raw = $entry.Substring('Dxa__Services__Discovery='.Length) + try { + return [Uri]$raw + } + catch { + return $null + } + } + } + return $null +} + +function Get-DiscoveryCapabilityInfo { + param( + [string]$DiscoveryBaseUrl, + [string]$TokenUrl, + [string]$ClientId = 'cduser', + [string]$ClientSecret = 'CDUserP@ssw0rd' + ) + + $info = @{ + Hosts = @() + Token = $null + ContentServiceUrl = $null + } + + $capabilityHostSet = New-Object System.Collections.Generic.HashSet[string] + try { + $tokenResponse = Invoke-WebRequest -Uri $TokenUrl -Method POST -UseBasicParsing -TimeoutSec 15 ` + -ContentType 'application/x-www-form-urlencoded' ` + -Body "grant_type=client_credentials&client_id=$ClientId&client_secret=$ClientSecret" + $tokenJson = $tokenResponse.Content | ConvertFrom-Json + $tokenProp = $tokenJson.PSObject.Properties | Where-Object { $_.Name -eq 'access_token' -or $_.Name -eq 'accessToken' } | Select-Object -First 1 + if ($tokenProp) { + $info.Token = [string]$tokenProp.Value + } + } + catch { + Write-Host 'Could not read Token Service; Docker extra hosts may be incomplete.' -ForegroundColor DarkYellow + } + + $headers = @{ + Accept = 'application/json;odata.metadata=minimal' + 'OData-Version' = '4.0' + 'OData-MaxVersion' = '4.0' + } + if ($info.Token) { + $headers['Authorization'] = "Bearer $($info.Token)" + } + + $paths = @( + 'TokenServiceCapabilities', + 'ContentServiceCapabilities', + 'PreviewWebServiceCapabilities', + 'DiscoveryServiceCapabilities', + 'DeployerCapabilities', + 'IQQueryCapabilities', + 'ContextServiceCapabilities' + ) + + foreach ($path in $paths) { + $url = "$DiscoveryBaseUrl/$path" + '?$top=1' + try { + $response = Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing -TimeoutSec 15 + $json = $response.Content | ConvertFrom-Json + $items = @($json.value) + if ($items.Count -eq 0) { + continue + } + $item = $items[0] + $uriText = $null + foreach ($name in @('Uri', 'uri', 'URL', 'url')) { + if ($item.PSObject.Properties.Name -contains $name) { + $uriText = [string]$item.$name + break + } + } + if ($uriText) { + $uri = [Uri]$uriText + [void]$capabilityHostSet.Add($uri.Host) + if ($path -eq 'ContentServiceCapabilities') { + $info.ContentServiceUrl = $uriText + } + } + } + catch { + } + } + + $info.Hosts = @($capabilityHostSet) + return $info +} + +function Get-PublicationMapping { + param( + [string]$ContentServiceUrl, + [string]$CmServerIp, + [string]$Token, + [string[]]$CandidateHosts + ) + + if (-not $ContentServiceUrl -or -not $Token) { + return $null + } + + try { + $contentUri = [Uri]$ContentServiceUrl + } + catch { + return $null + } + + $builder = New-Object System.UriBuilder($contentUri) + $builder.Host = $CmServerIp + $graphQlUrl = $builder.Uri.AbsoluteUri.Replace('content.svc', 'cd/api') + + $siteUrls = New-Object System.Collections.Generic.List[string] + foreach ($hostName in $CandidateHosts) { + if ([string]::IsNullOrWhiteSpace($hostName)) { + continue + } + [void]$siteUrls.Add("http://${hostName}/") + [void]$siteUrls.Add("https://${hostName}/") + [void]$siteUrls.Add("http://${hostName}:80/") + [void]$siteUrls.Add("https://${hostName}:443/") + } + + $headers = @{ + Authorization = "Bearer $Token" + Accept = 'application/json' + } + + foreach ($siteUrl in $siteUrls) { + $payload = @{ + query = 'query($namespaceId: Int!, $siteUrl: String!) { publicationMapping(namespaceId: $namespaceId, siteUrl: $siteUrl) { publicationId protocol domain port path } }' + variables = @{ + namespaceId = 1 + siteUrl = $siteUrl + } + } + $jsonBody = $payload | ConvertTo-Json -Compress -Depth 6 + try { + $response = Invoke-WebRequest -Uri $graphQlUrl -Method POST -Headers $headers ` + -ContentType 'application/json; charset=utf-8' -Body $jsonBody -UseBasicParsing -TimeoutSec 20 + $data = $response.Content | ConvertFrom-Json + $mapping = $null + if ($data.PSObject.Properties.Name -contains 'data' -and $data.data -and $data.data.publicationMapping) { + $mapping = $data.data.publicationMapping + } + if ($mapping -and $mapping.domain) { + Write-Host ("Topology publication mapping: {0}://{1}:{2}{3} (probed {4})" -f $mapping.protocol, $mapping.domain, $mapping.port, $mapping.path, $siteUrl) -ForegroundColor Cyan + return $mapping + } + } + catch { + } + } + + Write-Host 'Could not read a Topology publication mapping from Content Service GraphQL.' -ForegroundColor DarkYellow + return $null +} + +function Get-HeadersFromWebsiteUrl { + param([Uri]$WebsiteUri) + + $hostHeader = $WebsiteUri.Host + $defaultPort = 80 + if ($WebsiteUri.Scheme -eq 'https') { + $defaultPort = 443 + } + if ($WebsiteUri.IsDefaultPort -eq $false -and $WebsiteUri.Port -gt 0 -and $WebsiteUri.Port -ne $defaultPort) { + $hostHeader = '{0}:{1}' -f $WebsiteUri.Host, $WebsiteUri.Port + } + return @{ + HostHeader = $hostHeader + OriginHeader = $WebsiteUri.GetLeftPart([System.UriPartial]::Authority) + } +} + +function Test-PageLocalizationOk { + param($HttpResult) + + if ($null -eq $HttpResult) { + return $false + } + $searchTied = Test-IsSearchModuleError -Html $HttpResult.Content + $statusOk = $HttpResult.StatusCode -eq 200 -or ($searchTied -and $HttpResult.StatusCode -ge 400) + $looksHtml = $statusOk -and ( + $HttpResult.MediaType -match 'html' -or $HttpResult.Content -match '(?i)]*>(.*?)') + foreach ($pre in $preMatches) { + $text = $pre.Groups[1].Value + $text = [System.Net.WebUtility]::HtmlDecode($text) + $text = ($text -replace '\s+', ' ').Trim() + if (-not [string]::IsNullOrWhiteSpace($text)) { + [void]$details.Add($text) + } + } + return $details +} + +function Test-HasFatalSectionRenderError { + param( + [string]$Html, + [switch]$BareSectionErrorIsSearchBox + ) + + if (-not (Test-HtmlHasSectionRenderError -Html $Html)) { + return $false + } + $details = Get-SectionRenderErrorDetails -Html $Html + if (Test-IsSearchModuleError -Html $Html) { + if ($null -eq $details -or $details.Count -eq 0) { + return $false + } + foreach ($detail in $details) { + $tiedToSearchBox = $detail -match '(?i)Search(?::|:)Entity(?::|:)SearchBox' -or $detail -match '(?i)SearchBox' -or $detail -match "(?i)View in the 'Search' area" + if (-not $tiedToSearchBox) { + return $true + } + } + return $false + } + if ($BareSectionErrorIsSearchBox -and ($null -eq $details -or $details.Count -eq 0)) { + return $false + } + return $true +} + +function Test-ContainerSearchBoxErrorsOnly { + param([string]$ContainerName) + + if ([string]::IsNullOrWhiteSpace($ContainerName)) { + return $false + } + $raw = '' + try { + $raw = (& docker logs --tail 200 $ContainerName 2>&1 | Out-String) + } + catch { + return $false + } + if ($raw -notmatch 'Search:Entity:SearchBox') { + return $false + } + $errorLines = [regex]::Matches($raw, '(?m)^.*\|Error\|.*$') + foreach ($match in $errorLines) { + $line = $match.Value + if ($line -notmatch 'Search:Entity:SearchBox' -and $line -notmatch "(?i)Search' area") { + return $false + } + } + return $true +} + +function Write-SectionRenderErrorLog { + param( + [string]$PageUrl, + [string]$Html, + [string]$ContainerName + ) + + Write-Host (" Section render error on {0}" -f $PageUrl) -ForegroundColor Red + $details = Get-SectionRenderErrorDetails -Html $Html + if ($null -ne $details -and $details.Count -gt 0) { + Write-Host ' Page error details:' -ForegroundColor DarkYellow + foreach ($detail in $details) { + Write-Host (" {0}" -f $detail) -ForegroundColor DarkYellow + } + } + else { + Write-Host ' No ExceptionEntity
 details in HTML (shown only when the site runs in Development).' -ForegroundColor DarkYellow
+    }
+    if (-not [string]::IsNullOrWhiteSpace($ContainerName)) {
+        Write-Host '  Container error logs:' -ForegroundColor DarkYellow
+        try {
+            & docker logs --tail 80 $ContainerName 2>&1 | Where-Object { $_ -match '(?i)(\|Error\||\|Warn\||Exception)' }
+        }
+        catch {
+        }
+    }
+}
+
+function Get-HrefUrlsFromHtml {
+    param([string]$Html)
+
+    $urls = New-Object System.Collections.Generic.List[string]
+    if ([string]::IsNullOrWhiteSpace($Html)) {
+        return @()
+    }
+    $hrefMatches = [regex]::Matches($Html, 'href\s*=\s*["'']([^"'']+)["'']', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
+    foreach ($match in $hrefMatches) {
+        $href = $match.Groups[1].Value.Trim()
+        if (-not [string]::IsNullOrWhiteSpace($href)) {
+            [void]$urls.Add($href)
+        }
+    }
+    return @($urls)
+}
+
+function Get-LocUrlsFromSitemapXml {
+    param([string]$XmlText)
+
+    $urls = New-Object System.Collections.Generic.List[string]
+    if ([string]::IsNullOrWhiteSpace($XmlText) -or $XmlText -notmatch '$null | Out-Null
+    if ($LASTEXITCODE -ne 0) {
+        throw 'Docker is not available. Start Docker Desktop / the Docker engine and retry.'
+    }
+}
+
+function Ensure-LocalHostsEntry {
+    param(
+        [string]$HostName,
+        [string]$IpAddress = '127.0.0.1'
+    )
+
+    if ([string]::IsNullOrWhiteSpace($HostName)) {
+        return
+    }
+    $parsed = $null
+    if ([System.Net.IPAddress]::TryParse($HostName, [ref]$parsed)) {
+        return
+    }
+
+    $hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts'
+    if (-not (Test-Path -LiteralPath $hostsPath)) {
+        Write-Host ("Hosts file not found: {0}" -f $hostsPath) -ForegroundColor DarkYellow
+        return
+    }
+
+    $lines = Get-Content -LiteralPath $hostsPath
+    foreach ($line in $lines) {
+        $trim = $line.Trim()
+        if ([string]::IsNullOrWhiteSpace($trim) -or $trim.StartsWith('#')) {
+            continue
+        }
+        $commentIndex = $trim.IndexOf('#')
+        if ($commentIndex -ge 0) {
+            $trim = $trim.Substring(0, $commentIndex).Trim()
+        }
+        $tokens = $trim -split '\s+'
+        if ($tokens.Count -lt 2) {
+            continue
+        }
+        if ($tokens[0] -ne $IpAddress) {
+            continue
+        }
+        for ($i = 1; $i -lt $tokens.Count; $i++) {
+            if ($tokens[$i] -eq $HostName) {
+                Write-Host ("Hosts file already has {0} {1}" -f $IpAddress, $HostName) -ForegroundColor DarkGray
+                return
+            }
+        }
+    }
+
+    $entry = "{0} {1}" -f $IpAddress, $HostName
+    try {
+        Add-Content -LiteralPath $hostsPath -Value $entry -Encoding ASCII
+        Write-Host ("Added to hosts file: {0}" -f $entry) -ForegroundColor Green
+    }
+    catch {
+        Write-Host ("Could not add '{0}' to {1}. Run this script as Administrator. {2}" -f $entry, $hostsPath, $_.Exception.Message) -ForegroundColor DarkYellow
+    }
+}
+
+function Get-HttpResult {
+    param(
+        [string]$Url,
+        [int]$TimeoutSec = 45,
+        [string]$HostHeader,
+        [string]$OriginHeader
+    )
+
+    $result = @{
+        Url        = $Url
+        StatusCode = 0
+        Content    = ''
+        MediaType  = ''
+        Error      = $null
+        HostHeader = $HostHeader
+        OriginHeader = $OriginHeader
+    }
+
+    try {
+        if ($HostHeader -or $OriginHeader) {
+            $request = [System.Net.HttpWebRequest]::Create($Url)
+            $request.Method = 'GET'
+            $request.Timeout = [Math]::Max(1000, $TimeoutSec * 1000)
+            $request.AllowAutoRedirect = $true
+            if ($HostHeader) {
+                $request.Host = $HostHeader
+            }
+            if ($OriginHeader) {
+                $request.Headers['Origin'] = $OriginHeader
+            }
+            $response = $request.GetResponse()
+            $result.StatusCode = [int]$response.StatusCode
+            $result.MediaType = [string]$response.ContentType
+            $stream = $response.GetResponseStream()
+            if ($stream) {
+                $reader = New-Object System.IO.StreamReader($stream)
+                $result.Content = $reader.ReadToEnd()
+                $reader.Close()
+            }
+            $response.Close()
+        }
+        else {
+            $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec $TimeoutSec -MaximumRedirection 5
+            $result.StatusCode = [int]$response.StatusCode
+            $result.Content = [string]$response.Content
+            if ($response.Headers['Content-Type']) {
+                $result.MediaType = [string]$response.Headers['Content-Type']
+            }
+        }
+    }
+    catch {
+        $result.Error = $_.Exception.Message
+        $errResponse = $null
+        $ex = $_.Exception
+        while ($null -ne $ex) {
+            if ($ex -is [System.Net.WebException]) {
+                $errResponse = $ex.Response
+                break
+            }
+            $ex = $ex.InnerException
+        }
+        if ($null -ne $errResponse) {
+            try {
+                $result.StatusCode = [int]$errResponse.StatusCode
+            }
+            catch {
+            }
+            try {
+                $stream = $errResponse.GetResponseStream()
+                if ($null -ne $stream) {
+                    $reader = New-Object System.IO.StreamReader($stream)
+                    $result.Content = $reader.ReadToEnd()
+                    $reader.Close()
+                }
+            }
+            catch {
+            }
+        }
+    }
+
+    return $result
+}
+
+function Write-TestResult {
+    param(
+        [bool]$Passed,
+        [string]$Name,
+        [string]$Detail
+    )
+
+    if ($Passed) {
+        Write-Host ("  PASS  {0}  {1}" -f $Name, $Detail) -ForegroundColor Green
+    }
+    else {
+        Write-Host ("  FAIL  {0}  {1}" -f $Name, $Detail) -ForegroundColor Red
+    }
+}
+
+Test-DockerAvailable
+
+if ($null -ne $websiteUri) {
+    Ensure-LocalHostsEntry -HostName $websiteUri.Host -IpAddress '127.0.0.1'
+}
+
+& docker image inspect $imageTag 2>$null | Out-Null
+if ($LASTEXITCODE -ne 0) {
+    throw "Image '$imageTag' was not found. Run .\test\New-CmTestDockerfile.ps1 -Framework $Framework -CmServerIp  first."
+}
+
+$discoveryUri = Get-ImageDiscoveryUri -ImageTag $imageTag
+if (-not $CmServerIp) {
+    if ($discoveryUri -and $discoveryUri.Host) {
+        $parsed = $null
+        if ([System.Net.IPAddress]::TryParse($discoveryUri.Host, [ref]$parsed)) {
+            $CmServerIp = $discoveryUri.Host
+        }
+    }
+}
+if (-not $CmServerIp) {
+    throw 'Provide -CmServerIp (or rebuild the image with New-CmTestDockerfile.ps1 so Dxa__Services__Discovery contains the CM IP).'
+}
+
+$discoveryPort = 8082
+if ($discoveryUri -and $discoveryUri.Port -gt 0) {
+    $discoveryPort = $discoveryUri.Port
+}
+$discoveryBase = "http://${CmServerIp}:${discoveryPort}/discovery.svc"
+$tokenUrl = "http://${CmServerIp}:${discoveryPort}/token.svc"
+
+Write-Host "Discovery: $discoveryBase" -ForegroundColor DarkGray
+$discoveryInfo = Get-DiscoveryCapabilityInfo -DiscoveryBaseUrl $discoveryBase -TokenUrl $tokenUrl
+$capabilityHosts = @($discoveryInfo.Hosts)
+$dockerHostNames = New-Object System.Collections.Generic.HashSet[string]
+$addHostArgs = @()
+foreach ($capabilityHost in $capabilityHosts) {
+    $parsedHost = $null
+    $isIp = [System.Net.IPAddress]::TryParse($capabilityHost, [ref]$parsedHost)
+    if ($isIp -or $capabilityHost -eq 'localhost' -or $capabilityHost -eq '127.0.0.1') {
+        continue
+    }
+    if ($dockerHostNames.Add($capabilityHost)) {
+        Write-Host ("Mapping Discovery host {0} -> {1} (docker --add-host)" -f $capabilityHost, $CmServerIp) -ForegroundColor Cyan
+        $addHostArgs += '--add-host'
+        $addHostArgs += "${capabilityHost}:$CmServerIp"
+    }
+}
+if ($null -ne $websiteUri -and $dockerHostNames.Add($websiteUri.Host)) {
+    Write-Host ("Mapping Topology website host {0} -> {1} (docker --add-host)" -f $websiteUri.Host, $CmServerIp) -ForegroundColor Cyan
+    $addHostArgs += '--add-host'
+    $addHostArgs += "$($websiteUri.Host):$CmServerIp"
+}
+if ($addHostArgs.Count -eq 0) {
+    Write-Host 'No extra Docker host mappings (Discovery URLs already use an IP or localhost).' -ForegroundColor DarkGray
+}
+
+$topologyHeaders = @{
+    HostHeader   = $null
+    OriginHeader = $null
+}
+if ($null -ne $websiteUri) {
+    $topologyHeaders = Get-HeadersFromWebsiteUrl -WebsiteUri $websiteUri
+    Write-Host ("Topology website {0} -> Host '{1}', Origin '{2}'" -f $WebsiteUrl, $topologyHeaders.HostHeader, $topologyHeaders.OriginHeader) -ForegroundColor Cyan
+}
+
+$existing = & docker ps -aq --filter "name=^/${containerName}$"
+if (-not $existing) {
+    $existing = & docker ps -aq --filter "name=$containerName"
+}
+if ($existing) {
+    Write-Host "Removing existing container $containerName..." -ForegroundColor DarkGray
+    & docker rm -f $containerName | Out-Null
+}
+
+Write-Host ("Starting {0} as {1} (host port {2} -> container 8080)..." -f $imageTag, $containerName, $HostPort) -ForegroundColor Cyan
+$containerId = & docker run -d --name $containerName -p "${HostPort}:8080" `
+    -e Dxa__PreferOriginHeaderForLocalizationResolver=true `
+    -e ASPNETCORE_ENVIRONMENT=Development `
+    @addHostArgs $imageTag
+if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($containerId)) {
+    throw ("docker run failed for {0}. If host port {1} is in use, stop the process bound to it (often IIS on 80) or pass -HostPort. Topology localization requires the browser port to match the website Base URL (default 80, not 8080)." -f $imageTag, $HostPort)
+}
+
+$failed = 0
+$runError = $null
+try {
+    Write-Host "Waiting for the site at $baseUrl ..." -ForegroundColor Cyan
+    $deadline = [DateTime]::UtcNow.AddSeconds($StartupTimeoutSec)
+    $health = $null
+    while ([DateTime]::UtcNow -lt $deadline) {
+        $health = Get-HttpResult -Url "$baseUrl/system/health" -TimeoutSec 5
+        if ($health.StatusCode -eq 200 -and $health.Content -match 'DXA Health Check OK') {
+            break
+        }
+        Start-Sleep -Seconds 2
+    }
+
+    Write-Host 'Connectivity tests' -ForegroundColor Yellow
+
+    $siteUp = $health.StatusCode -eq 200 -and $health.Content -match 'DXA Health Check OK'
+    if ($siteUp) {
+        Write-TestResult -Passed $true -Name 'Site running' -Detail '/system/health returned 200 DXA Health Check OK'
+    }
+    else {
+        $failed++
+        $detail = if ($health.Error) { $health.Error } else { "HTTP $($health.StatusCode)" }
+        Write-TestResult -Passed $false -Name 'Site running' -Detail "/system/health: $detail"
+    }
+
+    $homePage = Get-HttpResult -Url "$baseUrl/" -TimeoutSec 45 -HostHeader $topologyHeaders.HostHeader -OriginHeader $topologyHeaders.OriginHeader
+    if (-not (Test-PageLocalizationOk -HttpResult $homePage) -and [string]::IsNullOrWhiteSpace($WebsiteUrl)) {
+        Write-Host 'Home page missed Topology website URL; trying GraphQL mapping then Origin probes (not port 8080)...' -ForegroundColor DarkYellow
+        $publicationMapping = Get-PublicationMapping -ContentServiceUrl $discoveryInfo.ContentServiceUrl -CmServerIp $CmServerIp -Token $discoveryInfo.Token -CandidateHosts $capabilityHosts
+        $fallbackHeaders = Get-TopologyRequestHeaders -Mapping $publicationMapping -FallbackHost $null
+        $originProbes = New-Object System.Collections.Generic.List[object]
+        [void]$originProbes.Add($fallbackHeaders)
+        if ($null -ne $websiteUri) {
+            $probeHost = $websiteUri.Host
+            [void]$originProbes.Add(@{ HostHeader = $probeHost; OriginHeader = "https://${probeHost}" })
+            [void]$originProbes.Add(@{ HostHeader = $probeHost; OriginHeader = "http://${probeHost}" })
+            [void]$originProbes.Add(@{ HostHeader = "${probeHost}:443"; OriginHeader = "https://${probeHost}:443" })
+            [void]$originProbes.Add(@{ HostHeader = $probeHost; OriginHeader = "http://${probeHost}:80" })
+            [void]$originProbes.Add(@{ HostHeader = "${probeHost}:81"; OriginHeader = "http://${probeHost}:81" })
+        }
+        foreach ($probe in $originProbes) {
+            if ([string]::IsNullOrWhiteSpace($probe.HostHeader) -and [string]::IsNullOrWhiteSpace($probe.OriginHeader)) {
+                continue
+            }
+            if ($probe.OriginHeader -eq $topologyHeaders.OriginHeader -and $probe.HostHeader -eq $topologyHeaders.HostHeader) {
+                continue
+            }
+            $homePage = Get-HttpResult -Url "$baseUrl/" -TimeoutSec 45 -HostHeader $probe.HostHeader -OriginHeader $probe.OriginHeader
+            if (Test-HomePageSuccess -HttpResult $homePage) {
+                $topologyHeaders = $probe
+                Write-Host ("Home page succeeded with Origin '{0}' Host '{1}'" -f $probe.OriginHeader, $probe.HostHeader) -ForegroundColor Cyan
+                break
+            }
+        }
+    }
+    if (Test-HomePageSuccess -HttpResult $homePage) {
+        $hostNote = if ($topologyHeaders.OriginHeader) { " Origin=$($topologyHeaders.OriginHeader)" } elseif ($topologyHeaders.HostHeader) { " Host=$($topologyHeaders.HostHeader)" } else { '' }
+        Write-TestResult -Passed $true -Name 'Home page' -Detail "HTTP $($homePage.StatusCode) HTML from /$hostNote"
+    }
+    elseif (Test-PageLocalizationOk -HttpResult $homePage -and (Test-IsSearchModuleError -Html $homePage.Content) -and -not (Test-HasFatalSectionRenderError -Html $homePage.Content)) {
+        $hostNote = if ($topologyHeaders.OriginHeader) { " Origin=$($topologyHeaders.OriginHeader)" } elseif ($topologyHeaders.HostHeader) { " Host=$($topologyHeaders.HostHeader)" } else { '' }
+        Write-TestResult -Passed $true -Name 'Home page' -Detail "HTTP $($homePage.StatusCode) HTML from /$hostNote (Search:Entity:SearchBox ignored)"
+        Write-Host '  WARN  Home page Search Module errors are ignored.' -ForegroundColor DarkYellow
+    }
+    else {
+        $failed++
+        if (Test-PageLocalizationOk -HttpResult $homePage) {
+            Write-TestResult -Passed $false -Name 'Home page' -Detail 'Page opened but a region failed to render.'
+            Write-SectionRenderErrorLog -PageUrl "$baseUrl/" -Html $homePage.Content -ContainerName $containerName
+        }
+        else {
+            $snippet = ''
+            if ($homePage.Content) {
+                $collapsed = ($homePage.Content -replace '\s+', ' ')
+                $snippet = $collapsed.Substring(0, [Math]::Min(180, $collapsed.Length))
+            }
+            $detail = "HTTP $($homePage.StatusCode) $($homePage.Error) $snippet"
+            if ($homePage.Content -match 'No matching Localization') {
+                $detail += ' Topology website Base URL must match Origin/Host (default http://dxa.tridiondemo.com).'
+            }
+            Write-TestResult -Passed $false -Name 'Home page' -Detail $detail.Trim()
+        }
+    }
+
+    if (Test-PageLocalizationOk -HttpResult $homePage) {
+        $websiteHostName = $null
+        if ($null -ne $websiteUri) {
+            $websiteHostName = $websiteUri.Host
+        }
+        $sitemapFailed = Test-SitemapLinks -LocalBaseUrl $baseUrl -TopologyHeaders $topologyHeaders -WebsiteHost $websiteHostName -HomeHtml $homePage.Content -ContainerName $containerName
+        if ($sitemapFailed -gt 0) {
+            $failed++
+        }
+    }
+
+    $nav = Get-HttpResult -Url "$baseUrl/navigation.json" -TimeoutSec 45 -HostHeader $topologyHeaders.HostHeader -OriginHeader $topologyHeaders.OriginHeader
+    $navJson = $false
+    if ($nav.StatusCode -eq 200 -and $nav.Content) {
+        $trim = $nav.Content.TrimStart()
+        $navJson = $trim.StartsWith('{') -or $trim.StartsWith('[')
+    }
+    if ($navJson) {
+        Write-TestResult -Passed $true -Name 'CD communication' -Detail '/navigation.json returned JSON (Content Service / Discovery)'
+    }
+    else {
+        $failed++
+        $detail = if ($nav.Error) { $nav.Error } else { "HTTP $($nav.StatusCode) (expected JSON from CD)" }
+        Write-TestResult -Passed $false -Name 'CD communication' -Detail "/navigation.json: $detail"
+    }
+}
+catch {
+    $runError = $_.Exception.Message
+    Write-Host ("Start script error: {0}" -f $runError) -ForegroundColor Red
+}
+finally {
+    $cleanup = ($failed -gt 0) -or $RemoveWhenDone -or (-not [string]::IsNullOrWhiteSpace($runError))
+    if ($failed -gt 0 -or -not [string]::IsNullOrWhiteSpace($runError)) {
+        Write-Host ''
+        Write-Host 'Container logs (last 80 lines):' -ForegroundColor DarkYellow
+        & docker logs --tail 80 $containerName
+    }
+
+    if ($cleanup) {
+        Write-Host "Removing container $containerName..." -ForegroundColor DarkGray
+        & docker rm -f $containerName 2>$null | Out-Null
+    }
+    else {
+        Write-Host ("Container {0} is running. Browse {1}" -f $containerName, $browseUrl) -ForegroundColor Cyan
+    }
+}
+
+Write-Host ''
+if ($failed -eq 0 -and [string]::IsNullOrWhiteSpace($runError)) {
+    Write-Host 'Site is running and CD communication succeeded.' -ForegroundColor Green
+    if (-not $RemoveWhenDone) {
+        Write-Host ("Opening default browser: {0}" -f $browseUrl) -ForegroundColor Cyan
+        Start-Process $browseUrl
+    }
+    exit 0
+}
+
+if (-not [string]::IsNullOrWhiteSpace($runError)) {
+    Write-Host ("Start script failed: {0}" -f $runError) -ForegroundColor Red
+    exit 1
+}
+
+Write-Host ("{0} connectivity test(s) failed." -f $failed) -ForegroundColor Red
+exit 1
diff --git a/test/Test-DxaRuntimePorts.ps1 b/test/Test-DxaRuntimePorts.ps1
new file mode 100644
index 0000000..00e5061
--- /dev/null
+++ b/test/Test-DxaRuntimePorts.ps1
@@ -0,0 +1,394 @@
+<#
+.SYNOPSIS
+    Tests TCP ports from CmTestSetup.md against a CM/CD server IP, grouped by section.
+    When Discovery is reachable, lists capability URLs/ports and overrides default ports if they differ.
+
+.PARAMETER CmServerIp
+    IPv4 or IPv6 address of the combined CM/CD host (not a hostname).
+
+.PARAMETER TimeoutMs
+    Connect timeout per port in milliseconds. Default 4000.
+
+.PARAMETER DiscoveryPort
+    Default Discovery TCP/HTTP port before capabilities are read. Default 8082.
+
+.PARAMETER ClientId
+    OAuth client id for Token Service (appsettings Dxa:OAuth:ClientId). Default cduser.
+
+.PARAMETER ClientSecret
+    OAuth client secret for Token Service. Default matches Example WebApp appsettings.
+
+.PARAMETER DxaRuntimeOnly
+    Test only the "Required for DXA runtime" section.
+
+.PARAMETER PassThru
+    Return $true/$false instead of calling exit. Use when invoked from another script.
+
+.EXAMPLE
+    .\test\Test-DxaRuntimePorts.ps1 -CmServerIp 192.0.2.10
+#>
+[CmdletBinding()]
+param(
+    [Parameter(Mandatory = $true, Position = 0)]
+    [string]$CmServerIp,
+
+    [int]$TimeoutMs = 4000,
+
+    [int]$DiscoveryPort = 8082,
+
+    [string]$ClientId = 'cduser',
+
+    [string]$ClientSecret = 'CDUserP@ssw0rd',
+
+    [switch]$DxaRuntimeOnly,
+
+    [switch]$PassThru
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$parsedIp = $null
+if (-not [System.Net.IPAddress]::TryParse($CmServerIp, [ref]$parsedIp)) {
+    throw "CmServerIp must be an IP address. Got: '$CmServerIp'"
+}
+
+# Sections match CmTestSetup.md. Required sections affect the exit code; Optional does not.
+# Capability names match Discovery OData entity sets (used to override default ports).
+$sections = @(
+    @{
+        Name     = 'Required for DXA runtime'
+        Required = $true
+        Ports    = @(
+            @{ Port = $DiscoveryPort; Service = 'Discovery Service (/discovery.svc)'; Capability = 'DiscoveryService' }
+            @{ Port = $DiscoveryPort; Service = 'Token Service (/token.svc)'; Capability = 'TokenService' }
+            @{ Port = 8081; Service = 'Content Service (/content.svc, GraphQL)'; Capability = 'ContentService' }
+            @{ Port = 8083; Service = 'Session-enabled Content Service (XPM preview)'; Capability = 'PreviewWebService' }
+        )
+    },
+    @{
+        Name     = 'Required for publishing (CM on the same box)'
+        Required = $true
+        Ports    = @(
+            @{ Port = 80;   Service = 'IIS (CME, Topology Manager if bound here, Core Service)' }
+            @{ Port = 443;  Service = 'IIS HTTPS (CME, Topology Manager, Core Service)' }
+            @{ Port = 81;   Service = 'Topology Manager (common alternate IIS binding)' }
+            @{ Port = 8084; Service = 'Content Deployer'; Capability = 'Deployer' }
+        )
+    },
+    @{
+        Name     = 'Optional (open only if you use the feature)'
+        Required = $false
+        Ports    = @(
+            @{ Port = 8087; Service = 'Context Engine'; Capability = 'ContextService' }
+            @{ Port = 8097; Service = 'IQ Query'; Capability = 'IQQuery' }
+            @{ Port = 9200; Service = 'Elasticsearch / OpenSearch' }
+            @{ Port = 3389; Service = 'RDP' }
+            @{ Port = 1433; Service = 'SQL Server' }
+        )
+    }
+)
+
+$capabilityQueries = @(
+    @{ Capability = 'DiscoveryService'; Paths = @('DiscoveryServiceCapabilities') }
+    @{ Capability = 'TokenService'; Paths = @('TokenServiceCapabilities') }
+    @{ Capability = 'ContentService'; Paths = @('ContentServiceCapabilities') }
+    @{ Capability = 'PreviewWebService'; Paths = @('PreviewWebServiceCapabilities', 'SessionEnabledContentServiceCapabilities') }
+    @{ Capability = 'Deployer'; Paths = @('DeployerCapabilities', 'DeployerCapability') }
+    @{ Capability = 'ContextService'; Paths = @('ContextServiceCapabilities') }
+    @{ Capability = 'IQQuery'; Paths = @('IQQueryCapabilities') }
+)
+
+function Test-TcpPortOpen {
+    param(
+        [System.Net.IPAddress]$Address,
+        [int]$Port,
+        [int]$TimeoutMs
+    )
+
+    $client = New-Object System.Net.Sockets.TcpClient
+    try {
+        $async = $client.BeginConnect($Address, $Port, $null, $null)
+        if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
+            return $false
+        }
+        $client.EndConnect($async)
+        return $client.Connected
+    }
+    catch {
+        return $false
+    }
+    finally {
+        $client.Close()
+    }
+}
+
+function Get-HttpStatusCode {
+    param($ErrorRecord)
+
+    $ex = $ErrorRecord.Exception
+    if ($ex.Response -and $ex.Response.StatusCode) {
+        return [int]$ex.Response.StatusCode
+    }
+    if ($ex.InnerException -and $ex.InnerException.Response) {
+        return [int]$ex.InnerException.Response.StatusCode
+    }
+    return 0
+}
+
+function Get-ODataUriProperty {
+    param($Item)
+
+    if ($null -eq $Item) {
+        return $null
+    }
+    foreach ($name in @('Uri', 'uri', 'URL', 'url')) {
+        if ($Item.PSObject.Properties.Name -contains $name) {
+            $text = [string]$Item.$name
+            if (-not [string]::IsNullOrWhiteSpace($text)) {
+                return $text
+            }
+        }
+    }
+    return $null
+}
+
+function Invoke-DiscoveryRequest {
+    param(
+        [string]$Url,
+        [string]$Method = 'GET',
+        [string]$Token,
+        [string]$Body
+    )
+
+    $headers = @{
+        Accept           = 'application/json;odata.metadata=minimal'
+        'OData-Version'  = '4.0'
+        'OData-MaxVersion' = '4.0'
+    }
+    if ($Token) {
+        $headers['Authorization'] = "Bearer $Token"
+    }
+
+    $params = @{
+        Uri             = $Url
+        Method          = $Method
+        Headers         = $headers
+        TimeoutSec      = [Math]::Max(1, [Math]::Ceiling($TimeoutMs / 1000.0))
+        UseBasicParsing = $true
+        ErrorAction     = 'Stop'
+    }
+    if ($Method -eq 'POST') {
+        $params['ContentType'] = 'application/x-www-form-urlencoded'
+        $params['Body'] = $Body
+    }
+
+    return Invoke-WebRequest @params
+}
+
+function Get-DiscoveryCapabilities {
+    param(
+        [string]$CmServerIp,
+        [int]$DiscoveryPort,
+        [string]$ClientId,
+        [string]$ClientSecret
+    )
+
+    $result = @{
+        Connected    = $false
+        Capabilities = @()
+    }
+
+    $discoveryBase = "http://${CmServerIp}:${DiscoveryPort}/discovery.svc"
+    $tokenUrl = "http://${CmServerIp}:${DiscoveryPort}/token.svc"
+
+    try {
+        $null = Invoke-DiscoveryRequest -Url $discoveryBase
+        $result.Connected = $true
+    }
+    catch {
+        $status = Get-HttpStatusCode $_
+        if ($status -ge 200) {
+            $result.Connected = $true
+        }
+        else {
+            Write-Host ("Discovery HTTP call failed: {0}" -f $_.Exception.Message) -ForegroundColor DarkYellow
+            return $result
+        }
+    }
+
+    $token = $null
+    try {
+        $tokenBody = "grant_type=client_credentials&client_id=$ClientId&client_secret=$ClientSecret"
+        $tokenResponse = Invoke-DiscoveryRequest -Url $tokenUrl -Method POST -Body $tokenBody
+        $tokenJson = $tokenResponse.Content | ConvertFrom-Json
+        $tokenProp = $tokenJson.PSObject.Properties | Where-Object { $_.Name -eq 'access_token' -or $_.Name -eq 'accessToken' } | Select-Object -First 1
+        if ($tokenProp) {
+            $token = [string]$tokenProp.Value
+        }
+    }
+    catch {
+        Write-Host 'Could not obtain an OAuth token from Token Service. Capability URLs may be unavailable.' -ForegroundColor DarkYellow
+    }
+
+    foreach ($query in $capabilityQueries) {
+        $uriText = $null
+        foreach ($path in $query.Paths) {
+            $url = "$discoveryBase/$path" + '?$top=1'
+            try {
+                $response = Invoke-DiscoveryRequest -Url $url -Token $token
+                $json = $response.Content | ConvertFrom-Json
+                $items = @($json.value)
+                if ($items.Count -gt 0) {
+                    $uriText = Get-ODataUriProperty $items[0]
+                    if ($uriText) {
+                        break
+                    }
+                }
+            }
+            catch {
+                $status = Get-HttpStatusCode $_
+                if ($status -eq 401 -or $status -eq 403) {
+                    Write-Host ("Discovery returned {0} for {1}" -f $status, $path) -ForegroundColor DarkYellow
+                    break
+                }
+            }
+        }
+
+        if (-not $uriText) {
+            continue
+        }
+
+        try {
+            $uri = [Uri]$uriText
+        }
+        catch {
+            continue
+        }
+
+        $port = $uri.Port
+        if ($port -lt 0) {
+            if ($uri.Scheme -eq 'https') { $port = 443 } else { $port = 80 }
+        }
+
+        $result.Capabilities += @{
+            Capability = $query.Capability
+            Url        = $uriText
+            Port       = $port
+            Host       = $uri.Host
+        }
+    }
+
+    return $result
+}
+
+function Set-PortsFromDiscovery {
+    param($Sections, $Capabilities)
+
+    $byName = @{}
+    foreach ($cap in $Capabilities) {
+        $byName[$cap.Capability] = $cap
+    }
+
+    foreach ($section in $Sections) {
+        foreach ($entry in $section.Ports) {
+            if (-not $entry.ContainsKey('Capability')) {
+                continue
+            }
+            if (-not $byName.ContainsKey($entry.Capability)) {
+                continue
+            }
+            $cap = $byName[$entry.Capability]
+            $defaultPort = $entry.Port
+            if ($cap.Port -ne $defaultPort) {
+                Write-Host ("  Override {0}: default TCP {1} -> {2} ({3})" -f $entry.Capability, $defaultPort, $cap.Port, $cap.Url) -ForegroundColor Cyan
+                $entry.Port = $cap.Port
+            }
+            $entry.Service = '{0} [{1}]' -f $entry.Service, $cap.Url
+        }
+    }
+}
+
+if ($DxaRuntimeOnly) {
+    $sections = @($sections | Where-Object { $_.Name -eq 'Required for DXA runtime' })
+}
+
+Write-Host "Testing CmTestSetup ports on $CmServerIp (timeout ${TimeoutMs}ms)" -ForegroundColor Cyan
+Write-Host "DXA web app listen port 8080 is local and is not tested against the CM server." -ForegroundColor DarkGray
+Write-Host ""
+
+$discoveryTcpOpen = Test-TcpPortOpen -Address $parsedIp -Port $DiscoveryPort -TimeoutMs $TimeoutMs
+if ($discoveryTcpOpen) {
+    Write-Host ("Discovery TCP {0} is open. Reading capability URLs..." -f $DiscoveryPort) -ForegroundColor Cyan
+    $discovered = Get-DiscoveryCapabilities -CmServerIp $CmServerIp -DiscoveryPort $DiscoveryPort -ClientId $ClientId -ClientSecret $ClientSecret
+    if ($discovered.Connected -and $discovered.Capabilities.Count -gt 0) {
+        Write-Host ""
+        Write-Host 'Discovery configured URLs and ports:' -ForegroundColor Yellow
+        foreach ($cap in $discovered.Capabilities) {
+            $hostNote = ''
+            if ($cap.Host -eq 'localhost' -or $cap.Host -eq '127.0.0.1') {
+                $hostNote = '  (localhost - DXA on another machine cannot use this host)'
+            }
+            Write-Host ("  {0,-20} {1,-6} {2}{3}" -f $cap.Capability, $cap.Port, $cap.Url, $hostNote)
+        }
+        Write-Host ""
+        Write-Host 'Applying Discovery ports over CmTestSetup defaults where they differ...' -ForegroundColor Cyan
+        Set-PortsFromDiscovery -Sections $sections -Capabilities $discovered.Capabilities
+        Write-Host ""
+    }
+    elseif ($discovered.Connected) {
+        Write-Host 'Discovery responded but returned no capability URLs. Using CmTestSetup default ports.' -ForegroundColor DarkYellow
+        Write-Host ""
+    }
+    else {
+        Write-Host 'Discovery TCP is open but the HTTP service was not usable. Using CmTestSetup default ports.' -ForegroundColor DarkYellow
+        Write-Host ""
+    }
+}
+else {
+    Write-Host ("Discovery TCP {0} is closed. Using CmTestSetup default ports." -f $DiscoveryPort) -ForegroundColor DarkYellow
+    Write-Host ""
+}
+
+$requiredFailed = 0
+$optionalClosed = 0
+
+foreach ($section in $sections) {
+    Write-Host $section.Name -ForegroundColor Yellow
+    foreach ($entry in $section.Ports) {
+        $open = Test-TcpPortOpen -Address $parsedIp -Port $entry.Port -TimeoutMs $TimeoutMs
+        if ($open) {
+            Write-Host ("  OPEN   {0,-5}  {1}" -f $entry.Port, $entry.Service) -ForegroundColor Green
+        }
+        else {
+            Write-Host ("  CLOSED {0,-5}  {1}" -f $entry.Port, $entry.Service) -ForegroundColor Red
+            if ($section.Required) {
+                $requiredFailed++
+            }
+            else {
+                $optionalClosed++
+            }
+        }
+    }
+    Write-Host ""
+}
+
+$success = $requiredFailed -eq 0
+if ($success) {
+    Write-Host 'All required ports are reachable from this machine.' -ForegroundColor Green
+    if ($optionalClosed -gt 0) {
+        Write-Host ("{0} optional port(s) are closed (expected if those features are unused)." -f $optionalClosed) -ForegroundColor DarkYellow
+    }
+}
+else {
+    Write-Host ("{0} required port(s) are not reachable. Check AWS security group DXA-CmTest and Windows Firewall." -f $requiredFailed) -ForegroundColor Red
+    if ($optionalClosed -gt 0) {
+        Write-Host ("{0} optional port(s) are also closed." -f $optionalClosed) -ForegroundColor DarkYellow
+    }
+}
+
+if ($PassThru) {
+    return $success
+}
+
+exit $(if ($success) { 0 } else { 1 })

From ed937ba897088c457336db6a87d60999e8dcf195 Mon Sep 17 00:00:00 2001
From: Indio Giles <33137578+rwsigiles@users.noreply.github.com>
Date: Mon, 24 Aug 2026 21:54:29 +0100
Subject: [PATCH 7/7] Documentation update

---
 NuGetApiToken.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
 README.md        |  5 ++++-
 2 files changed, 50 insertions(+), 1 deletion(-)
 create mode 100644 NuGetApiToken.md

diff --git a/NuGetApiToken.md b/NuGetApiToken.md
new file mode 100644
index 0000000..ca00038
--- /dev/null
+++ b/NuGetApiToken.md
@@ -0,0 +1,46 @@
+# Create a NuGet.org API token
+
+Use a **nuget.org API key** when you push a stable DXA release to the public feed with `Release-Dxa.ps1`. Do not use the default Nexus key from `build.proj` against nuget.org.
+
+Official reference: [Create API keys](https://learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package#create-api-keys).
+
+## Prerequisites
+
+- A [nuget.org](https://www.nuget.org/) account that is allowed to publish `Tridion.Dxa.*` packages (organization membership as required by RWS).
+- Two-factor authentication enabled on that account (required by nuget.org).
+
+## Create the key
+
+1. Sign in at [https://www.nuget.org/](https://www.nuget.org/).
+2. Open **API Keys**: [https://www.nuget.org/account/apikeys](https://www.nuget.org/account/apikeys) (or select your username, then **API Keys**).
+3. Select **Create**.
+4. Fill in:
+   - **Key name** — for example `dxa-core-push-YYYY-MM`.
+   - **Expires** — pick a short lifetime (for example 1 year or less). Rotate before expiry.
+   - **Glob pattern** — `Tridion.Dxa.*` (limits the key to DXA package IDs).
+   - **Select scopes** — enable **Push** (new packages and new versions). Enable **Unlist** only if you must unlist a bad package.
+5. Select **Create**. Copy the key immediately. nuget.org shows the full value **once**.
+
+Store the key in a password manager or a CI secret. Never commit it to git, paste it into `build.proj`, or share it in chat.
+
+## Use the key
+
+Push a verified stable build to nuget.org:
+
+```powershell
+.\Release-Dxa.ps1 -NuGetSource https://api.nuget.org/v3/index.json -ApiKey 
+```
+
+Replace `` with the value you copied. Preview releases (`-Preview`) stay on internal Nexus and must not use nuget.org.
+
+To store the key locally for `dotnet nuget` (optional):
+
+```powershell
+dotnet nuget setapikey  --source https://api.nuget.org/v3/index.json
+```
+
+## If a key is leaked
+
+1. On [API Keys](https://www.nuget.org/account/apikeys), **Regenerate** or **Remove** the leaked key.
+2. Create a new key with the same glob and scopes.
+3. Update any CI secrets or local `setapikey` entries.
diff --git a/README.md b/README.md
index 096d948..d0fbf73 100644
--- a/README.md
+++ b/README.md
@@ -54,9 +54,12 @@ Each package step:
 .\Release-Dxa.ps1 -Preview -NonInteractive
 
 # After Nexus has been verified, re-publish a stable build to public NuGet.org.
+# Create the API key first: see NuGetApiToken.md
 .\Release-Dxa.ps1 -NuGetSource https://api.nuget.org/v3/index.json -ApiKey 
 ```
 
+Create a nuget.org API token before a public push: [NuGetApiToken.md](NuGetApiToken.md).
+
 ### Parameters
 
 | Parameter | Default | Purpose |
@@ -64,7 +67,7 @@ Each package step:
 | `-Version` | `2.4.1` | Version prefix (`VersionPrefix`). Stable packs with empty suffix; `-Preview` appends `preview-{timestamp}`. |
 | `-Preview` | `false` | Pack/push `{Version}-preview-{yyyyMMddHHmmss}` to Nexus only (refuses nuget.org). |
 | `-NuGetSource` | Internal Nexus URL | Target feed for `dotnet nuget push`. |
-| `-ApiKey` | `(from build.proj)` | API key for push. |
+| `-ApiKey` | `(from build.proj)` | API key for push. For nuget.org, create a token as in [NuGetApiToken.md](NuGetApiToken.md). |
 | `-SkipSign` | `false` | Skip `SignAssemblies` target. |
 | `-SkipPush` | `false` | Build & pack only; do not push. |
 | `-DryRun` | `false` | Print commands without executing. |