Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/bin/bash
# Installs the .NET 10 SDK so dotnet build/restore/test work in this session.
# Idempotent: re-running is cheap once the SDK is already on disk.
set -euo pipefail

# Only run in the Claude Code on the web remote container.
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
exit 0
fi

DOTNET_ROOT="${DOTNET_ROOT:-$HOME/.dotnet}"
INSTALL_SCRIPT="$HOME/.dotnet-install.sh"
# Channel "10.0" tracks the latest .NET 10 SDK, which Umbraco 18 requires.
CHANNEL="10.0"

mkdir -p "$DOTNET_ROOT"

if [ ! -x "$DOTNET_ROOT/dotnet" ]; then
if [ ! -f "$INSTALL_SCRIPT" ]; then
curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$INSTALL_SCRIPT"
chmod +x "$INSTALL_SCRIPT"
fi
"$INSTALL_SCRIPT" --channel "$CHANNEL" --install-dir "$DOTNET_ROOT" --no-path
fi

# Persist DOTNET_ROOT and PATH for the rest of the session.
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
{
echo "export DOTNET_ROOT=\"$DOTNET_ROOT\""
echo "export PATH=\"$DOTNET_ROOT:\$PATH\""
echo "export DOTNET_CLI_TELEMETRY_OPTOUT=1"
echo "export DOTNET_NOLOGO=1"
} >> "$CLAUDE_ENV_FILE"
fi

"$DOTNET_ROOT/dotnet" --version >&2
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
}
]
}
]
}
}
115 changes: 115 additions & 0 deletions .github/umbraco-18-upgrade-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Umbraco 18 Beta Upgrade Plan for Clean

Investigation of Umbraco 18 beta breaking changes and the plan to make Clean
compatible. Target: Umbraco `18.0.0-beta2`, Clean package version `8.0.0-beta1`.

## Current state

All 5 projects (`Clean`, `Clean.Core`, `Clean.Headless`, `Clean.Blog`,
`Clean.Models`) target `net10.0` and reference Umbraco `17.1.0`. Package
version is stamped at `7.0.5`. Compatible extras: `uSync 17.0.1` and
`uSync.Command.Setup 16.1.0`.

## Decisions

- **Target Umbraco version**: `18.0.0-beta2` (latest beta as of 2026-05-14).
- **Clean package version**: bump to `8.0.0-beta1` (matches the existing
convention: Umbraco 17 → Clean 7, Umbraco 18 → Clean 8).
- **uSync**: keep current pins (`uSync 17.0.1`, `uSync.Command.Setup 16.1.0`)
in `Clean.Blog`. No v18-compatible release exists yet on NuGet; expect a
follow-up bump once Kevin Jump publishes one.

## Breaking changes that affect Clean

### 1. OpenAPI: Swashbuckle removed, replaced by `Microsoft.AspNetCore.OpenApi`

- `template/Clean.Headless/Startup/ConfigureSwaggerGenOptions.cs` uses
`IConfigureOptions<SwaggerGenOptions>` + `SwaggerDoc(...)` — rewrite using
`builder.Services.AddOpenApi("clean-starter", options =>
options.AddDocumentTransformer(...))`, setting
`Title = "Clean starter kit"`, `Version = "Latest"`,
`Description = "Contains headless endpoints for search, dictionaries and forms"`,
plus `AddOpenApiDocumentToUi("clean-starter", "Clean starter kit")`.
- `template/Clean.Headless/Startup/WorkshopComposer.cs` (line 11) — replace
the `ConfigureOptions<ConfigureSwaggerGenOptions>()` call with the new
registration. The `ConfigureSwaggerGenOptions` class can collapse into the
composer or be kept as a small helper.
- The Swagger UI URL changes from `/umbraco/swagger` to `/umbraco/openapi`.

### 2. `IPublishedContent.Parent` / `.Children` properties removed

Both are now extension methods in `Umbraco.Extensions` (already imported via
`_ViewImports.cshtml`). Razor views need `()` added:

- `template/Clean.Blog/Views/Author.cshtml:8` —
`Model.Parent as AuthorList` → `Model.Parent() as AuthorList`
- `template/Clean.Blog/Views/Partials/mainNavigation.cshtml:21` —
`homePage.Children.Where(...)` → `homePage.Children().Where(...)`
- `template/Clean.Blog/Views/Partials/xmlSitemap.cshtml:14,16,29` — three
`.Children` references → `.Children()`

Existing `Children<T>()` calls in `Partials/authors.cshtml` and
`latestArticlesRow.cshtml` are already method calls and don't need changes.

### 3. csproj package bumps

For every csproj, bump `Umbraco.Cms.*` from `17.1.0` to `18.0.0-beta2`:

- `template/Clean.Models/Clean.Models.csproj`
- `template/Clean.Core/Clean.Core.csproj`
- `template/Clean.Headless/Clean.Headless.csproj`
- `template/Clean/Clean.csproj` (also bump the two `Clean.Core` /
`Clean.Headless` PackageReferences to `8.0.0-beta1`)
- `template/Clean.Blog/Clean.Blog.csproj` (bump `Umbraco.Cms` and
`Umbraco.Cms.DevelopmentMode.Backoffice`; leave uSync untouched)

Bump `<Version>`, `<AssemblyVersion>`, `<InformationalVersion>` from `7.0.5`
to `8.0.0-beta1` on the four packaged projects (`Clean.Models` is unversioned).

## Confirmed NOT broken (do not change)

- **`IEmailSender`** — interface remains; only obsolete `SendAsync` overloads
without an `expires` parameter were removed. Current call
`_emailSender.SendAsync(message, emailType: "Contact")` still binds to the
surviving overload. No change needed in `ContactSurfaceController.cs` or
`ContactV1Controller.cs`.
- **`IContentTypeBaseServiceProvider`** in `ImportPackageXmlMigration.cs` —
the `Provider` variant survived; only `IContentTypeBaseService` (without
"Provider") was removed.
- **`AsyncPackageMigrationBase`** — kept in v18; only the sync
`MigrationBase` and `PackageMigrationBase` were removed.
- **`IDictionaryItemService.GetAtRootAsync()`** in
`DictionaryApiV1Controller.cs` and `PackageController.cs` — a different
API from the removed `GetAtRoot()` (which was on `UmbracoHelper`,
`IPublishedContentCache`, `IUmbracoContext.Content`).

## Phased execution

1. **Phase 1 — packaged libraries**: bump `Umbraco.Cms.*` to
`18.0.0-beta2` in all 5 csproj files. Rewrite `ConfigureSwaggerGenOptions.cs`
and update `WorkshopComposer.cs`. Bump package `<Version>` to
`8.0.0-beta1`.
2. **Phase 2 — `Clean.Blog` test host**: bump `Umbraco.Cms` and
`Umbraco.Cms.DevelopmentMode.Backoffice`. Update the four Razor views to
call `Parent()` / `Children()`.
3. **Phase 3 — verification + docs**: local `dotnet build`, smoke test
(login, publish home, hit OpenAPI UI at `/umbraco/openapi`). Add an
"Umbraco 18" install section to `README.md`.

## Risks and open items

- **uSync 17 against Umbraco 18 may fail at runtime.** The pins are kept per
the chosen strategy; expect a follow-up bump when
[uSync](https://www.nuget.org/packages/uSync) ships a v18 build (see
[KevinJump/uSync releases](https://github.com/KevinJump/uSync/releases)).
- **Workflows** under `.github/workflows/` (e.g.
`test-umbraco-latest-nuget.yml`, `update-packages.yml`) may need a separate
look if they pin to non-prerelease versions — out of scope for this plan.

## Sources

- [Breaking Changes Overview — CMS 18.latest (Beta)](https://docs.umbraco.com/umbraco-cms/18.latest/get-started/upgrading-and-migrating/version-specific)
- [Umbraco-CMS v18.0.0 release notes](https://releases.umbraco.com/release/umbraco/Umbraco-CMS/18.0.0)
- [API versioning and OpenAPI — CMS 18.latest](https://docs.umbraco.com/umbraco-cms/18.latest/extend-your-project/server-side-extensions/api-versioning-and-openapi)
- [Umbraco.Cms on NuGet](https://www.nuget.org/packages/Umbraco.Cms)
- [uSync on NuGet](https://www.nuget.org/packages/uSync)
6 changes: 3 additions & 3 deletions template-pack.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageVersion>7.0.5</PackageVersion>
<PackageVersion>8.0.0-beta01</PackageVersion>
<PackageType>Template</PackageType>
<PackageId>Umbraco.Community.Templates.Clean</PackageId>
<Title>Umbraco.Community.Templates.Clean</Title>
Expand All @@ -20,8 +20,8 @@
<PackageProjectUrl>https://github.com/prjseal/Clean</PackageProjectUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<InformationalVersion>7.0.5</InformationalVersion>
<AssemblyVersion>7.0.5</AssemblyVersion>
<InformationalVersion>8.0.0</InformationalVersion>
<AssemblyVersion>8.0.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<Content Include="template\**\*" Exclude="template\**\bin\**;template\**\obj\**,template\.vs\**,template\**\created-packages\**" />
Expand Down
9 changes: 5 additions & 4 deletions template/Clean.Blog/Clean.Blog.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
<!-- Disable compression. E.g. for umbraco backoffice files. These files should be precompressed by node and not let dotnet handle it -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Umbraco.Cms" Version="17.1.0" />
<PackageReference Include="Umbraco.Cms.DevelopmentMode.Backoffice" Version="17.1.0" />
<PackageReference Include="uSync.Command.Setup" Version="16.1.0" />
<PackageReference Include="Umbraco.Cms" Version="18.0.0-beta2" />
<PackageReference Include="Umbraco.Cms.DevelopmentMode.Backoffice" Version="18.0.0-beta2" />
<ProjectReference Include="..\Clean.Core\Clean.Core.csproj" />
<ProjectReference Include="..\Clean.Headless\Clean.Headless.csproj" />
<ProjectReference Include="..\Clean.Models\Clean.Models.csproj" />
<PackageReference Include="uSync" Version="17.0.1" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\Clean\Migrations\package.zip" LogicalName="Clean.Blog.Migrations.package.zip" />
</ItemGroup>
<ItemGroup>
<!-- Opt-in to app-local ICU to ensure consistent globalization APIs across different platforms -->
Expand Down
113 changes: 113 additions & 0 deletions template/Clean.Blog/Composing/ApiClientSetup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Infrastructure.Install;
using Umbraco.Cms.Infrastructure.Security;

namespace Clean.Blog.Composing;

internal static class ApiClientSetup
{
// Run the Clean.Blog package migration synchronously so the runtime is at
// Run before the listener accepts requests. By default Umbraco defers package
// migrations to a hosted service that races with first incoming traffic, and
// /umbraco/management/api/v1/security/back-office/token returns 503
// "application is currently being upgraded" until that hosted service
// finishes.
public static async Task RunPackageMigrationsAsync(IServiceProvider rootServices, string packageName)
{
using var scope = rootServices.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<PackageMigrationRunner>();
await runner.RunPendingPackageMigrations(packageName);
}

public static async Task EnsureAsync(IServiceProvider rootServices, CancellationToken ct = default)
{
using var scope = rootServices.CreateScope();
var sp = scope.ServiceProvider;
var configuration = sp.GetRequiredService<IConfiguration>();
var logger = sp.GetRequiredService<ILogger<ApiClientSetupMarker>>();

try
{
if (configuration.GetValue("uSync:Command:AddIfMissing", false) is false)
{
return;
}

var clientId = configuration.GetValue("uSync:Command:ClientId", string.Empty);
var clientSecret = configuration.GetValue("uSync:Command:Secret", string.Empty);
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
logger.LogWarning("Clean.Blog API client setup is enabled but ClientId or Secret is missing from configuration");
return;
}

var userService = sp.GetRequiredService<IUserService>();
if (await userService.FindByClientIdAsync(clientId) is not null)
{
return;
}

var fallbackEmail = $"{Path.GetFileNameWithoutExtension(Path.GetRandomFileName())}@example.com";
var userGroupKeys = new HashSet<Guid>
{
configuration.GetValue("uSync:Command:UserGroupKey", Constants.Security.AdminGroupKey),
};

var createAttempt = await userService.CreateAsync(
Constants.Security.SuperUserKey,
new UserCreateModel
{
Email = configuration.GetValue("uSync:Command:Email", fallbackEmail),
UserName = configuration.GetValue("uSync:Command:Username", fallbackEmail),
Kind = UserKind.Api,
Name = configuration.GetValue("uSync:Command:Name", "Clean API User"),
UserGroupKeys = userGroupKeys,
},
approveUser: false);

if (createAttempt.Success is false)
{
logger.LogWarning("Could not create the API user: {status}", createAttempt.Status);
return;
}

var userKey = createAttempt.Result.CreatedUser?.Key;
if (userKey.HasValue is false)
{
logger.LogWarning("Could not create the API user: no key returned");
return;
}

var addClientIdStatus = await userService.AddClientIdAsync(userKey.Value, clientId);
if (addClientIdStatus != UserClientCredentialsOperationStatus.Success)
{
logger.LogWarning("Could not attach client id to user: {status}", addClientIdStatus);
return;
}

var applicationManager = sp.GetService<IBackOfficeApplicationManager>();
if (applicationManager is null)
{
logger.LogWarning("IBackOfficeApplicationManager is not available; OpenIddict client not registered");
return;
}

await applicationManager.EnsureBackOfficeClientCredentialsApplicationAsync(clientId, clientSecret, ct);
await userService.EnableAsync(userKey.Value, new HashSet<Guid> { userKey.Value });

logger.LogInformation("Provisioned API client '{clientId}' for Clean.Blog", clientId);
}
catch (Exception ex)
{
logger.LogError(ex, "Error provisioning the Clean.Blog API client");
}
}

private sealed class ApiClientSetupMarker { }
}
14 changes: 14 additions & 0 deletions template/Clean.Blog/Composing/BlogComposer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Clean.Blog.Migrations;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Infrastructure.Migrations.Notifications;

namespace Clean.Blog.Composing;

public class BlogComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.AddNotificationAsyncHandler<MigrationPlansExecutedNotification, PostMigrationNotificationHandler>();
}
}
16 changes: 16 additions & 0 deletions template/Clean.Blog/Migrations/BlogPackageMigrationPlan.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Umbraco.Cms.Core.Packaging;

namespace Clean.Blog.Migrations;

public class BlogPackageMigrationPlan : PackageMigrationPlan
{
public BlogPackageMigrationPlan()
: base("Clean.Blog")
{
}

protected override void DefinePlan()
{
To<ImportPackageXmlMigration>(new Guid("B1C9D0E1-2F34-4A56-9B78-C0D1E2F3A4B5"));
}
}
38 changes: 38 additions & 0 deletions template/Clean.Blog/Migrations/ImportPackageXmlMigration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Infrastructure.Migrations;
using Umbraco.Cms.Infrastructure.Packaging;

namespace Clean.Blog.Migrations;

public class ImportPackageXmlMigration : AsyncPackageMigrationBase
{
public ImportPackageXmlMigration(
IPackagingService packagingService,
IMediaService mediaService,
MediaFileManager mediaFileManager,
MediaUrlGeneratorCollection mediaUrlGenerators,
IShortStringHelper shortStringHelper,
IContentTypeBaseServiceProvider contentTypeBaseServiceProvider,
IMigrationContext context,
IOptions<PackageMigrationSettings> packageMigrationSettings)
: base(packagingService,
mediaService,
mediaFileManager,
mediaUrlGenerators,
shortStringHelper,
contentTypeBaseServiceProvider,
context, packageMigrationSettings)
{
}

protected override Task MigrateAsync()
{
ImportPackage.FromEmbeddedResource(GetType()).Do();
return Task.CompletedTask;
}
}
Loading
Loading