From d8e70efe6b4831985dd81caf01c9fb0aa7769383 Mon Sep 17 00:00:00 2001 From: Gavin Faux Date: Thu, 14 May 2026 14:00:38 +0100 Subject: [PATCH 1/8] feat: Support separate Articulate 6 and 7 package lanes (Umbraco 18 validation and compatibility) Enable an opt-in lane to validate against Umbraco 18 beta while preserving default Umbraco 16/17 lanes. Key changes: - conditional code paths: add preprocessor guards to support both legacy (Umbraco 16/17) and v18 OpenAPI/Swagger APIs - adapt URL providers/content finders and MasterModel constructors to match API surface changes. - update ArticulateRouteValidatorTests and test mocks to avoid relying on obsolete Children property The Umbraco 18 integration points are not binary-compatible with the Umbraco 16/17 package line, so the build now produces explicit package lanes: - Articulate 6 for Umbraco 16/17 - Articulate 7 for Umbraco 18. Docker validation also follows those lanes so local images restore the matching package artifact instead of accidentally mixing versions. The local Docker flow keeps direct HTTP containers for fast boot smoke tests, but uses Compose plus Caddy for the real backoffice HTTPS path required by OpenIddict in production mode. Constraint: Umbraco 18 OpenAPI/security APIs differ from the Umbraco 17 surface Constraint: OpenIddict backoffice authorize requests require HTTPS in production mode Rejected: Single NuGet package for 16/17/18 | compiled API surface is not binary-compatible Directive: Do not mix package artifacts across lanes; build v6 and v7 packages separately Tested: net9/net10 baseline tests; v18 net10 restore/build/test; Debug package builds for legacy and umbraco18; Docker v18 image build; Compose config and compose-build for v18 HTTPS lane Not-tested: Full interactive Caddy HTTPS backoffice login after compose-up --- .github/workflows/build.yml | 31 ++- DEVELOP.md | 209 ++++++++++++++++-- Directory.Build.props | 11 +- Dockerfile | 3 +- README.md | 18 +- RELEASE_NOTES.md | 10 + build/build.ps1 | 65 +++++- build/build.sh | 75 ++++++- build/docker-lane.ps1 | 156 +++++++++++++ build/docker-lane.sh | 143 ++++++++++++ build/docker-site/ArticulateDockerSite.csproj | 2 +- build/docker-site/README.md | 88 ++++++-- docker-compose.yml | 5 +- src/Articulate.Tests/Articulate.Tests.csproj | 2 +- .../Routing/ArticulateRouteValidatorTests.cs | 32 ++- src/Articulate.Web/Articulate.Web.csproj | 4 +- src/Articulate/Articulate.csproj | 4 +- .../Components/ArticulateApiComposer.cs | 18 +- .../Components/ArticulateComposer.cs | 5 + .../Controllers/MetaWeblogController.cs | 1 - src/Articulate/Models/MasterModel.cs | 11 +- .../Models/PublishedContentExtensions.cs | 6 +- .../Options/ArticulateSwaggerOptions.cs | 59 ++++- .../Routing/ArticulateRouteValidator.cs | 9 +- .../Routing/DateFormattedPostContentFinder.cs | 14 +- .../Routing/DateFormattedUrlProvider.cs | 16 +- .../Swagger/ArticulateOperationIdHandler.cs | 123 +++++++++-- .../ArticulateOperationSecurityFilter.cs | 110 ++++++++- 28 files changed, 1112 insertions(+), 118 deletions(-) create mode 100644 build/docker-lane.ps1 create mode 100644 build/docker-lane.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ddc2409f0..834af0bc4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,10 +67,33 @@ jobs: 10.0.201 - name: Run build pipeline script - run: bash ./build/build.sh + shell: bash + run: | + set -euo pipefail + LEGACY_VERSION="${NBGV_SemVer2}" + UMBRACO18_VERSION="7.${LEGACY_VERSION#*.}" + + ARTICULATE_PACKAGE_LANE=legacy \ + ARTICULATE_PACKAGE_VERSION="$LEGACY_VERSION" \ + PACK_SAMPLE_THEME=true \ + bash ./build/build.sh + + ARTICULATE_PACKAGE_LANE=umbraco18 \ + ARTICULATE_PACKAGE_VERSION="$UMBRACO18_VERSION" \ + PACK_SAMPLE_THEME=true \ + bash ./build/build.sh + + echo "LEGACY_VERSION=$LEGACY_VERSION" >> "$GITHUB_ENV" + echo "UMBRACO18_VERSION=$UMBRACO18_VERSION" >> "$GITHUB_ENV" + + - name: Upload v6 packages + uses: actions/upload-artifact@v7.0.1 + with: + name: Articulate-v6-${{ env.LEGACY_VERSION }} + path: build/Release/legacy/Articulate.* - - name: Upload packages + - name: Upload v7 packages uses: actions/upload-artifact@v7.0.1 with: - name: Articulate.${{ env.NBGV_SemVer2 }} - path: ${{ env.RELEASE_FOLDER }}/Articulate.* + name: Articulate-v7-${{ env.UMBRACO18_VERSION }} + path: build/Release/umbraco18/Articulate.* diff --git a/DEVELOP.md b/DEVELOP.md index a80f680cc..ff00bed83 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -56,22 +56,199 @@ pnpm run generate:api - `BUILD_CONFIGURATION=Debug` is the default for local builds; Release is the default in packaging flows. - `ENABLE_CLIENT_BUILD=true` enables local TypeScript Back Office client builds. - `PACK_SAMPLE_THEME=true` forces packing `Articulate.Theme.Sample`; local builds pack it by default, but CI skips it unless explicitly enabled. -- The scripts clean, restore, build, and pack the current Articulate projects for .NET 9 and .NET 10. -- The packable NuGet package is produced by `src/Articulate.Web/Articulate.Web.csproj` (`PackageId=Articulate`). Packages are written under `build/$(Configuration)` by default. -- If you change packaged runtime dependencies or client/static assets, regenerate the Docker inputs before validating source-built or Docker-based installs: - - `dotnet pack src/Articulate.Web/Articulate.Web.csproj -c Release` - - `dotnet pack src/Articulate.Theme.Sample/Articulate.Theme.Sample.csproj -c Release` -- Keep `Articulate` and `Articulate.Theme.Sample` at the same package version in `build/Release`; the Docker site restores both using the selected Articulate package version. -- The Dockerfile selects the newest `Articulate.[0-9]*.nupkg` in `build/Release` by modified time and ignores `.snupkg` files and theme packages when choosing the version. -- Rebuilding the image is not enough on its own. A running Compose service can remain on an older image/container. Use `docker compose up -d --build --force-recreate articulate`, or run both steps explicitly: - - `docker compose build articulate` - - `docker compose up -d --force-recreate --no-deps articulate` -- The expected image tag is `articulate-local:net10`; the Compose container name will still be project/service based, for example `articulate-pr-articulate-1`. -- If the Docker back office still appears stale after a rebuild, check the running container, not just the image: - - `docker compose ps` - - `docker exec articulate-pr-articulate-1 /bin/sh -c "find /app -path '*App_Plugins/Articulate/BackOffice/articulate-backoffice.js' -o -path '*App_Plugins/Articulate/umbraco-package.json'"` - - `Invoke-WebRequest https://localhost:18443/App_Plugins/Articulate/BackOffice/articulate-backoffice.js -SkipCertificateCheck` -- The default unattended Docker backoffice user is `admin@localhost` with password `@rticulate` and display name `Jane Doe`. Override with `UMBRACO_USER_NAME`, `UMBRACO_USER_EMAIL`, and `UMBRACO_USER_PASSWORD` when needed. +- The scripts clean, restore, build, and pack one package lane at a time. The default lane is `legacy`. +- Running the local build script once does not produce both lanes; run it once with `ARTICULATE_PACKAGE_LANE=legacy` and once with `ARTICULATE_PACKAGE_LANE=umbraco18` when you need both NuGet package sets locally. +- The packable NuGet package is produced by `src/Articulate.Web/Articulate.Web.csproj` (`PackageId=Articulate`). +- Packages are written under `build/$(Configuration)/$(ARTICULATE_PACKAGE_LANE)`. + +### Package Lanes + +The source tree supports two package lanes: + +| Lane | Package line | Umbraco support | Target frameworks | Output folder | +| --- | --- | --- | --- | --- | +| `legacy` | Articulate 6.x | Umbraco 16/17 | `net9.0`, `net10.0` | `build/Release/legacy` | +| `umbraco18` | Articulate 7.x | Umbraco 18 | `net10.0` | `build/Release/umbraco18` | + +The lanes produce separate NuGet packages because the compiled Umbraco 17 and Umbraco 18 extension points are not binary-compatible. Do not install an Articulate 6 package into Umbraco 18, or an Articulate 7 package into Umbraco 16/17. + +Build the Articulate 6 lane: + +PowerShell: + +```powershell +$env:ARTICULATE_PACKAGE_LANE='legacy' +$env:ARTICULATE_PACKAGE_VERSION='6.0.0-rc.2' +$env:PACK_SAMPLE_THEME='true' +./build/build.ps1 +``` + +Bash: + +```bash +ARTICULATE_PACKAGE_LANE=legacy \ +ARTICULATE_PACKAGE_VERSION=6.0.0-rc.2 \ +PACK_SAMPLE_THEME=true \ +./build/build.sh +``` + +Build the Articulate 7 / Umbraco 18 lane: + +PowerShell: + +```powershell +$env:ARTICULATE_PACKAGE_LANE='umbraco18' +$env:ARTICULATE_PACKAGE_VERSION='7.0.0-rc.2' +$env:PACK_SAMPLE_THEME='true' +./build/build.ps1 +``` + +Bash: + +```bash +ARTICULATE_PACKAGE_LANE=umbraco18 \ +ARTICULATE_PACKAGE_VERSION=7.0.0-rc.2 \ +PACK_SAMPLE_THEME=true \ +./build/build.sh +``` + +The build scripts temporarily patch `version.json` when `ARTICULATE_PACKAGE_VERSION` is set, then restore it before exiting. This keeps Nerdbank.GitVersioning as the source of package metadata while allowing one checkout to produce the Articulate 6 and 7 package lines. + +## Local Docker Validation + +Docker is a local validation tool. GitHub Actions builds package artifacts but does not run Docker. + +The lane wrappers have two modes: + +- `up` starts a direct HTTP container. This is useful as a fast package/install boot smoke test. +- `compose-up` starts the Caddy stack with server-side HTTPS enabled. Use this for real backoffice login/auth testing. + +The direct HTTP mode will not complete the backoffice OpenID Connect authorize flow in production mode; OpenIddict rejects the HTTP authorize request with `ID2083` because the server only accepts HTTPS requests. + +Build and start the Umbraco 17 direct HTTP smoke container from the Articulate 6 lane: + +PowerShell: + +```powershell +pwsh -File build/docker-lane.ps1 -Lane legacy -Action up +``` + +Bash: + +```bash +./build/docker-lane.sh legacy up +``` + +Build and start the Umbraco 18 direct HTTP smoke container from the Articulate 7 lane: + +PowerShell: + +```powershell +pwsh -File build/docker-lane.ps1 -Lane umbraco18 -Action up +``` + +Bash: + +```bash +./build/docker-lane.sh umbraco18 up +``` + +The direct wrapper mode builds the correct image from the selected package lane and starts containers on fixed ports: + +| Lane | Image | Container | URL | +| --- | --- | --- | --- | +| `legacy` | `articulate-local:umbraco17` | `articulate-umbraco17` | `http://localhost:18017/umbraco` | +| `umbraco18` | `articulate-local:umbraco18` | `articulate-umbraco18` | `http://localhost:18018/umbraco` | + +The Docker wrappers expect both `Articulate` and `Articulate.Theme.Sample` packages in the selected lane folder. Rebuild with `PACK_SAMPLE_THEME=true` if the wrapper reports that the sample theme package is missing. + +The default unattended Docker backoffice credentials are: + +- Name: `Jane Doe` +- Password: `@rticulate` +- Email: `admin17@localhost` for the legacy lane +- Email: `admin18@localhost` for the Umbraco 18 lane + +### HTTPS / Caddy Compose Path + +Use `compose-up` for the full local HTTPS experience. The wrapper passes the selected package lane, Umbraco version, public HTTPS URLs, unattended user email, image tag, and lane-specific volume prefix into `docker compose`. + +Articulate 6 / Umbraco 17: + +PowerShell: + +```powershell +pwsh -File build/docker-lane.ps1 -Lane legacy -Action compose-up +``` + +Bash: + +```bash +./build/docker-lane.sh legacy compose-up +``` + +Articulate 7 / Umbraco 18: + +PowerShell: + +```powershell +pwsh -File build/docker-lane.ps1 -Lane umbraco18 -Action compose-up +``` + +Bash: + +```bash +./build/docker-lane.sh umbraco18 compose-up +``` + +Compose exposes Caddy at `https://localhost:18443/umbraco`. It runs one selected HTTPS lane at a time because both lanes bind port `18443`; use `compose-down` for the active lane before switching. The wrapper keeps Umbraco data/media volumes separate per lane so v17 and v18 databases are not reused across lanes. + +## Opt-in Umbraco 18 beta validation (net10 only) + +Default source validation lanes remain unchanged (`net9.0` => Umbraco 16, `net10.0` => Umbraco 17 stable). + +Use explicit version pinning to validate Umbraco 18 beta locally. + +OpenAPI note: + +- Umbraco 16/17 lane uses legacy SwaggerGen/operation filter registration. +- Umbraco 18 lane uses native OpenAPI transformers for Articulate operation IDs and security requirements. + +Baseline (`net10.0` + Umbraco 17 stable): + +PowerShell: + +```powershell +dotnet restore .\src\Articulate.sln -p:UmbracoCmsPackageVersion=17.2.2 +dotnet build .\src\Articulate.sln -c Debug -f net10.0 --no-restore -p:UmbracoCmsPackageVersion=17.2.2 +dotnet test .\src\Articulate.sln -c Debug -f net10.0 --no-build --no-restore -p:UmbracoCmsPackageVersion=17.2.2 +``` + +Bash: + +```bash +dotnet restore ./src/Articulate.sln -p:UmbracoCmsPackageVersion=17.2.2 +dotnet build ./src/Articulate.sln -c Debug -f net10.0 --no-restore -p:UmbracoCmsPackageVersion=17.2.2 +dotnet test ./src/Articulate.sln -c Debug -f net10.0 --no-build --no-restore -p:UmbracoCmsPackageVersion=17.2.2 +``` + +PowerShell: + +```powershell +dotnet restore .\src\Articulate.sln -p:UmbracoCmsPackageVersion=18.0.0-beta2 +dotnet build .\src\Articulate.sln -c Debug -f net10.0 --no-restore -p:UmbracoCmsPackageVersion=18.0.0-beta2 +dotnet test .\src\Articulate.sln -c Debug -f net10.0 --no-build --no-restore -p:UmbracoCmsPackageVersion=18.0.0-beta2 +dotnet run -f net10.0 --project .\src\Articulate.Tests.Website\Articulate.Tests.Website.csproj -p:UmbracoCmsPackageVersion=18.0.0-beta2 +``` + +Bash: + +```bash +dotnet restore ./src/Articulate.sln -p:UmbracoCmsPackageVersion=18.0.0-beta2 +dotnet build ./src/Articulate.sln -c Debug -f net10.0 --no-restore -p:UmbracoCmsPackageVersion=18.0.0-beta2 +dotnet test ./src/Articulate.sln -c Debug -f net10.0 --no-build --no-restore -p:UmbracoCmsPackageVersion=18.0.0-beta2 +dotnet run -f net10.0 --project ./src/Articulate.Tests.Website/Articulate.Tests.Website.csproj -p:UmbracoCmsPackageVersion=18.0.0-beta2 +``` ## Back Office Client Builds diff --git a/Directory.Build.props b/Directory.Build.props index 039591e7d..e268486dc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -27,9 +27,18 @@ [2.3.0,3.0.0) [5.3.0,6.0.0) [10.0.5,11.0.0) - [10.0.6,11.0.0) + [10.0.7,11.0.0) [4.16.0,5.0.0) 3.1.3 + [1.1.3,2.0.0) + [18.5.1,19.0.0) + + + $(DefineConstants);UMBRACO_18_OR_GREATER + + + [0.44.0,1.0.0) + [18.0.1,19.0.0) diff --git a/Dockerfile b/Dockerfile index 816034195..c932e05ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /src ARG UMBRACO_CMS_VERSION="[17.2.2,18.0.0)" ARG BUILD_CONFIGURATION=Release ARG PACKAGE_DIR=/articulate-packages +ARG PACKAGE_SOURCE=build/Release/legacy # Copy config files COPY global.json ./ @@ -18,7 +19,7 @@ COPY build/docker-site/Program.cs build/docker-site/ COPY build/docker-site/appsettings.json build/docker-site/ COPY build/docker-site/appsettings.Container.json build/docker-site/ COPY build/docker-site/nuget.config build/docker-site/ -COPY build/Release/ build/Release/ +COPY ${PACKAGE_SOURCE}/ build/Release/ # Copy nupkg to packages folder and extract version, then restore and publish RUN set -eux; \ diff --git a/README.md b/README.md index 8f1642b4c..4de7a0184 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,21 @@ _Need help?_ Head over to [Articulate on GitHub](https://github.com/Shazwazza/Ar ### Umbraco 16 (NET 9) & 17 (NET 10) (current track) -Articulate 6 targets Umbraco 16.5.1+ and 17.2.2+ +Articulate 6 targets Umbraco 16.5.1+ and 17.2.2+. It does not target Umbraco 18. - Install `Articulate` from NuGet (`dotnet add package Articulate`). The package includes the backoffice extension and static assets; no extra package references or manual copies required. - When building from source, run the test site `dotnet run -f net9.0 --project src/Articulate.Tests.Website/Articulate.Tests.Website.csproj` (or `-f net10.0` for Umbraco 17) and sign into the Umbraco Back Office to finish setup. - Migrating from 5.x: in place upgrade or export BlogML from your Articulate 5 site and import it into Articulate 6; media in `media/articulate` is not auto-migrated. During import you can map `postImage` to base64 or an attachment; other inline images must be moved manually (copy the folder, or consider an in-place package upgrade). +### Umbraco 18 beta (NET 10) opt-in + +Articulate 7 targets Umbraco 18 on `net10.0`. + +- Articulate 6.x packages are for Umbraco 16/17. +- Articulate 7.x packages are for Umbraco 18. +- The source tree can build both package lanes, but the resulting NuGet packages are separate because the compiled Umbraco 17 and Umbraco 18 integration points are not binary-compatible. +- See `DEVELOP.md` for lane build and local Docker commands. + #### Rich Text Editor upgrade behavior On Umbraco 16/17, Articulate will migrate the built-in `Umbraco.RichText` property editor to `Umb.PropertyEditorUi.TipTap` during package upgrade only if the TinyMCE editor UI is actually registered. @@ -246,6 +255,13 @@ Built-in themes render Disqus comments only when both post comments are enabled - Articulate 5.x (maintenance): Umbraco 13 LTS (security support through Dec 2025, EOL Dec 2026) - Articulate 6.x (current): Umbraco 16.5.1+ on .NET 9; Umbraco 17.2.2+ on .NET 10 +- Articulate 7.x (Umbraco 18 track): Umbraco 18+ on .NET 10 + +### Umbraco 18 beta validation (opt-in) + +This branch enables opt-in validation against Umbraco 18 beta on `net10.0` using the Articulate 7 package lane. + +See [DEVELOP.md](DEVELOP.md) for package-lane build commands and local Docker usage. ## [Documentation](https://github.com/Shazwazza/Articulate/wiki) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 69882ac7a..0dcdefe94 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,15 @@ # Articulate Release Notes +## PR branch notes (Umbraco 18 beta validation) + +- Default support lanes remain unchanged in this branch: + - .NET 9 lane targets Umbraco 16. + - .NET 10 lane defaults to Umbraco 17 stable. +- Umbraco 18 beta validation is available as an **opt-in** workflow using explicit version pin overrides: + - `-p:UmbracoCmsPackageVersion=18.0.0-beta2` for local restore/build/test and `Articulate.Tests.Website` runs on `net10.0`. + - `UMBRACO_CMS_VERSION=18.0.0-beta2` for Docker validation. +- Validation/reference source used for API compatibility checks: `E:\ext\Umbraco-CMS`. + ## Version 6.0.0 ### Breaking Changes diff --git a/build/build.ps1 b/build/build.ps1 index 7d133216a..ab38e38a7 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -3,16 +3,22 @@ # ENABLE_CLIENT_BUILD=true pwsh -NoLogo -File build/build.ps1 # RUN_TESTS=true pwsh -NoLogo -File build/build.ps1 # PACK_SAMPLE_THEME=true pwsh -NoLogo -File build/build.ps1 +# ARTICULATE_PACKAGE_LANE=umbraco18 ARTICULATE_PACKAGE_VERSION=7.0.0-rc.1 pwsh -NoLogo -File build/build.ps1 # Release builds enable the client build by default so packaged assets carry the stamped version. $ScriptStart = Get-Date $PSScriptFilePath = Get-Item $MyInvocation.MyCommand.Path $RepoRoot = $PSScriptFilePath.Directory.Parent.FullName $BuildFolder = Join-Path -Path $RepoRoot -ChildPath "build" $Configuration = if ([string]::IsNullOrWhiteSpace($env:BUILD_CONFIGURATION)) { "Release" } else { $env:BUILD_CONFIGURATION } -$ReleaseFolder = Join-Path -Path $BuildFolder -ChildPath $Configuration +$ReleaseRoot = Join-Path -Path $BuildFolder -ChildPath $Configuration +$PackageLane = if ([string]::IsNullOrWhiteSpace($env:ARTICULATE_PACKAGE_LANE)) { "legacy" } else { $env:ARTICULATE_PACKAGE_LANE.ToLowerInvariant() } +if ($PackageLane -notin @("legacy", "umbraco18")) { + throw "Unsupported ARTICULATE_PACKAGE_LANE '$PackageLane'. Expected 'legacy' or 'umbraco18'." +} +$ReleaseFolder = Join-Path -Path $ReleaseRoot -ChildPath $PackageLane $SolutionRoot = Join-Path -Path $RepoRoot -ChildPath "src" $SolutionPath = Join-Path -Path $SolutionRoot -ChildPath "Articulate.sln" -$TargetFrameworks = @("net9.0", "net10.0") +$TargetFrameworks = if ($PackageLane -eq "umbraco18") { @("net10.0") } else { @("net9.0", "net10.0") } $PSMajorVersion = $PSVersionTable.PSVersion.Major $SupportsParallel = $PSMajorVersion -ge 7 if (-not $SupportsParallel) { @@ -44,6 +50,9 @@ if ($env:MAXCPU -and ($env:MAXCPU -as [int]) -gt 0) { $cpu = [int]$env:MAXCPU } $msbuildArgs = @("-m", "-maxcpucount:$cpu", "-p:BuildInParallel=true", "-p:RestoreUseStaticGraphEvaluation=true") +if ($PackageLane -eq "umbraco18") { + $msbuildArgs = @("-m", "-maxcpucount:$cpu", "-p:BuildInParallel=true") +} $runningInCi = ($env:CI -eq 'true') -or ($env:GITHUB_ACTIONS -eq 'true') if ([string]::IsNullOrEmpty($env:RUN_TESTS)) { $runTests = $runningInCi @@ -58,10 +67,48 @@ else { $clientBuildValue = $env:ENABLE_CLIENT_BUILD } $clientBuildProperty = "-p:EnableClientBuild=$clientBuildValue" +$laneProperties = @("-p:ArticulatePackageLane=$PackageLane") +if ($PackageLane -eq "umbraco18") { + $laneProperties += @( + "-p:TargetFramework=net10.0", + "-p:UmbracoCmsPackageVersion=18.0.0-beta2" + ) +} +if (-not [string]::IsNullOrWhiteSpace($env:ARTICULATE_PACKAGE_VERSION)) { + $laneProperties += @( + "-p:Version=$env:ARTICULATE_PACKAGE_VERSION", + "-p:PackageVersion=$env:ARTICULATE_PACKAGE_VERSION" + ) +} +$packProperties = @($laneProperties) +if ($PackageLane -eq "umbraco18") { + $packProperties += "-p:TargetFrameworks=net10.0" +} $packSampleTheme = ($env:PACK_SAMPLE_THEME -eq 'true') -or ([string]::IsNullOrEmpty($env:PACK_SAMPLE_THEME) -and -not $runningInCi) $dotnetCommon = @("-v", "minimal") Write-Host "Using up to $cpu parallel MSBuild nodes" Write-Host "Build configuration: $Configuration" +Write-Host "Package lane: $PackageLane" +Write-Host "Package output: $ReleaseFolder" + +$script:versionJsonPath = Join-Path -Path $RepoRoot -ChildPath "version.json" +$script:originalVersionJson = $null +function Restore-VersionJson { + if ($null -ne $script:originalVersionJson) { + Set-Content -LiteralPath $script:versionJsonPath -Value $script:originalVersionJson -NoNewline + } +} +trap { + Restore-VersionJson + break +} + +if (-not [string]::IsNullOrWhiteSpace($env:ARTICULATE_PACKAGE_VERSION)) { + $script:originalVersionJson = Get-Content -LiteralPath $script:versionJsonPath -Raw + $updatedVersionJson = $script:originalVersionJson -replace '("version"\s*:\s*")[^"]+(")', "`${1}$env:ARTICULATE_PACKAGE_VERSION`${2}" + Set-Content -LiteralPath $script:versionJsonPath -Value $updatedVersionJson -NoNewline + Write-Host "Temporarily using package version: $env:ARTICULATE_PACKAGE_VERSION" +} # Friendly note if running on Windows against a WSL filesystem (\\wsl$ UNC) if ($RepoRoot.StartsWith("\\\\wsl$", [System.StringComparison]::OrdinalIgnoreCase) -or @@ -78,12 +125,12 @@ dotnet --version # 1) Clean the solution to ensure release/CI builds start from a fresh slate Write-Host "1. Cleaning solution outputs..." -& dotnet clean $SolutionPath -c $Configuration @dotnetCommon $clientBuildProperty +& dotnet clean $SolutionPath -c $Configuration @dotnetCommon $clientBuildProperty @laneProperties if (-not $?) { Write-Host "Warning dotnet clean failed" } # 2) Restore (solution-level) Write-Host "2. Restoring solution packages in parallel..." -& dotnet restore $SolutionPath @dotnetCommon @msbuildArgs $clientBuildProperty +& dotnet restore $SolutionPath @dotnetCommon @msbuildArgs $clientBuildProperty @laneProperties if (-not $?) { throw "dotnet restore failed" } # 3) Build TFMs sequentially to ensure net9.0 (client build) runs before net10.0 @@ -91,7 +138,7 @@ Write-Host "3. Building solution for: $($TargetFrameworks -join ', ')" foreach ($tfm in $TargetFrameworks) { Write-Host "[build] -> $tfm" $sw = [System.Diagnostics.Stopwatch]::StartNew() - & dotnet build $SolutionPath -c $Configuration -f $tfm --no-restore @dotnetCommon @msbuildArgs $clientBuildProperty + & dotnet build $SolutionPath -c $Configuration -f $tfm --no-restore @dotnetCommon @msbuildArgs $clientBuildProperty @laneProperties if ($LASTEXITCODE -ne 0) { throw "dotnet build failed for $tfm" } $sw.Stop() Write-Host "[build] <- $tfm done in $([int]$sw.Elapsed.TotalSeconds)s" @@ -100,7 +147,7 @@ foreach ($tfm in $TargetFrameworks) { # 4) Run tests if ($runTests) { Write-Host "4. Running tests..." - & dotnet test $SolutionPath -c $Configuration --no-restore --no-build @dotnetCommon + & dotnet test $SolutionPath -c $Configuration --no-restore --no-build @dotnetCommon @laneProperties if ($LASTEXITCODE -ne 0) { throw "dotnet test failed" } } else { @@ -125,7 +172,8 @@ if ($SupportsParallel) { $restoreArgs = @("--no-restore") $commonArgs = $using:dotnetCommon $clientBuildSwitch = $using:clientBuildProperty - & dotnet pack -c $using:Configuration $project @restoreArgs -o $using:ReleaseFolder @commonArgs "-p:BuildInParallel=false" $clientBuildSwitch + $laneSwitches = $using:packProperties + & dotnet pack -c $using:Configuration $project @restoreArgs -o $using:ReleaseFolder @commonArgs "-p:BuildInParallel=false" $clientBuildSwitch @laneSwitches if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed for $project" } } -ThrottleLimit $packThrottle -ErrorVariable packErrors if ($packErrors) { throw "One or more pack operations failed: $($packErrors | Out-String)" } @@ -133,9 +181,10 @@ if ($SupportsParallel) { else { foreach ($project in $projectsToPack) { Write-Host "[pack] -> $([IO.Path]::GetFileName($project))" - & dotnet pack -c $Configuration $project --no-restore -o $ReleaseFolder @dotnetCommon "-p:BuildInParallel=false" $clientBuildProperty + & dotnet pack -c $Configuration $project --no-restore -o $ReleaseFolder @dotnetCommon "-p:BuildInParallel=false" $clientBuildProperty @packProperties if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed for $project" } } } $TotalSeconds = (Get-Date) - $ScriptStart +Restore-VersionJson Write-Host ("Build pipeline completed in {0:N1}s. Packages available at {1}" -f $TotalSeconds.TotalSeconds, $ReleaseFolder) diff --git a/build/build.sh b/build/build.sh index 7330fed52..77d8eada4 100644 --- a/build/build.sh +++ b/build/build.sh @@ -4,6 +4,7 @@ # ENABLE_CLIENT_BUILD=true ./build/build.sh # RUN_TESTS=true ./build/build.sh # PACK_SAMPLE_THEME=true ./build/build.sh +# ARTICULATE_PACKAGE_LANE=umbraco18 ARTICULATE_PACKAGE_VERSION=7.0.0-rc.1 ./build/build.sh # Release builds enable the client build by default so packaged assets carry the stamped version. set -euo pipefail @@ -30,10 +31,23 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" BUILD_FOLDER="$REPO_ROOT/build" CONFIGURATION="${BUILD_CONFIGURATION:-Release}" -RELEASE_FOLDER="$BUILD_FOLDER/$CONFIGURATION" +PACKAGE_LANE="${ARTICULATE_PACKAGE_LANE:-legacy}" +case "$PACKAGE_LANE" in + legacy|umbraco18) ;; + *) + echo "Unsupported ARTICULATE_PACKAGE_LANE '$PACKAGE_LANE'. Expected 'legacy' or 'umbraco18'." >&2 + exit 1 + ;; +esac +RELEASE_ROOT="$BUILD_FOLDER/$CONFIGURATION" +RELEASE_FOLDER="$RELEASE_ROOT/$PACKAGE_LANE" SOLUTION_ROOT="$REPO_ROOT/src" SOLUTION_PATH="$SOLUTION_ROOT/Articulate.sln" -TARGET_FRAMEWORKS=("net9.0" "net10.0") +if [[ "$PACKAGE_LANE" == "umbraco18" ]]; then + TARGET_FRAMEWORKS=("net10.0") +else + TARGET_FRAMEWORKS=("net9.0" "net10.0") +fi # Compute CPU parallelism for MSBuild (allow override via MAXCPU) CPU_COUNT=${MAXCPU:-} @@ -41,6 +55,9 @@ if [[ -z "$CPU_COUNT" ]]; then CPU_COUNT=$( (command -v nproc >/dev/null 2>&1 && nproc --all) || getconf _NPROCESSORS_ONLN || echo 8 ) fi MSBUILD_PARALLEL=(-m -maxcpucount:"$CPU_COUNT" -p:BuildInParallel=true -p:RestoreUseStaticGraphEvaluation=true) +if [[ "$PACKAGE_LANE" == "umbraco18" ]]; then + MSBUILD_PARALLEL=(-m -maxcpucount:"$CPU_COUNT" -p:BuildInParallel=true) +fi DOTNET_COMMON=(--nologo -v minimal) # Handle ENABLE_CLIENT_BUILD environment variable (default to true for Release/CI, false otherwise) @@ -51,6 +68,23 @@ else fi CLIENT_BUILD_VALUE=${ENABLE_CLIENT_BUILD:-$CLIENT_BUILD_DEFAULT} CLIENT_BUILD_PROPERTY="-p:EnableClientBuild=$CLIENT_BUILD_VALUE" +LANE_PROPERTIES=("-p:ArticulatePackageLane=$PACKAGE_LANE") +if [[ "$PACKAGE_LANE" == "umbraco18" ]]; then + LANE_PROPERTIES+=( + "-p:TargetFramework=net10.0" + "-p:UmbracoCmsPackageVersion=18.0.0-beta2" + ) +fi +if [[ -n "${ARTICULATE_PACKAGE_VERSION:-}" ]]; then + LANE_PROPERTIES+=( + "-p:Version=$ARTICULATE_PACKAGE_VERSION" + "-p:PackageVersion=$ARTICULATE_PACKAGE_VERSION" + ) +fi +PACK_PROPERTIES=("${LANE_PROPERTIES[@]}") +if [[ "$PACKAGE_LANE" == "umbraco18" ]]; then + PACK_PROPERTIES+=("-p:TargetFrameworks=net10.0") +fi PACK_SAMPLE_THEME_VALUE=${PACK_SAMPLE_THEME:-} if [[ -n "${RUN_TESTS:-}" ]]; then RUN_TESTS_VALUE="$RUN_TESTS" @@ -62,6 +96,33 @@ fi echo "Using up to $CPU_COUNT parallel MSBuild nodes" echo "Build configuration: $CONFIGURATION" +echo "Package lane: $PACKAGE_LANE" +echo "Package output: $RELEASE_FOLDER" + +VERSION_JSON="$REPO_ROOT/version.json" +ORIGINAL_VERSION_JSON="" +restore_version_json() { + if [[ -n "$ORIGINAL_VERSION_JSON" ]]; then + printf '%s' "$ORIGINAL_VERSION_JSON" > "$VERSION_JSON" + fi +} +trap restore_version_json EXIT + +if [[ -n "${ARTICULATE_PACKAGE_VERSION:-}" ]]; then + ORIGINAL_VERSION_JSON="$(cat "$VERSION_JSON")" + python - "$VERSION_JSON" "$ARTICULATE_PACKAGE_VERSION" <<'PY' +import re +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +version = sys.argv[2] +text = path.read_text() +text = re.sub(r'("version"\s*:\s*")[^"]+(")', rf'\g<1>{version}\2', text, count=1) +path.write_text(text) +PY + echo "Temporarily using package version: $ARTICULATE_PACKAGE_VERSION" +fi # Advise when running in WSL against Windows-mounted drives (slow) if [[ $IS_WSL -eq 1 && "$REPO_ROOT" == /mnt/* ]]; then @@ -81,14 +142,14 @@ export RestoreFallbackFolders= # --- 1) Clean the solution so Release/CI builds start fresh --- echo "1. Cleaning solution outputs..." -if ! dotnet clean "$SOLUTION_PATH" -c "$CONFIGURATION" "${DOTNET_COMMON[@]}" "$CLIENT_BUILD_PROPERTY"; then +if ! dotnet clean "$SOLUTION_PATH" -c "$CONFIGURATION" "${DOTNET_COMMON[@]}" "$CLIENT_BUILD_PROPERTY" "${LANE_PROPERTIES[@]}"; then echo "Warning: dotnet clean failed" >&2 fi # --- 2) Solution-level restore --- mkdir -p "$RELEASE_FOLDER" echo "2. Restoring solution packages in parallel..." -if ! dotnet restore "$SOLUTION_PATH" "${DOTNET_COMMON[@]}" "${MSBUILD_PARALLEL[@]}" "$CLIENT_BUILD_PROPERTY"; then +if ! dotnet restore "$SOLUTION_PATH" "${DOTNET_COMMON[@]}" "${MSBUILD_PARALLEL[@]}" "$CLIENT_BUILD_PROPERTY" "${LANE_PROPERTIES[@]}"; then echo "dotnet restore failed" >&2 exit 1 fi @@ -99,7 +160,7 @@ echo "3. Building solution for: ${TARGET_FRAMEWORKS[*]}" for tfm in "${TARGET_FRAMEWORKS[@]}"; do echo "[build] -> $tfm" t0=$(date +%s) - if ! dotnet build "$SOLUTION_PATH" -c "$CONFIGURATION" -f "$tfm" --no-restore "${DOTNET_COMMON[@]}" "${MSBUILD_PARALLEL[@]}" "$CLIENT_BUILD_PROPERTY"; then + if ! dotnet build "$SOLUTION_PATH" -c "$CONFIGURATION" -f "$tfm" --no-restore "${DOTNET_COMMON[@]}" "${MSBUILD_PARALLEL[@]}" "$CLIENT_BUILD_PROPERTY" "${LANE_PROPERTIES[@]}"; then echo "dotnet build failed for $tfm" >&2 exit 1 fi @@ -110,7 +171,7 @@ done # --- 4) Run tests --- if [[ "$RUN_TESTS_VALUE" == "true" ]]; then echo "4. Running tests..." - if ! dotnet test "$SOLUTION_PATH" -c "$CONFIGURATION" --no-restore --no-build "${DOTNET_COMMON[@]}"; then + if ! dotnet test "$SOLUTION_PATH" -c "$CONFIGURATION" --no-restore --no-build "${DOTNET_COMMON[@]}" "${LANE_PROPERTIES[@]}"; then echo "dotnet test failed" >&2 exit 1 fi @@ -134,7 +195,7 @@ for proj in "${PACK_PROJECTS[@]}"; do echo "[pack] -> $(basename "$proj")" RESTORE_SWITCHES=(--no-restore) if ! dotnet pack -c "$CONFIGURATION" "$proj" "${RESTORE_SWITCHES[@]}" -o "$RELEASE_FOLDER" \ - "${DOTNET_COMMON[@]}" -p:BuildInParallel=false "$CLIENT_BUILD_PROPERTY"; then + "${DOTNET_COMMON[@]}" -p:BuildInParallel=false "$CLIENT_BUILD_PROPERTY" "${PACK_PROPERTIES[@]}"; then echo "dotnet pack failed for $proj" >&2 exit 1 fi diff --git a/build/docker-lane.ps1 b/build/docker-lane.ps1 new file mode 100644 index 000000000..3d17b918e --- /dev/null +++ b/build/docker-lane.ps1 @@ -0,0 +1,156 @@ +param( + [ValidateSet("legacy", "umbraco18")] + [string] $Lane = "legacy", + + [ValidateSet("build", "run", "up", "compose-build", "compose-up", "compose-down")] + [string] $Action = "up", + + [string] $Configuration = $(if ([string]::IsNullOrWhiteSpace($env:BUILD_CONFIGURATION)) { "Release" } else { $env:BUILD_CONFIGURATION }) +) + +$ErrorActionPreference = "Stop" +$ScriptStart = Get-Date +$PSScriptFilePath = Get-Item $MyInvocation.MyCommand.Path +$RepoRoot = $PSScriptFilePath.Directory.Parent.FullName + +if ($Lane -eq "umbraco18") { + $umbracoVersion = "18.0.0-beta2" + $imageTag = "articulate-local:umbraco18" + $containerName = "articulate-umbraco18" + $port = 18018 + $email = "admin18@localhost" +} +else { + $umbracoVersion = "[17.2.2,18.0.0)" + $imageTag = "articulate-local:umbraco17" + $containerName = "articulate-umbraco17" + $port = 18017 + $email = "admin17@localhost" +} + +$packageSource = "build/$Configuration/$Lane" +$packagePath = Join-Path $RepoRoot $packageSource +$requiresPackage = $Action -ne "compose-down" +if ($requiresPackage) { + if (-not (Test-Path -LiteralPath $packagePath)) { + throw "Package lane folder '$packagePath' does not exist. Run build/build.ps1 with ARTICULATE_PACKAGE_LANE=$Lane first." + } + if (-not (Get-ChildItem -LiteralPath $packagePath -Filter "Articulate.*.nupkg" | Where-Object { $_.Name -notlike "*.snupkg" })) { + throw "Package lane folder '$packagePath' does not contain an Articulate .nupkg." + } + if (-not (Get-ChildItem -LiteralPath $packagePath -Filter "Articulate.Theme.Sample.*.nupkg")) { + throw "Package lane folder '$packagePath' does not contain Articulate.Theme.Sample. Rebuild with PACK_SAMPLE_THEME=true." + } +} + +function Build-Image { + Write-Host "Building $imageTag from $packageSource with Umbraco $umbracoVersion" + & docker build ` + --build-arg "UMBRACO_CMS_VERSION=$umbracoVersion" ` + --build-arg "BUILD_CONFIGURATION=$Configuration" ` + --build-arg "PACKAGE_SOURCE=$packageSource" ` + -t $imageTag ` + $RepoRoot + if ($LASTEXITCODE -ne 0) { throw "docker build failed for $Lane" } +} + +function Run-Container { + Write-Host "Starting $containerName on http://localhost:$port/umbraco" + Write-Host "Direct HTTP mode is a boot/package smoke test. Use -Action compose-up for backoffice auth over HTTPS." + & docker rm -f $containerName 2>$null | Out-Null + & docker run -d ` + --name $containerName ` + -p "${port}:8080" ` + -e "ASPNETCORE_ENVIRONMENT=Container" ` + -e "ASPNETCORE_URLS=http://+:8080" ` + -e "ConnectionStrings__umbracoDbDSN=Data Source=/app/umbraco/Data/Umbraco.sqlite.db;Cache=Shared;Foreign Keys=True;Pooling=True" ` + -e "ConnectionStrings__umbracoDbDSN_ProviderName=Microsoft.Data.Sqlite" ` + -e "Umbraco__CMS__WebRouting__UmbracoApplicationUrl=http://localhost:$port/" ` + -e "Umbraco__CMS__Security__BackOfficeHost=http://localhost:$port" ` + -e "Umbraco__CMS__Unattended__InstallUnattended=true" ` + -e "Umbraco__CMS__Unattended__UpgradeUnattended=true" ` + -e "Umbraco__CMS__Unattended__UnattendedUserName=Jane Doe" ` + -e "Umbraco__CMS__Unattended__UnattendedUserEmail=$email" ` + -e "Umbraco__CMS__Unattended__UnattendedUserPassword=@rticulate" ` + -e "Umbraco__CMS__ModelsBuilder__ModelsMode=Nothing" ` + $imageTag + if ($LASTEXITCODE -ne 0) { throw "docker run failed for $Lane" } +} + +function Invoke-Compose { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("build", "up", "down")] + [string] $ComposeAction + ) + + $projectName = "articulate-$Lane" + $volumePrefix = "articulate_$Lane" + $oldValues = @{ + BUILD_CONFIGURATION = $env:BUILD_CONFIGURATION + PACKAGE_SOURCE = $env:PACKAGE_SOURCE + UMBRACO_CMS_VERSION = $env:UMBRACO_CMS_VERSION + IMAGE_TAG = $env:IMAGE_TAG + UMBRACO_USER_EMAIL = $env:UMBRACO_USER_EMAIL + COMPOSE_VOLUME_PREFIX = $env:COMPOSE_VOLUME_PREFIX + UMBRACO_PUBLIC_HOST = $env:UMBRACO_PUBLIC_HOST + UMBRACO_PUBLIC_URL = $env:UMBRACO_PUBLIC_URL + ARTICULATE_REDIRECT_URI = $env:ARTICULATE_REDIRECT_URI + ARTICULATE_LOGOUT_REDIRECT_URI = $env:ARTICULATE_LOGOUT_REDIRECT_URI + } + + try { + $env:BUILD_CONFIGURATION = $Configuration + $env:PACKAGE_SOURCE = $packageSource + $env:UMBRACO_CMS_VERSION = $umbracoVersion + $env:IMAGE_TAG = $imageTag + $env:UMBRACO_USER_EMAIL = $email + $env:COMPOSE_VOLUME_PREFIX = $volumePrefix + $env:UMBRACO_PUBLIC_HOST = "https://localhost:18443" + $env:UMBRACO_PUBLIC_URL = "https://localhost:18443/" + $env:ARTICULATE_REDIRECT_URI = "https://localhost:18443/a-new/" + $env:ARTICULATE_LOGOUT_REDIRECT_URI = "https://localhost:18443/" + + switch ($ComposeAction) { + "build" { + Write-Host "Building Compose HTTPS lane $Lane from $packageSource" + & docker compose -p $projectName build articulate + } + "up" { + Write-Host "Starting Compose HTTPS lane $Lane at https://localhost:18443/umbraco" + Write-Host "Only one Compose HTTPS lane can bind port 18443 at a time." + & docker compose -p $projectName up -d --build --force-recreate + } + "down" { + Write-Host "Stopping Compose HTTPS lane $Lane" + & docker compose -p $projectName down + } + } + if ($LASTEXITCODE -ne 0) { throw "docker compose $ComposeAction failed for $Lane" } + } + finally { + foreach ($key in $oldValues.Keys) { + if ($null -eq $oldValues[$key]) { + Remove-Item "Env:$key" -ErrorAction SilentlyContinue + } + else { + Set-Item "Env:$key" $oldValues[$key] + } + } + } +} + +switch ($Action) { + "build" { Build-Image } + "run" { Run-Container } + "up" { + Build-Image + Run-Container + } + "compose-build" { Invoke-Compose -ComposeAction build } + "compose-up" { Invoke-Compose -ComposeAction up } + "compose-down" { Invoke-Compose -ComposeAction down } +} + +$elapsed = (Get-Date) - $ScriptStart +Write-Host ("Docker lane '{0}' {1} completed in {2:N1}s." -f $Lane, $Action, $elapsed.TotalSeconds) diff --git a/build/docker-lane.sh b/build/docker-lane.sh new file mode 100644 index 000000000..1c2af9dc7 --- /dev/null +++ b/build/docker-lane.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +LANE="${1:-${ARTICULATE_PACKAGE_LANE:-legacy}}" +ACTION="${2:-up}" +CONFIGURATION="${BUILD_CONFIGURATION:-Release}" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" + +case "$LANE" in + legacy) + UMBRACO_VERSION="[17.2.2,18.0.0)" + IMAGE_TAG="articulate-local:umbraco17" + CONTAINER_NAME="articulate-umbraco17" + PORT=18017 + EMAIL="admin17@localhost" + ;; + umbraco18) + UMBRACO_VERSION="18.0.0-beta2" + IMAGE_TAG="articulate-local:umbraco18" + CONTAINER_NAME="articulate-umbraco18" + PORT=18018 + EMAIL="admin18@localhost" + ;; + *) + echo "Unsupported lane '$LANE'. Expected 'legacy' or 'umbraco18'." >&2 + exit 1 + ;; +esac + +case "$ACTION" in + build|run|up|compose-build|compose-up|compose-down) ;; + *) + echo "Unsupported action '$ACTION'. Expected 'build', 'run', 'up', 'compose-build', 'compose-up', or 'compose-down'." >&2 + exit 1 + ;; +esac + +PACKAGE_SOURCE="build/$CONFIGURATION/$LANE" +if [[ "$ACTION" != "compose-down" ]]; then + if [[ ! -d "$REPO_ROOT/$PACKAGE_SOURCE" ]]; then + echo "Package lane folder '$REPO_ROOT/$PACKAGE_SOURCE' does not exist. Run build/build.sh with ARTICULATE_PACKAGE_LANE=$LANE first." >&2 + exit 1 + fi + shopt -s nullglob + ARTICULATE_PACKAGES=("$REPO_ROOT/$PACKAGE_SOURCE"/Articulate.[0-9]*.nupkg) + THEME_PACKAGES=("$REPO_ROOT/$PACKAGE_SOURCE"/Articulate.Theme.Sample.*.nupkg) + if [[ ${#ARTICULATE_PACKAGES[@]} -eq 0 ]]; then + echo "Package lane folder '$REPO_ROOT/$PACKAGE_SOURCE' does not contain an Articulate .nupkg." >&2 + exit 1 + fi + if [[ ${#THEME_PACKAGES[@]} -eq 0 ]]; then + echo "Package lane folder '$REPO_ROOT/$PACKAGE_SOURCE' does not contain Articulate.Theme.Sample. Rebuild with PACK_SAMPLE_THEME=true." >&2 + exit 1 + fi +fi + +build_image() { + echo "Building $IMAGE_TAG from $PACKAGE_SOURCE with Umbraco $UMBRACO_VERSION" + docker build \ + --build-arg "UMBRACO_CMS_VERSION=$UMBRACO_VERSION" \ + --build-arg "BUILD_CONFIGURATION=$CONFIGURATION" \ + --build-arg "PACKAGE_SOURCE=$PACKAGE_SOURCE" \ + -t "$IMAGE_TAG" \ + "$REPO_ROOT" +} + +run_container() { + echo "Starting $CONTAINER_NAME on http://localhost:$PORT/umbraco" + echo "Direct HTTP mode is a boot/package smoke test. Use 'compose-up' for backoffice auth over HTTPS." + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + docker run -d \ + --name "$CONTAINER_NAME" \ + -p "$PORT:8080" \ + -e "ASPNETCORE_ENVIRONMENT=Container" \ + -e "ASPNETCORE_URLS=http://+:8080" \ + -e "ConnectionStrings__umbracoDbDSN=Data Source=/app/umbraco/Data/Umbraco.sqlite.db;Cache=Shared;Foreign Keys=True;Pooling=True" \ + -e "ConnectionStrings__umbracoDbDSN_ProviderName=Microsoft.Data.Sqlite" \ + -e "Umbraco__CMS__WebRouting__UmbracoApplicationUrl=http://localhost:$PORT/" \ + -e "Umbraco__CMS__Security__BackOfficeHost=http://localhost:$PORT" \ + -e "Umbraco__CMS__Unattended__InstallUnattended=true" \ + -e "Umbraco__CMS__Unattended__UpgradeUnattended=true" \ + -e "Umbraco__CMS__Unattended__UnattendedUserName=Jane Doe" \ + -e "Umbraco__CMS__Unattended__UnattendedUserEmail=$EMAIL" \ + -e "Umbraco__CMS__Unattended__UnattendedUserPassword=@rticulate" \ + -e "Umbraco__CMS__ModelsBuilder__ModelsMode=Nothing" \ + "$IMAGE_TAG" +} + +compose_command() { + local compose_action="$1" + local project_name="articulate-$LANE" + local volume_prefix="articulate_$LANE" + + case "$compose_action" in + build) + echo "Building Compose HTTPS lane $LANE from $PACKAGE_SOURCE" + BUILD_CONFIGURATION="$CONFIGURATION" \ + PACKAGE_SOURCE="$PACKAGE_SOURCE" \ + UMBRACO_CMS_VERSION="$UMBRACO_VERSION" \ + IMAGE_TAG="$IMAGE_TAG" \ + UMBRACO_USER_EMAIL="$EMAIL" \ + COMPOSE_VOLUME_PREFIX="$volume_prefix" \ + UMBRACO_PUBLIC_HOST="https://localhost:18443" \ + UMBRACO_PUBLIC_URL="https://localhost:18443/" \ + ARTICULATE_REDIRECT_URI="https://localhost:18443/a-new/" \ + ARTICULATE_LOGOUT_REDIRECT_URI="https://localhost:18443/" \ + docker compose -p "$project_name" build articulate + ;; + up) + echo "Starting Compose HTTPS lane $LANE at https://localhost:18443/umbraco" + echo "Only one Compose HTTPS lane can bind port 18443 at a time." + BUILD_CONFIGURATION="$CONFIGURATION" \ + PACKAGE_SOURCE="$PACKAGE_SOURCE" \ + UMBRACO_CMS_VERSION="$UMBRACO_VERSION" \ + IMAGE_TAG="$IMAGE_TAG" \ + UMBRACO_USER_EMAIL="$EMAIL" \ + COMPOSE_VOLUME_PREFIX="$volume_prefix" \ + UMBRACO_PUBLIC_HOST="https://localhost:18443" \ + UMBRACO_PUBLIC_URL="https://localhost:18443/" \ + ARTICULATE_REDIRECT_URI="https://localhost:18443/a-new/" \ + ARTICULATE_LOGOUT_REDIRECT_URI="https://localhost:18443/" \ + docker compose -p "$project_name" up -d --build --force-recreate + ;; + down) + echo "Stopping Compose HTTPS lane $LANE" + COMPOSE_VOLUME_PREFIX="$volume_prefix" docker compose -p "$project_name" down + ;; + esac +} + +case "$ACTION" in + build) build_image ;; + run) run_container ;; + up) + build_image + run_container + ;; + compose-build) compose_command build ;; + compose-up) compose_command up ;; + compose-down) compose_command down ;; +esac diff --git a/build/docker-site/ArticulateDockerSite.csproj b/build/docker-site/ArticulateDockerSite.csproj index 070e850b9..7b882ec7f 100644 --- a/build/docker-site/ArticulateDockerSite.csproj +++ b/build/docker-site/ArticulateDockerSite.csproj @@ -10,6 +10,6 @@ - + diff --git a/build/docker-site/README.md b/build/docker-site/README.md index 16f69b82a..2685a2e31 100644 --- a/build/docker-site/README.md +++ b/build/docker-site/README.md @@ -1,6 +1,55 @@ -# Local HTTPS (Windows) +# Local Docker Site -This repo uses Caddy to terminate TLS for the local Umbraco container. +The repository has two local Docker validation paths: + +- `build/docker-lane.ps1 -Lane legacy -Action up` or `build/docker-lane.sh legacy up` starts an Umbraco 17 direct HTTP smoke container from the Articulate 6 package lane at `http://localhost:18017/umbraco`. +- `build/docker-lane.ps1 -Lane umbraco18 -Action up` or `build/docker-lane.sh umbraco18 up` starts an Umbraco 18 direct HTTP smoke container from the Articulate 7 package lane at `http://localhost:18018/umbraco`. +- `build/docker-lane.ps1 -Lane legacy -Action compose-up` or `build/docker-lane.sh legacy compose-up` starts the Umbraco 17 lane behind Caddy at `https://localhost:18443/umbraco`. +- `build/docker-lane.ps1 -Lane umbraco18 -Action compose-up` or `build/docker-lane.sh umbraco18 compose-up` starts the Umbraco 18 lane behind Caddy at `https://localhost:18443/umbraco`. + +Use direct HTTP mode for fast package/install smoke checks only. In production mode, the backoffice OpenID Connect authorize endpoint requires HTTPS and rejects HTTP requests with OpenIddict `ID2083`. + +Build the package lane before running Docker: + +Running the local build script once produces one lane only. Run it once with `ARTICULATE_PACKAGE_LANE=legacy` and once with `ARTICULATE_PACKAGE_LANE=umbraco18` when you need both NuGet package sets locally. + +PowerShell: + +```powershell +$env:ARTICULATE_PACKAGE_LANE='legacy' +$env:ARTICULATE_PACKAGE_VERSION='6.0.0-rc.2' +$env:PACK_SAMPLE_THEME='true' +./build/build.ps1 + +pwsh -File build/docker-lane.ps1 -Lane legacy -Action compose-up +``` + +```powershell +$env:ARTICULATE_PACKAGE_LANE='umbraco18' +$env:ARTICULATE_PACKAGE_VERSION='7.0.0-rc.2' +$env:PACK_SAMPLE_THEME='true' +./build/build.ps1 + +pwsh -File build/docker-lane.ps1 -Lane umbraco18 -Action compose-up +``` + +Bash: + +```bash +ARTICULATE_PACKAGE_LANE=legacy ARTICULATE_PACKAGE_VERSION=6.0.0-rc.2 PACK_SAMPLE_THEME=true ./build/build.sh +./build/docker-lane.sh legacy compose-up +``` + +```bash +ARTICULATE_PACKAGE_LANE=umbraco18 ARTICULATE_PACKAGE_VERSION=7.0.0-rc.2 PACK_SAMPLE_THEME=true ./build/build.sh +./build/docker-lane.sh umbraco18 compose-up +``` + +The Docker image builds from packaged NuGet artifacts in `build//`, not directly from project output. Keep `Articulate` and `Articulate.Theme.Sample` at the same package version in each lane folder. The Dockerfile ignores `.snupkg` files and theme packages when selecting the Articulate package version. + +## Local HTTPS (Compose + Caddy) + +The Compose path uses Caddy to terminate TLS for a single local Umbraco container at a time. The wrapper commands pass the selected package lane, Umbraco version, HTTPS public URLs, image tag, unattended user email, and lane-specific volume prefix to Compose. The default `Caddyfile` uses `tls internal`, which generates a local CA and a server certificate. Browsers on Windows will show a TLS error until the local CA is trusted. @@ -9,7 +58,8 @@ The default `Caddyfile` uses `tls internal`, which generates a local CA and a se From the repo root: 1. Start containers: - - `docker compose up -d` + - `pwsh -File build/docker-lane.ps1 -Lane legacy -Action compose-up` + - or `pwsh -File build/docker-lane.ps1 -Lane umbraco18 -Action compose-up` 2. Trust Caddy's local root CA (Current User by default): - `powershell -ExecutionPolicy Bypass -File .\build\docker-site\Trust-CaddyRootCA.ps1` @@ -18,7 +68,7 @@ If you need machine-wide trust (admin required): - `powershell -ExecutionPolicy Bypass -File .\build\docker-site\Trust-CaddyRootCA.ps1 -Scope LocalMachine` -Restart your browser and open `https://localhost:18443`. +Restart your browser and open `https://localhost:18443/umbraco`. - The script runs on the host and uses `docker cp` to export Caddy's internal root CA from the running `caddy` container. - No bind mounts are required for certificate export. @@ -28,7 +78,8 @@ Restart your browser and open `https://localhost:18443`. From the repo root: 1. Start containers: - - `docker compose up -d` + - `./build/docker-lane.sh legacy compose-up` + - or `./build/docker-lane.sh umbraco18 compose-up` 2. Trust Caddy's local root CA (system store; requires sudo): - `./build/docker-site/trust-caddy-root-ca.sh` @@ -39,23 +90,22 @@ From the repo root: - If your team policy disallows installing a local root CA, you will need a publicly trusted certificate/domain for local development. - Default unattended backoffice credentials for the Docker site are: - Name: `Jane Doe` - - Email: `admin@localhost` + - Email: `admin17@localhost` for the legacy lane, or `admin18@localhost` for the Umbraco 18 lane - Password: `@rticulate` - Override those defaults with `UMBRACO_USER_NAME`, `UMBRACO_USER_EMAIL`, and `UMBRACO_USER_PASSWORD` before starting the stack if needed. -- The Docker image builds from packaged NuGet artifacts in `build/Release`, not directly from project output. -- Regenerate the package inputs after client/static asset or packaged dependency changes: - - `dotnet pack src/Articulate.Web/Articulate.Web.csproj -c Release` - - `dotnet pack src/Articulate.Theme.Sample/Articulate.Theme.Sample.csproj -c Release` -- Alternatively, run the repo build script with `PACK_SAMPLE_THEME=true` to produce both packages for Docker validation. -- Keep `Articulate` and `Articulate.Theme.Sample` at the same package version. The Docker site restores both with the version selected from the newest `Articulate.[0-9]*.nupkg`. -- The Dockerfile ignores `.snupkg` files and theme packages when selecting the Articulate package version. +- Only one Compose HTTPS lane can run at a time because both lanes bind `18443`. Stop the active lane with `compose-down` before switching: + - `pwsh -File build/docker-lane.ps1 -Lane legacy -Action compose-down` + - `pwsh -File build/docker-lane.ps1 -Lane umbraco18 -Action compose-down` +- Compose uses lane-specific Umbraco data/media volumes via `COMPOSE_VOLUME_PREFIX`, so the v17 and v18 databases are not reused across lanes. - Rebuilding the image is not enough on its own. A running Compose service can stay on an older container/image. Prefer: - - `docker compose up -d --build --force-recreate articulate` + - `pwsh -File build/docker-lane.ps1 -Lane legacy -Action compose-up` + - or `pwsh -File build/docker-lane.ps1 -Lane umbraco18 -Action compose-up` - Or run the two steps explicitly: - - `docker compose build articulate` - - `docker compose up -d --force-recreate --no-deps articulate` -- `articulate-local:net10` is the image tag. The running container name is generated by Compose, for example `articulate-pr-articulate-1`. + - `pwsh -File build/docker-lane.ps1 -Lane legacy -Action compose-build` + - `pwsh -File build/docker-lane.ps1 -Lane legacy -Action compose-up` +- `articulate-local:umbraco17` and `articulate-local:umbraco18` are the lane image tags. Compose container names include the lane project name, for example `articulate-legacy-articulate-1`. - If the Docker back office still serves older JavaScript, check the running container rather than only the image: - - `docker compose ps` - - `docker exec articulate-pr-articulate-1 /bin/sh -c "find /app -path '*App_Plugins/Articulate/BackOffice/articulate-backoffice.js' -o -path '*App_Plugins/Articulate/umbraco-package.json'"` + - `docker compose -p articulate-legacy ps` + - `docker compose -p articulate-umbraco18 ps` + - `docker exec articulate-legacy-articulate-1 /bin/sh -c "find /app -path '*App_Plugins/Articulate/BackOffice/articulate-backoffice.js' -o -path '*App_Plugins/Articulate/umbraco-package.json'"` - `Invoke-WebRequest https://localhost:18443/App_Plugins/Articulate/BackOffice/articulate-backoffice.js -SkipCertificateCheck` diff --git a/docker-compose.yml b/docker-compose.yml index ebff95d33..8dca3bbe7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: args: BUILD_CONFIGURATION: ${BUILD_CONFIGURATION:-Release} UMBRACO_CMS_VERSION: ${UMBRACO_CMS_VERSION:-[17.2.2,18.0.0)} + PACKAGE_SOURCE: ${PACKAGE_SOURCE:-build/Release/legacy} image: ${IMAGE_TAG:-articulate-local:net10} user: "1654:1654" environment: @@ -62,8 +63,8 @@ services: volumes: articulate-media: - name: articulate_media + name: ${COMPOSE_VOLUME_PREFIX:-articulate}_media articulate-db: - name: articulate_db + name: ${COMPOSE_VOLUME_PREFIX:-articulate}_db caddy-data: caddy-config: diff --git a/src/Articulate.Tests/Articulate.Tests.csproj b/src/Articulate.Tests/Articulate.Tests.csproj index c436543ba..227458067 100644 --- a/src/Articulate.Tests/Articulate.Tests.csproj +++ b/src/Articulate.Tests/Articulate.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Articulate.Tests/Routing/ArticulateRouteValidatorTests.cs b/src/Articulate.Tests/Routing/ArticulateRouteValidatorTests.cs index 46878a3b8..1d657737b 100644 --- a/src/Articulate.Tests/Routing/ArticulateRouteValidatorTests.cs +++ b/src/Articulate.Tests/Routing/ArticulateRouteValidatorTests.cs @@ -167,15 +167,13 @@ public void ValidateRootPathMappings_rejects_same_path_with_equivalent_path_base [Test] public void ValidateConfiguredRouteSegments_rejects_duplicate_child_route_segments() { - IPublishedContent root = CreateRoot( - children: - [ - CreateRoot(name: "Child A", urlSegment: "tags"), - CreateRoot(name: "Child B", urlSegment: "Tags") - ]); + IPublishedContent root = CreateRoot(); InvalidOperationException ex = Assert.Throws(() => - ArticulateRouteValidator.ValidateConfiguredRouteSegments(root))!; + ArticulateRouteValidator.ValidateConfiguredRouteSegments(root, [ + CreateRoot(name: "Child A", urlSegment: "tags"), + CreateRoot(name: "Child B", urlSegment: "Tags") + ]))!; Assert.That(ex.Message, Does.Contain("child content 'Child A'")); Assert.That(ex.Message, Does.Contain("child content 'Child B'")); @@ -217,8 +215,7 @@ private static IPublishedContent CreateRoot( int id = 1, string name = "Blog", string path = "-1,1", - string urlSegment = "blog", - IEnumerable? children = null) + string urlSegment = "blog") { Mock contentType = new(); contentType.SetupGet(x => x.Alias).Returns(ArticulateConstants.ContentType.Articulate); @@ -227,11 +224,22 @@ private static IPublishedContent CreateRoot( root.SetupGet(x => x.Id).Returns(id); root.SetupGet(x => x.Name).Returns(name); root.SetupGet(x => x.Path).Returns(path); + root.SetupGet(x => x.Key).Returns(Guid.NewGuid()); + root.SetupGet(x => x.Properties).Returns([]); + root.Setup(x => x.GetProperty(It.IsAny())).Returns((IPublishedProperty?)null); + root.SetupGet(x => x.SortOrder).Returns(0); + root.SetupGet(x => x.CreatorId).Returns(0); + root.SetupGet(x => x.CreateDate).Returns(DateTime.UtcNow); + root.SetupGet(x => x.WriterId).Returns(0); + root.SetupGet(x => x.UpdateDate).Returns(DateTime.UtcNow); + root.SetupGet(x => x.Cultures).Returns(new Dictionary()); + root.SetupGet(x => x.ItemType).Returns(PublishedItemType.Content); + root.Setup(x => x.IsDraft(It.IsAny())).Returns(false); + root.Setup(x => x.IsPublished(It.IsAny())).Returns(true); + root.SetupGet(x => x.Level).Returns(1); + root.SetupGet(x => x.TemplateId).Returns((int?)null); root.SetupGet(x => x.UrlSegment).Returns(urlSegment); root.SetupGet(x => x.ContentType).Returns(contentType.Object); -#pragma warning disable CS0618 // Test double needs to supply the legacy Children property used by validation. - root.SetupGet(x => x.Children).Returns(children ?? []); -#pragma warning restore CS0618 return root.Object; } } diff --git a/src/Articulate.Web/Articulate.Web.csproj b/src/Articulate.Web/Articulate.Web.csproj index 1b9f16000..b4f6d7d32 100644 --- a/src/Articulate.Web/Articulate.Web.csproj +++ b/src/Articulate.Web/Articulate.Web.csproj @@ -36,11 +36,11 @@ - + - + diff --git a/src/Articulate/Articulate.csproj b/src/Articulate/Articulate.csproj index fe1235806..46e31c35e 100644 --- a/src/Articulate/Articulate.csproj +++ b/src/Articulate/Articulate.csproj @@ -15,14 +15,14 @@ - + - + diff --git a/src/Articulate/Components/ArticulateApiComposer.cs b/src/Articulate/Components/ArticulateApiComposer.cs index 4e4774fea..c8a9da9b0 100644 --- a/src/Articulate/Components/ArticulateApiComposer.cs +++ b/src/Articulate/Components/ArticulateApiComposer.cs @@ -4,26 +4,40 @@ using Articulate.Swagger; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +#if !(NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER) using Umbraco.Cms.Api.Common.OpenApi; +#else +using Umbraco.Cms.Api.Common.OpenApi; +using Umbraco.Cms.Api.Management.OpenApi; +#endif using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Notifications; namespace Articulate.Components { /// - /// Composes the Articulate API by registering Swagger/OpenAPI configuration and operation handlers. + /// Composes the Articulate API by registering version-appropriate OpenAPI configuration and application services. /// public class ArticulateApiComposer : IComposer { /// - /// Registers services and configures Swagger/OpenAPI for the Articulate API. + /// Registers services and configures Articulate OpenAPI behavior for the active Umbraco runtime. /// /// The Umbraco builder used for service registration. public void Compose(IUmbracoBuilder builder) { IServiceCollection services = builder.Services; +#if !(NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER) _ = services.AddSingleton(); _ = services.ConfigureOptions(); +#else + _ = services.ConfigureOptions(); + _ = builder.AddBackOfficeOpenApiDocument( + ArticulateConstants.ManagementApi.Name, + document => document + .WithTitle("Articulate Management API") + .WithBackOfficeAuthentication()); +#endif _ = services.Configure( builder.Config.GetSection(ArticulateOpenIdClientOptions.SectionName)); _ = services.AddSingleton, ArticulateOpenIdClientOptionsValidator>(); diff --git a/src/Articulate/Components/ArticulateComposer.cs b/src/Articulate/Components/ArticulateComposer.cs index fe7271a79..2b85c2f04 100644 --- a/src/Articulate/Components/ArticulateComposer.cs +++ b/src/Articulate/Components/ArticulateComposer.cs @@ -59,8 +59,13 @@ public void Compose(IUmbracoBuilder builder) options.ViewLocationExpanders.Add(new ArticulateViewLocationExpander()); }); +#if NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER + _ = builder.UrlProviders().InsertBefore(); + _ = builder.ContentFinders().InsertBefore(); +#else _ = builder.UrlProviders().InsertBefore(); _ = builder.ContentFinders().InsertBefore(); +#endif _ = services.AddOptions() .BindConfiguration("Articulate"); diff --git a/src/Articulate/Controllers/MetaWeblogController.cs b/src/Articulate/Controllers/MetaWeblogController.cs index 05153af58..40c88048a 100644 --- a/src/Articulate/Controllers/MetaWeblogController.cs +++ b/src/Articulate/Controllers/MetaWeblogController.cs @@ -3,7 +3,6 @@ using System.Xml; using System.Xml.Linq; using Articulate.Attributes; -using Articulate.ImportExport; using Articulate.MetaWeblog; using Articulate.Options; using Microsoft.AspNetCore.Mvc; diff --git a/src/Articulate/Models/MasterModel.cs b/src/Articulate/Models/MasterModel.cs index 7c29479a3..32a1d886f 100644 --- a/src/Articulate/Models/MasterModel.cs +++ b/src/Articulate/Models/MasterModel.cs @@ -13,9 +13,14 @@ public class MasterModel : PublishedContentWrapped, IMasterModel /// /// The basic model for all articulate objects /// - public MasterModel(IPublishedContent content, IPublishedValueFallback publishedValueFallback) : base( - content, - publishedValueFallback) => PublishedValueFallback = publishedValueFallback; +#if NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER + public MasterModel(IPublishedContent content, IPublishedValueFallback publishedValueFallback) : base(content) + => PublishedValueFallback = publishedValueFallback; +#else + public MasterModel(IPublishedContent content, IPublishedValueFallback publishedValueFallback) + : base(content, publishedValueFallback) + => PublishedValueFallback = publishedValueFallback; +#endif /// /// Returns the current theme diff --git a/src/Articulate/Models/PublishedContentExtensions.cs b/src/Articulate/Models/PublishedContentExtensions.cs index 095e1532d..0b5a8eb47 100644 --- a/src/Articulate/Models/PublishedContentExtensions.cs +++ b/src/Articulate/Models/PublishedContentExtensions.cs @@ -779,9 +779,9 @@ public static IPublishedContent[] GetListNodes(IMasterModel masterModel) throw new ArgumentNullException(nameof(masterModel)); } - IPublishedContent[] listNodes = masterModel.RootBlogNode - .ChildrenOfType(ArticulateConstants.ContentType - .ArticulateArchive).ToArray(); + IPublishedContent[] listNodes = masterModel.RootBlogNode + .ChildrenOfType(ArticulateConstants.ContentType + .ArticulateArchive).ToArray(); if (listNodes.Length == 0) { diff --git a/src/Articulate/Options/ArticulateSwaggerOptions.cs b/src/Articulate/Options/ArticulateSwaggerOptions.cs index 49ab48773..43e88f1e5 100644 --- a/src/Articulate/Options/ArticulateSwaggerOptions.cs +++ b/src/Articulate/Options/ArticulateSwaggerOptions.cs @@ -1,11 +1,16 @@ #nullable enable +#if !(NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER) using System.Reflection; -using Articulate.Swagger; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.SwaggerGen; -#if NET10_0_OR_GREATER +#endif +using Articulate.Swagger; +using Microsoft.Extensions.Options; +#if NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; +#elif NET10_0_OR_GREATER using Microsoft.OpenApi; #else using Microsoft.OpenApi.Models; @@ -13,16 +18,14 @@ namespace Articulate.Options { +#if !(NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER) /// - /// Configures SwaggerGen options for the Articulate API, including documentation, tags, and XML comments. + /// Configures Articulate management API OpenAPI generation across supported Umbraco versions. /// public class ArticulateSwaggerOptions(ILogger logger) : IConfigureOptions { - /// - /// Configures SwaggerGen options for the Articulate API. - /// - /// The SwaggerGen options to configure. + /// public void Configure(SwaggerGenOptions options) { var year = DateTime.Now.Year.ToString(); @@ -68,4 +71,44 @@ public void Configure(SwaggerGenOptions options) options.OperationFilter(); } } +#else + /// + /// Configures named for the Articulate management API document in Umbraco 18+. + /// + public class ArticulateSwaggerOptions + : IConfigureNamedOptions + { + /// + /// Configures the named Articulate OpenAPI document by registering operation/document transformers. + /// + /// The OpenAPI document name. + /// The OpenAPI options for the named document. + public void Configure(string? name, OpenApiOptions options) + { + if (!string.Equals(name, ArticulateConstants.ManagementApi.Name, StringComparison.Ordinal)) + { + return; + } + + options.AddOperationTransformer(); + options.AddOperationTransformer(); + options.AddDocumentTransformer(); + options.AddDocumentTransformer((document, _, _) => + { + document.Info.Version = "Latest"; + document.Info.Title = "Articulate Management API"; + document.Info.Description = "API for the back office dashboard section Articulate, a wonderful Blog engine built on Umbraco. "; + return Task.CompletedTask; + }); + } + + /// + /// Required interface member for unnamed options; intentionally unused because Articulate config is named. + /// + /// The unnamed OpenAPI options instance. + public void Configure(OpenApiOptions options) + { + } + } +#endif } diff --git a/src/Articulate/Routing/ArticulateRouteValidator.cs b/src/Articulate/Routing/ArticulateRouteValidator.cs index 3f459a8c8..46fb976c3 100644 --- a/src/Articulate/Routing/ArticulateRouteValidator.cs +++ b/src/Articulate/Routing/ArticulateRouteValidator.cs @@ -33,13 +33,14 @@ internal static List DomainsForContent(IContent content, IReadOnlyList nodePaths.Contains(domain.ContentId))]; } - internal static void ValidateConfiguredRouteSegments(IPublishedContent articulateRootNode) + internal static void ValidateConfiguredRouteSegments( + IPublishedContent articulateRootNode, + IEnumerable? children = null) { var configuredSegments = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning disable CS0618 // IPublishedContent.Children used here to avoid navigation-service requirements during validation. - foreach (IPublishedContent child in articulateRootNode.Children) -#pragma warning restore CS0618 + IEnumerable configuredChildren = children ?? articulateRootNode.Children(); + foreach (IPublishedContent child in configuredChildren) { string? childRouteSegment = ArticulateRouteSegmentHelper.NormalizeOrNull(child.UrlSegment); if (childRouteSegment is not null) diff --git a/src/Articulate/Routing/DateFormattedPostContentFinder.cs b/src/Articulate/Routing/DateFormattedPostContentFinder.cs index c30e9e829..f2992cf33 100644 --- a/src/Articulate/Routing/DateFormattedPostContentFinder.cs +++ b/src/Articulate/Routing/DateFormattedPostContentFinder.cs @@ -14,7 +14,11 @@ namespace Articulate.Routing /// /// Content finder that handles date-formatted URLs for Articulate blog posts (e.g., /YYYY/MM/DD/post-name/). /// +#if NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER + public class DateFormattedPostContentFinder : ContentFinderByUrl +#else public class DateFormattedPostContentFinder : ContentFinderByUrlNew +#endif { private readonly IDocumentUrlService _documentUrlService; private readonly IPublishedContentCache _publishedContentCache; @@ -23,12 +27,20 @@ public class DateFormattedPostContentFinder : ContentFinderByUrlNew /// Initializes a new instance of the class. /// public DateFormattedPostContentFinder( - ILogger logger, +#if NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER + ILogger logger, +#else + ILogger logger, +#endif IUmbracoContextAccessor umbracoContextAccessor, IDocumentUrlService documentUrlService, IPublishedContentCache publishedContentCache, IOptionsMonitor webRoutingSettings) +#if NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER : base(logger, umbracoContextAccessor, documentUrlService, publishedContentCache, webRoutingSettings) +#else + : base(logger, umbracoContextAccessor, documentUrlService, publishedContentCache, webRoutingSettings) +#endif { _documentUrlService = documentUrlService; _publishedContentCache = publishedContentCache; diff --git a/src/Articulate/Routing/DateFormattedUrlProvider.cs b/src/Articulate/Routing/DateFormattedUrlProvider.cs index c319e41e5..16c9cc47c 100644 --- a/src/Articulate/Routing/DateFormattedUrlProvider.cs +++ b/src/Articulate/Routing/DateFormattedUrlProvider.cs @@ -14,15 +14,19 @@ namespace Articulate.Routing /// /// Provides date-formatted URLs for Articulate blog posts (e.g., /YYYY/MM/DD/post-name/). /// +#if NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER + public class DateFormattedUrlProvider : DefaultUrlProvider +#else public class DateFormattedUrlProvider : NewDefaultUrlProvider +#endif { -#if NET10_0_OR_GREATER +#if NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER /// /// Initializes a new instance of the class for NET10 (Umbraco 17+). /// public DateFormattedUrlProvider( IOptionsMonitor requestSettings, - ILogger logger, + ILogger logger, ISiteDomainMapper siteDomainMapper, IUmbracoContextAccessor umbracoContextAccessor, UriUtility uriUtility, @@ -54,13 +58,19 @@ public DateFormattedUrlProvider( /// public DateFormattedUrlProvider( IOptionsMonitor requestSettings, +#if NET9_0 ILogger logger, +#else + ILogger logger, +#endif ISiteDomainMapper siteDomainMapper, IUmbracoContextAccessor umbracoContextAccessor, UriUtility uriUtility, +#if NET9_0 #pragma warning disable CS0618 // Type or member is obsolete ILocalizationService localizationService, #pragma warning restore CS0618 // Type or member is obsolete +#endif IPublishedContentCache publishedContentCache, IDomainCache domainCache, IIdKeyMap idKeyMap, @@ -74,7 +84,9 @@ public DateFormattedUrlProvider( siteDomainMapper, umbracoContextAccessor, uriUtility, +#if NET9_0 localizationService, +#endif publishedContentCache, domainCache, idKeyMap, diff --git a/src/Articulate/Swagger/ArticulateOperationIdHandler.cs b/src/Articulate/Swagger/ArticulateOperationIdHandler.cs index 6559b8860..dfb214272 100644 --- a/src/Articulate/Swagger/ArticulateOperationIdHandler.cs +++ b/src/Articulate/Swagger/ArticulateOperationIdHandler.cs @@ -1,6 +1,7 @@ #nullable enable -using Articulate.Controllers.Api; +#if !(NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER) using Asp.Versioning; +using Articulate.Controllers.Api; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.Extensions.Options; @@ -9,9 +10,9 @@ namespace Articulate.Swagger { /// - /// Handles the generation of operation IDs for Articulate API endpoints in Swagger. + /// Handles the generation of operation IDs for Articulate API endpoints. /// -#pragma warning disable CS9107 // Parameter captured and passed to base - intentional, base class doesn't expose options +#pragma warning disable CS9107 internal class ArticulateOperationIdHandler(IOptions apiVersioningOptions) : OperationIdHandler(apiVersioningOptions) #pragma warning restore CS9107 @@ -19,7 +20,6 @@ internal class ArticulateOperationIdHandler(IOptions apiVe /// public override string Handle(ApiDescription apiDescription) => ArticulateOperationId(apiDescription); - // Adapted from Umbraco.Cms.Api.Common.OpenApi.OperationIdHandler /// protected override bool CanHandle( ApiDescription apiDescription, @@ -27,10 +27,14 @@ protected override bool CanHandle( { Type type = typeof(BlogMlApiController); var namespaceName = type.Namespace ?? "Articulate.Api.Management.Controllers"; + var controllerNamespace = controllerActionDescriptor.ControllerTypeInfo.Namespace; - return controllerActionDescriptor.ControllerTypeInfo.Namespace?.StartsWith( - namespaceName, - StringComparison.InvariantCultureIgnoreCase) is true; + return controllerNamespace?.StartsWith( + namespaceName, + StringComparison.InvariantCultureIgnoreCase) is true + || controllerNamespace?.StartsWith( + "Articulate.Api.Management.Controllers", + StringComparison.InvariantCultureIgnoreCase) is true; } private string ArticulateOperationId(ApiDescription apiDescription) @@ -50,36 +54,123 @@ private string ArticulateOperationId(ApiDescription apiDescription) $"There is no relative path for controller action {apiDescription.ActionDescriptor.RouteValues["controller"]}"); } - // Remove the prefixed base path with version, e.g. /umbraco/articulate/api/v1/tracked-reference/{id} => tracked-reference/{id} var unprefixedRelativePath = ArticulateOperationIdRegexes .VersionPrefixRegex() .Replace(relativePath, string.Empty); - // Remove template placeholders, e.g. tracked-reference/{id} => tracked-reference/ID var formattedOperationId = ArticulateOperationIdRegexes .TemplatePlaceholdersRegex() .Replace(unprefixedRelativePath, m => $"By{m.Groups[1].Value.ToFirstUpper()}"); - // Remove dashes (-) and slashes (/) and convert the following letter to uppercase with - // the word "By" in front, e.g. tracked-reference-id => trackedReferenceById formattedOperationId = ArticulateOperationIdRegexes .ToCamelCaseRegex() .Replace(formattedOperationId, m => m.Groups[1].Value.ToUpper()); - // Get map to version attribute var version = string.Empty; - var versionAttributeValue = controllerActionDescriptor.MethodInfo.GetMapToApiVersionAttributeValue(); - // We only want to add a version, if it is not the default one. if (!string.IsNullOrEmpty(versionAttributeValue) && - !string.Equals(versionAttributeValue, defaultVersion.ToString())) + !string.Equals(versionAttributeValue, defaultVersion.ToString(), StringComparison.Ordinal)) + { + version = versionAttributeValue; + } + + return $"{httpMethod}{formattedOperationId.ToFirstUpper()}{version}"; + } + } +} +#else +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; + +namespace Articulate.Swagger +{ + /// + /// Transforms OpenAPI operation IDs for Articulate API endpoints in Umbraco 18+. + /// + internal class ArticulateOperationIdHandler : IOpenApiOperationTransformer + { + /// + public Task TransformAsync( + OpenApiOperation operation, + OpenApiOperationTransformerContext context, + CancellationToken cancellationToken) + { + var operationId = GenerateOperationId(context); + if (operationId is not null) + { + operation.OperationId = operationId; + } + + return Task.CompletedTask; + } + + private static string? GenerateOperationId(OpenApiOperationTransformerContext context) + { + ApiDescription apiDescription = context.Description; + if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor) + { + return null; + } + + Type type = typeof(Controllers.Api.BlogMlApiController); + var namespaceName = type.Namespace ?? "Articulate.Api.Management.Controllers"; + var controllerNamespace = controllerActionDescriptor.ControllerTypeInfo.Namespace; + + var shouldHandle = controllerNamespace?.StartsWith(namespaceName, StringComparison.InvariantCultureIgnoreCase) is true + || controllerNamespace?.StartsWith("Articulate.Api.Management.Controllers", StringComparison.InvariantCultureIgnoreCase) is true; + + if (!shouldHandle) + { + return null; + } + + ApiVersion defaultVersion = context.ApplicationServices.GetRequiredService>().Value.DefaultApiVersion; + var httpMethod = apiDescription.HttpMethod?.ToLower().ToFirstUpper() ?? "Get"; + + if (string.IsNullOrWhiteSpace(apiDescription.ActionDescriptor.AttributeRouteInfo?.Name) == false) + { + var explicitOperationId = apiDescription.ActionDescriptor.AttributeRouteInfo!.Name; + return explicitOperationId.InvariantStartsWith(httpMethod) + ? explicitOperationId + : $"{httpMethod}{explicitOperationId}"; + } + + var relativePath = apiDescription.RelativePath; + + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new InvalidOperationException( + $"There is no relative path for controller action {apiDescription.ActionDescriptor.RouteValues["controller"]}"); + } + + var unprefixedRelativePath = ArticulateOperationIdRegexes + .VersionPrefixRegex() + .Replace(relativePath, string.Empty); + + var formattedOperationId = ArticulateOperationIdRegexes + .TemplatePlaceholdersRegex() + .Replace(unprefixedRelativePath, m => $"By{m.Groups[1].Value.ToFirstUpper()}"); + + formattedOperationId = ArticulateOperationIdRegexes + .ToCamelCaseRegex() + .Replace(formattedOperationId, m => m.Groups[1].Value.ToUpper()); + + string? version = null; + var versionAttributeValue = controllerActionDescriptor.MethodInfo.GetMapToApiVersionAttributeValue(); + + if (string.Equals(versionAttributeValue, defaultVersion.ToString(), StringComparison.Ordinal) == false) { version = versionAttributeValue; } - // Return the operation ID with the formatted http method verb in front, e.g. GetTrackedReferenceById return $"{httpMethod}{formattedOperationId.ToFirstUpper()}{version}"; } } } +#endif diff --git a/src/Articulate/Swagger/ArticulateOperationSecurityFilter.cs b/src/Articulate/Swagger/ArticulateOperationSecurityFilter.cs index 904ae6035..6e9309020 100644 --- a/src/Articulate/Swagger/ArticulateOperationSecurityFilter.cs +++ b/src/Articulate/Swagger/ArticulateOperationSecurityFilter.cs @@ -1,10 +1,11 @@ #nullable enable +#if !(NET10_0_OR_GREATER && UMBRACO_18_OR_GREATER) using Umbraco.Cms.Api.Management.OpenApi; namespace Articulate.Swagger { /// - /// Adds security requirements to Articulate API operations for Swagger documentation. + /// Adds backoffice security requirements to Articulate API operations. /// internal class ArticulateOperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase { @@ -14,3 +15,110 @@ internal class ArticulateOperationSecurityFilter : BackOfficeSecurityRequirement protected override string ApiName => ArticulateConstants.ManagementApi.Name; } } +#else +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; +using Umbraco.Cms.Api.Common.Security; + +namespace Articulate.Swagger +{ + /// + /// Adds backoffice security requirements for Articulate API operations in Umbraco 18+ OpenAPI. + /// + internal class ArticulateOperationSecurityFilter : IOpenApiOperationTransformer, IOpenApiDocumentTransformer + { + private const int BaseAuthorizeAttributeCount = 2; + private const string BackOfficeUserSecurityName = "Backoffice-User"; + + /// + public Task TransformAsync( + OpenApiOperation operation, + OpenApiOperationTransformerContext context, + CancellationToken cancellationToken) + { + if (context.Description.ActionDescriptor is not ControllerActionDescriptor description) + { + return Task.CompletedTask; + } + + if (description.MethodInfo.GetCustomAttributes(true).Any(x => x is AllowAnonymousAttribute) || + description.MethodInfo.DeclaringType?.GetCustomAttributes(true).Any(x => x is AllowAnonymousAttribute) == true) + { + operation.Security = []; + return Task.CompletedTask; + } + + operation.Responses ??= new OpenApiResponses(); + operation.Responses[StatusCodes.Status401Unauthorized.ToString()] = new OpenApiResponse + { + Description = "The resource is protected and requires an authentication token" + }; + + var schemaRef = new OpenApiSecuritySchemeReference(BackOfficeUserSecurityName, context.Document); + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement { [schemaRef] = [] }); + + var numberOfAuthorizeAttributes = + description.MethodInfo.GetCustomAttributes(true).Count(x => x is AuthorizeAttribute) + + description.MethodInfo.DeclaringType?.GetCustomAttributes(true).Count(x => x is AuthorizeAttribute); + + if (numberOfAuthorizeAttributes > BaseAuthorizeAttributeCount || InjectsAuthorizationService(description.MethodInfo.DeclaringType)) + { + operation.Responses[StatusCodes.Status403Forbidden.ToString()] = new OpenApiResponse + { + Description = "The authenticated user does not have access to this resource" + }; + } + + return Task.CompletedTask; + } + + /// + public Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken) + { + var apiKeyScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Name = "Umbraco", + In = ParameterLocation.Header, + Description = "Umbraco Authentication", + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new System.Uri(Paths.BackOfficeApi.AuthorizationEndpoint, UriKind.Relative), + TokenUrl = new System.Uri(Paths.BackOfficeApi.TokenEndpoint, UriKind.Relative), + }, + }, + }; + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes[BackOfficeUserSecurityName] = apiKeyScheme; + + var schemaRef = new OpenApiSecuritySchemeReference(BackOfficeUserSecurityName, document); + document.Security ??= new List(); + document.Security.Add(new OpenApiSecurityRequirement { [schemaRef] = [] }); + return Task.CompletedTask; + } + + private static bool InjectsAuthorizationService(Type? type) + { + if (type is null) + { + return false; + } + + return type.GetConstructors() + .Any(ctor => ctor.GetParameters() + .Any(parameter => parameter.ParameterType == typeof(IAuthorizationService))); + } + } +} +#endif From 10f6d4a04b0c1b725bbd25c2192c145eb17991be Mon Sep 17 00:00:00 2001 From: Gavin Faux Date: Tue, 26 May 2026 19:17:02 +0100 Subject: [PATCH 2/8] fix: handle RuntimeLevel.Upgrading in CanAutoPublish and bump dependency versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change CanAutoPublish guard from 'is not RuntimeLevel.Run' to 'is RuntimeLevel.Boot or RuntimeLevel.BootFailed or RuntimeLevel.Unknown' so Umbraco 17's new RuntimeLevel.Upgrading does not block auto-publish after unattended install, without breaking Umbraco 16 where the enum value does not exist. - Add diagnostic logging showing RuntimeLevel and AutoPublishOnStartup values to aid future debugging. - Bump System.Security.Cryptography.Xml from [10.0.6,11.0.0) to [10.0.7,11.0.0) in Directory.Build.props, Directory.Packages.props, and src/Articulate/Articulate.csproj — required by Umbraco 18 beta2. - Bump FileSignatures from 6.1.1 to 7.2.1 in both Articulate.csproj and Articulate.Web.csproj. - Remove duplicate OpenMcdf references from test projects (already brought in transitively by Umbraco.Cms). - Move central version ranges into Directory.Packages.props, keeping Directory.Build.props focused on per-TFM deltas only. --- Directory.Build.props | 41 +++++++------------ Directory.Packages.props | 21 ++++++++++ .../Articulate.Tests.Website.csproj | 1 - src/Articulate.Tests/Articulate.Tests.csproj | 1 - src/Articulate.Web/Articulate.Web.csproj | 5 +-- src/Articulate/Articulate.csproj | 6 +-- .../ArticulateMigrationPlanExecutedHandler.cs | 16 +++++++- 7 files changed, 55 insertions(+), 36 deletions(-) create mode 100644 Directory.Packages.props diff --git a/Directory.Build.props b/Directory.Build.props index e268486dc..7cbb251b0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,32 +14,22 @@ [16.5.1,17.0.0) - [1.6.22,2.0.0) - [4.13.0,5.3.0) - [9.0.14,10.0.3) - [9.0.15,10.0.0) - [4.16.0,5.0.0) - 3.1.3 + - [17.2.2,18.0.0) - [2.3.0,3.0.0) - [5.3.0,6.0.0) - [10.0.5,11.0.0) - [10.0.7,11.0.0) - [4.16.0,5.0.0) - 3.1.3 - [1.1.3,2.0.0) + [17.4.2,18.0.0) + + [1.2.0,2.0.0) [18.5.1,19.0.0) + + [10.0.8,11.0.0) + [10.0.7,11.0.0) $(DefineConstants);UMBRACO_18_OR_GREATER - - [0.44.0,1.0.0) - [18.0.1,19.0.0) - + false @@ -69,14 +59,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - true \ @@ -86,6 +68,13 @@ \ + + + + + + + $(MSBuildThisFileDirectory) diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 000000000..17db6622b --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,21 @@ + + + + + + [1.6.22,3.0.0) + + + [4.13.0,6.0.0) + + + [9.0.16,11.0.0) + [10.0.7,11.0.0) + + + [4.16.0,5.0.0) + [1.2.0,2.0.0) + [3.1.4,4.0.0) + [18.5.1,19.0.0) + + diff --git a/src/Articulate.Tests.Website/Articulate.Tests.Website.csproj b/src/Articulate.Tests.Website/Articulate.Tests.Website.csproj index 0a44ae06f..db70bba94 100644 --- a/src/Articulate.Tests.Website/Articulate.Tests.Website.csproj +++ b/src/Articulate.Tests.Website/Articulate.Tests.Website.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Articulate.Tests/Articulate.Tests.csproj b/src/Articulate.Tests/Articulate.Tests.csproj index 227458067..8c63384f2 100644 --- a/src/Articulate.Tests/Articulate.Tests.csproj +++ b/src/Articulate.Tests/Articulate.Tests.csproj @@ -19,7 +19,6 @@ - diff --git a/src/Articulate.Web/Articulate.Web.csproj b/src/Articulate.Web/Articulate.Web.csproj index b4f6d7d32..74baa9f60 100644 --- a/src/Articulate.Web/Articulate.Web.csproj +++ b/src/Articulate.Web/Articulate.Web.csproj @@ -35,12 +35,11 @@ - + - @@ -109,7 +108,7 @@ - + diff --git a/src/Articulate/Articulate.csproj b/src/Articulate/Articulate.csproj index 46e31c35e..d9c1b7f44 100644 --- a/src/Articulate/Articulate.csproj +++ b/src/Articulate/Articulate.csproj @@ -14,15 +14,15 @@ - + - - + + diff --git a/src/Articulate/Migrations/ArticulateMigrationPlanExecutedHandler.cs b/src/Articulate/Migrations/ArticulateMigrationPlanExecutedHandler.cs index 08bf4011a..43a4cbed3 100644 --- a/src/Articulate/Migrations/ArticulateMigrationPlanExecutedHandler.cs +++ b/src/Articulate/Migrations/ArticulateMigrationPlanExecutedHandler.cs @@ -108,10 +108,17 @@ private bool ShouldPublishAfterPackageImport( private bool CanAutoPublish(string trigger) { - if (runtimeState.Level is not RuntimeLevel.Run) + logger.LogInformation( + "Articulate CanAutoPublish check for {Trigger}: RuntimeLevel={RuntimeLevel}, AutoPublishOnStartup={AutoPublishOnStartup}", + trigger, + runtimeState.Level, + options.Value.AutoPublishOnStartup); + + if (runtimeState.Level is RuntimeLevel.Boot or RuntimeLevel.BootFailed or RuntimeLevel.Unknown) { logger.LogInformation( - "Umbraco is not in Run level, skipping Articulate post-migration tasks ({Trigger}).", + "Umbraco runtime is not ready (level={RuntimeLevel}), skipping Articulate post-migration tasks ({Trigger}).", + runtimeState.Level, trigger); return false; } @@ -123,6 +130,11 @@ private bool CanAutoPublish(string trigger) return false; } + logger.LogInformation( + "Articulate post-migration tasks will proceed for {Trigger} (RuntimeLevel={RuntimeLevel}).", + trigger, + runtimeState.Level); + return true; } From 6a71e78888d8bec94ebfd30a9bfa003bdd5f4e73 Mon Sep 17 00:00:00 2001 From: Gavin Faux Date: Tue, 26 May 2026 19:17:15 +0100 Subject: [PATCH 3/8] fix: prevent HMAC image 400 by using IHtmlContent for CSS url() values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Umbraco 17+ auto-generates an HMAC secret key on first boot. GetCropUrl() returns URLs with &hmac=... — Razor HTML-encodes & to & in @ expressions. Inside