From 9cafd7014cec60ebae1cf0679c0b0520e897999b Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Thu, 23 Jul 2026 11:54:54 +0100 Subject: [PATCH 1/3] Merge handler settings over HandlerDefaults instead of replacing them A handler that defined its own settings block previously ignored the set's HandlerDefaults entirely (GetHandlerSettings returned the handler's Clone()). This meant per-key defaults such as CreateOnly, and strongly typed defaults such as UseFlatStructure, silently did not apply to any handler with its own block - a common "phantom pending change" footgun. Changes: - MergeWithDefaults: merges the Settings dictionary (defaults as base, handler keys win). Used lazily by GetHandlerSettings so late additions to HandlerDefaults.Settings still cascade. - ConfigureHandlerSet: options-layer helper that binds each handler's raw config over a clone of HandlerDefaults, so the strongly typed properties cascade too (an unset bool can't be distinguished from its default once bound, so this has to happen at bind time). Reusable so Complete's named sets can adopt it. - Clone() now preserves CreateClean and FullFileOnDifference (latent bug). Behavioural/breaking change - documented in CHANGELOG.md. Targeting v18. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 38 +++++ .../HandlerSetConfigurationExtensions.cs | 62 ++++++++ .../Configuration/uSyncHandlerSetSettings.cs | 10 +- .../Configuration/uSyncHandlerSettings.cs | 45 +++++- .../uSyncBackOfficeBuilderExtensions.cs | 8 +- .../HandlerSetOptionsBindingTests.cs | 147 ++++++++++++++++++ .../HandlerSettingsMergeTests.cs | 128 +++++++++++++++ 7 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs create mode 100644 uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs create mode 100644 uSync.Tests/Configuration/HandlerSettingsMergeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d9c2916db --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable code and behaviour changes to uSync are recorded here. + +This file tracks changes to how uSync *behaves*. For changes to the on-disk +`.config` file format (which can cause items to report as changed and prompt a +re-export), see [`changes/format.md`](changes/format.md). + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Changed + +- **Handler settings now inherit from `HandlerDefaults`.** When a handler has its + own settings block, its values are layered *over* the set's `HandlerDefaults` + instead of replacing them wholesale. A handler now only needs to specify the + settings it wants to change from the defaults. + - The additional `Settings` dictionary is merged key-by-key (the handler's own + keys win), so a per-key default such as `CreateOnly` set in `HandlerDefaults` + now cascades to handlers that define their own block. + - The strongly typed properties (`UseFlatStructure`, `GuidNames`, etc.) also + cascade, via a new `IServiceCollection.ConfigureHandlerSet(...)` extension + that binds each handler's configuration on top of a clone of the defaults at + options-binding time. + + > **Breaking:** Previously a handler that defined its own block ignored + > `HandlerDefaults` entirely. Configurations that relied on that replacement + > behaviour (i.e. expected a handler block to *reset* settings back to their + > built-in defaults rather than inherit the set defaults) will now see the + > inherited values instead. Review any set that mixes `HandlerDefaults` with + > per-handler blocks. + +### Fixed + +- `HandlerSettings.Clone()` no longer drops the `CreateClean` and + `FullFileOnDifference` properties. Handlers that set either value in their own + block were previously resolved as `false` regardless; they are now honoured. diff --git a/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs b/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs new file mode 100644 index 000000000..e7c734d4f --- /dev/null +++ b/uSync.BackOffice/Configuration/HandlerSetConfigurationExtensions.cs @@ -0,0 +1,62 @@ +using System.Linq; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace uSync.BackOffice.Configuration; + +/// +/// Service collection helpers for registering a uSync handler set from configuration. +/// +public static class HandlerSetConfigurationExtensions +{ + /// + /// Bind a handler set from configuration, and layer each named handler's own settings + /// over the top of the set's . + /// + /// + /// + /// This is a drop-in replacement for services.Configure<uSyncHandlerSetSettings>(setName, section). + /// + /// + /// After the normal bind, each handler that has its own block is re-bound on top of a clone of + /// the resolved . Because configuration binding + /// only writes the keys that are actually present, a handler inherits every default it does not + /// explicitly override - including the strongly typed properties (UseFlatStructure, GuidNames, etc.) + /// that can't be merged after binding (an unset boolean is indistinguishable from one set to its + /// default value once bound). + /// + /// + /// The additional dictionary is also merged here, and is + /// merged again (idempotently) at resolution time in + /// so that keys added to + /// by later post-configure steps still cascade. + /// + /// + public static IServiceCollection ConfigureHandlerSet(this IServiceCollection services, string setName, IConfigurationSection section) + { + services.Configure(setName, section); + services.PostConfigure(setName, options => MergeHandlerDefaults(options, section)); + return services; + } + + /// + /// Re-bind every named handler in the set on top of a clone of the handler defaults. + /// + internal static void MergeHandlerDefaults(uSyncHandlerSetSettings options, IConfigurationSection section) + { + if (options.Handlers is null || options.Handlers.Count == 0) + return; + + var handlersSection = section.GetSection("Handlers"); + + foreach (var alias in options.Handlers.Keys.ToList()) + { + // start from the resolved defaults, then bind the handler's own raw config over the + // top - only the keys present in the handler block will override the defaults. + var merged = options.HandlerDefaults.Clone(); + handlersSection.GetSection(alias).Bind(merged); + options.Handlers[alias] = merged; + } + } +} diff --git a/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs b/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs index 868c21b8f..e728edf56 100644 --- a/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs +++ b/uSync.BackOffice/Configuration/uSyncHandlerSetSettings.cs @@ -48,10 +48,16 @@ public class uSyncHandlerSetSettings public static class HandlerSetSettingsExtensions { /// - /// Get the handler settings for the named handler - (will load defaults if no specific handler settings are found) + /// Get the handler settings for the named handler. /// + /// + /// When the handler has its own settings block, those settings are merged over the top of + /// (see ), + /// so a handler only needs to specify the additional settings it wants to change from the defaults. + /// If the handler has no block of its own, the defaults are used as-is. + /// public static HandlerSettings GetHandlerSettings(this uSyncHandlerSetSettings handlerSet, string alias) => handlerSet.Handlers.TryGetValue(alias, out var value) - ? value.Clone() + ? value.MergeWithDefaults(handlerSet.HandlerDefaults) : handlerSet.HandlerDefaults.Clone(); } diff --git a/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs b/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs index 4fe46a190..910479777 100644 --- a/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs +++ b/uSync.BackOffice/Configuration/uSyncHandlerSettings.cs @@ -128,8 +128,51 @@ public static HandlerSettings Clone(this HandlerSettings settings) UseFlatStructure = settings.UseFlatStructure, Group = settings.Group, GuidNames = settings.GuidNames, - Settings = new Dictionary(settings.Settings, StringComparer.InvariantCultureIgnoreCase) + CreateClean = settings.CreateClean, + FullFileOnDifference = settings.FullFileOnDifference, + Settings = settings.Settings is not null + ? new Dictionary(settings.Settings, StringComparer.InvariantCultureIgnoreCase) + : new Dictionary(StringComparer.InvariantCultureIgnoreCase) }; } + /// + /// Merge a handler's own settings over the top of a set of default settings. + /// + /// + /// + /// Returns a new so neither input is mutated. + /// + /// + /// The strongly typed properties (Enabled, UseFlatStructure, GuidNames, etc.) are taken from + /// . Configuration binding cannot tell an unset boolean from + /// one explicitly set to its default value, so we can't reliably layer these over the defaults + /// without risking overriding a deliberately-set value - the handler's own block wins for them. + /// + /// + /// The additional dictionary is merged, because a + /// key is only present when it has been explicitly configured. The + /// provide the base and the handler's own keys take precedence - so a per-key default (e.g. + /// CreateOnly) set in HandlerDefaults now cascades to handlers that define their own block. + /// + /// + public static HandlerSettings MergeWithDefaults(this HandlerSettings handlerSettings, HandlerSettings defaults) + { + var merged = handlerSettings.Clone(); + + // start from the defaults, then layer the handler's own keys on top. + var settings = defaults.Settings is not null + ? new Dictionary(defaults.Settings, StringComparer.InvariantCultureIgnoreCase) + : new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + if (handlerSettings.Settings is not null) + { + foreach (var setting in handlerSettings.Settings) + settings[setting.Key] = setting.Value; + } + + merged.Settings = settings; + return merged; + } + } diff --git a/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs b/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs index 917fcbdbe..632ca13c0 100644 --- a/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs +++ b/uSync.BackOffice/uSyncBackOfficeBuilderExtensions.cs @@ -55,9 +55,11 @@ public static IUmbracoBuilder AdduSync(this IUmbracoBuilder builder, Action(uSync.Sets.DefaultSet, + // default handler options, other people can load their own names handler options and + // they can be used throughout uSync (so complete will do this). + // ConfigureHandlerSet also layers each handler's own settings over the HandlerDefaults, + // so a handler only needs to specify the settings it wants to change from the defaults. + builder.Services.ConfigureHandlerSet(uSync.Sets.DefaultSet, builder.Config.GetSection(uSync.Configuration.ConfigDefaultSet)); // Setup uSync core. diff --git a/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs b/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs new file mode 100644 index 000000000..32bd7cbf2 --- /dev/null +++ b/uSync.Tests/Configuration/HandlerSetOptionsBindingTests.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using NUnit.Framework; + +using uSync.BackOffice.Configuration; +using uSync.BackOffice.Extensions; + +namespace uSync.Tests.Configuration; + +/// +/// Tests that layers a handler's +/// own configuration over the top of the set's HandlerDefaults - including the strongly typed +/// properties that can only be merged at bind time. +/// +[TestFixture] +public class HandlerSetOptionsBindingTests +{ + private const string SetPath = "uSync:Sets:Default"; + + private static uSyncHandlerSetSettings ResolveSet(Dictionary config) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(config) + .Build(); + + var services = new ServiceCollection(); + services.AddOptions(); + services.ConfigureHandlerSet("Default", configuration.GetSection(SetPath)); + + var provider = services.BuildServiceProvider(); + return provider.GetRequiredService>().Get("Default"); + } + + [Test] + public void StronglyTypedPropertiesCascadeFromDefaults() + { + // ContentHandler only sets CreateOnly - everything else should come from HandlerDefaults. + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:GuidNames"] = "true", + [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "false", + [$"{SetPath}:HandlerDefaults:Settings:FromDefaults"] = "default-value", + [$"{SetPath}:Handlers:ContentHandler:Settings:CreateOnly"] = "true", + }); + + var settings = set.GetHandlerSettings("ContentHandler"); + + Assert.Multiple(() => + { + // strongly typed defaults now cascade (the bit that needed the options layer) + Assert.That(settings.UseFlatStructure, Is.False, "UseFlatStructure should inherit from defaults"); + Assert.That(settings.GuidNames, Is.True, "GuidNames should inherit from defaults"); + // dictionary merge + Assert.That(settings.IsCreateOnly(), Is.True, "handler's own CreateOnly should win"); + Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value")); + }); + } + + [Test] + public void HandlerCanOverrideStronglyTypedDefault() + { + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:GuidNames"] = "true", + [$"{SetPath}:Handlers:FlatHandler:UseFlatStructure"] = "true", + }); + + var settings = set.GetHandlerSettings("FlatHandler"); + + Assert.Multiple(() => + { + Assert.That(settings.UseFlatStructure, Is.True, "handler override should win over default"); + Assert.That(settings.GuidNames, Is.True, "unset property should still inherit from defaults"); + }); + } + + [Test] + public void MinimalBlockInheritsAllUnsetDefaults() + { + // a handler that only sets one property (here Enabled) must still inherit every + // other default - this is the "lots of handlers with near-empty blocks" case. + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:GuidNames"] = "true", + [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "true", + [$"{SetPath}:Handlers:MinimalHandler:Enabled"] = "true", + }); + + var settings = set.GetHandlerSettings("MinimalHandler"); + + Assert.Multiple(() => + { + Assert.That(settings.Enabled, Is.True); + Assert.That(settings.UseFlatStructure, Is.False, "should inherit default, not the C# default of true"); + Assert.That(settings.GuidNames, Is.True); + Assert.That(settings.IsCreateOnly(), Is.True, "should inherit default settings key"); + }); + } + + [Test] + public void EmptyBlockHandlerIsNotEvenBound() + { + // an empty {} block emits no config leaves, so the handler never enters options.Handlers + // and is resolved purely from the defaults via the Clone() fallback. + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:GuidNames"] = "true", + // note: no leaf keys under EmptyBlockHandler are possible from an empty object. + }); + + Assert.That(set.Handlers.ContainsKey("EmptyBlockHandler"), Is.False); + + var settings = set.GetHandlerSettings("EmptyBlockHandler"); + + Assert.Multiple(() => + { + Assert.That(settings.UseFlatStructure, Is.False); + Assert.That(settings.GuidNames, Is.True); + }); + } + + [Test] + public void HandlerWithoutOwnBlockStillUsesDefaults() + { + var set = ResolveSet(new() + { + [$"{SetPath}:HandlerDefaults:UseFlatStructure"] = "false", + [$"{SetPath}:HandlerDefaults:Settings:CreateOnly"] = "true", + }); + + var settings = set.GetHandlerSettings("SomeHandlerWithNoBlock"); + + Assert.Multiple(() => + { + Assert.That(settings.UseFlatStructure, Is.False); + Assert.That(settings.IsCreateOnly(), Is.True); + }); + } +} diff --git a/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs b/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs new file mode 100644 index 000000000..fc6137623 --- /dev/null +++ b/uSync.Tests/Configuration/HandlerSettingsMergeTests.cs @@ -0,0 +1,128 @@ +using NUnit.Framework; + +using uSync.BackOffice.Configuration; +using uSync.BackOffice.Extensions; + +namespace uSync.Tests.Configuration; + +/// +/// Tests for how a handler's own settings are merged with the HandlerDefaults +/// when resolved via . +/// +[TestFixture] +public class HandlerSettingsMergeTests +{ + private static uSyncHandlerSetSettings BuildSet() + { + var set = new uSyncHandlerSetSettings + { + HandlerDefaults = new HandlerSettings + { + UseFlatStructure = false, + } + }; + + set.HandlerDefaults.Settings["CreateOnly"] = "false"; + set.HandlerDefaults.Settings["FromDefaults"] = "default-value"; + + return set; + } + + [Test] + public void HandlerWithoutOwnBlock_UsesDefaults() + { + var set = BuildSet(); + + var settings = set.GetHandlerSettings("ContentHandler"); + + Assert.Multiple(() => + { + Assert.That(settings.IsCreateOnly(), Is.False); + Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value")); + Assert.That(settings.UseFlatStructure, Is.False); + }); + } + + [Test] + public void HandlerOwnKey_OverridesDefaultKey() + { + // the #953 scenario: HandlerDefaults set CreateOnly false, + // the named handler explicitly turns it on. + var set = BuildSet(); + var handler = new HandlerSettings(); + handler.Settings["CreateOnly"] = "true"; + set.Handlers["ContentHandler"] = handler; + + var settings = set.GetHandlerSettings("ContentHandler"); + + Assert.That(settings.IsCreateOnly(), Is.True); + } + + [Test] + public void HandlerWithOwnBlock_InheritsDefaultKeysItDoesNotSet() + { + // previously a handler with its own block ignored HandlerDefaults entirely. + var set = BuildSet(); + var handler = new HandlerSettings(); + handler.Settings["CreateOnly"] = "true"; + set.Handlers["ContentHandler"] = handler; + + var settings = set.GetHandlerSettings("ContentHandler"); + + Assert.That(settings.GetSetting("FromDefaults", string.Empty), Is.EqualTo("default-value")); + } + + [Test] + public void Merge_DoesNotMutateInputs() + { + var set = BuildSet(); + var handler = new HandlerSettings(); + handler.Settings["CreateOnly"] = "true"; + set.Handlers["ContentHandler"] = handler; + + _ = set.GetHandlerSettings("ContentHandler"); + + Assert.Multiple(() => + { + // defaults should not have gained the handler's key + Assert.That(set.HandlerDefaults.Settings.ContainsKey("CreateOnly"), Is.True); + Assert.That(set.HandlerDefaults.Settings["CreateOnly"], Is.EqualTo("false")); + // handler block should not have gained the default's key + Assert.That(handler.Settings.ContainsKey("FromDefaults"), Is.False); + }); + } + + [Test] + public void Clone_PreservesCreateCleanAndFullFileOnDifference() + { + // regression: Clone() used to drop these two properties. + var settings = new HandlerSettings + { + CreateClean = true, + FullFileOnDifference = true, + }; + + var clone = settings.Clone(); + + Assert.Multiple(() => + { + Assert.That(clone.CreateClean, Is.True); + Assert.That(clone.FullFileOnDifference, Is.True); + }); + } + + [Test] + public void MergeWithDefaults_TakesStronglyTypedPropertiesFromHandler() + { + var defaults = new HandlerSettings { GuidNames = true, CreateClean = true }; + var handler = new HandlerSettings { GuidNames = false, CreateClean = false }; + + var merged = handler.MergeWithDefaults(defaults); + + Assert.Multiple(() => + { + Assert.That(merged.GuidNames, Is.False); + Assert.That(merged.CreateClean, Is.False); + }); + } +} From afdbea6cf9d29c2f0d1b06772e4c85770781cd3f Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Thu, 23 Jul 2026 12:01:00 +0100 Subject: [PATCH 2/3] Backfill CHANGELOG with v18 release history (from v18.0.0) Add historical sections for 18.0.0, 18.0.2 and 18.0.3 based on the release build commits on v18/main (the v18.0.0 / v18.0.8 tags are degenerate - both point at the same commit, so point releases aren't individually tagged). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c2916db..a7a745cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This file tracks changes to how uSync *behaves*. For changes to the on-disk re-export), see [`changes/format.md`](changes/format.md). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +History is backfilled from the v18 release history starting at `v18.0.0`. ## [Unreleased] @@ -36,3 +37,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `HandlerSettings.Clone()` no longer drops the `CreateClean` and `FullFileOnDifference` properties. Handlers that set either value in their own block were previously resolved as `false` regardless; they are now honoured. + +## [18.0.3] - 2026-07-22 + +### Fixed + +- Culture-variant property values were dropped on import because of a + `culture`/`cultures` typo. Variant properties now import correctly. (#1000) + +## [18.0.2] - 2026-07-13 + +### Added + +- `ISyncContainerHandler` — lets a handler whose items live in container (folder) + items export those containers one at a time. The Library **Element** handler + implements it, so container folders are no longer left behind when items are + exported individually (e.g. a dependency-based push); previously the folders + were only written during a full export. (#980) + +### Changed + +- **Extender API:** `ISyncContainerHandler.ExportContainer` now takes a `Udi`. + +### Fixed + +- Element container nodes now resolve to the Element handler by type name. +- Merged the latest `v17/main` fixes and performance improvements into v18. + +## [18.0.0] - 2026-06-25 + +### Added + +- Initial uSync release for **Umbraco 18**. + +--- + +> **Note on tags:** the `v18.0.0` and `v18.0.8` git tags both point at the same +> commit, so point releases (18.0.2, 18.0.3) are not individually tagged. The +> versions above are taken from the release build commits on `v18/main`. From 0d7c93a45a1ba42345bde192fefedba5f1295ab2 Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Thu, 23 Jul 2026 12:02:11 +0100 Subject: [PATCH 3/3] Drop tag note from CHANGELOG (tags fixed manually) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a745cf9..49fbc6671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable code and behaviour changes to uSync are recorded here. -This file tracks changes to how uSync *behaves*. For changes to the on-disk +This file tracks changes to how uSync _behaves_. For changes to the on-disk `.config` file format (which can cause items to report as changed and prompt a re-export), see [`changes/format.md`](changes/format.md). @@ -14,7 +14,7 @@ History is backfilled from the v18 release history starting at `v18.0.0`. ### Changed - **Handler settings now inherit from `HandlerDefaults`.** When a handler has its - own settings block, its values are layered *over* the set's `HandlerDefaults` + own settings block, its values are layered _over_ the set's `HandlerDefaults` instead of replacing them wholesale. A handler now only needs to specify the settings it wants to change from the defaults. - The additional `Settings` dictionary is merged key-by-key (the handler's own @@ -27,7 +27,7 @@ History is backfilled from the v18 release history starting at `v18.0.0`. > **Breaking:** Previously a handler that defined its own block ignored > `HandlerDefaults` entirely. Configurations that relied on that replacement - > behaviour (i.e. expected a handler block to *reset* settings back to their + > behaviour (i.e. expected a handler block to _reset_ settings back to their > built-in defaults rather than inherit the set defaults) will now see the > inherited values instead. Review any set that mixes `HandlerDefaults` with > per-handler blocks. @@ -69,9 +69,3 @@ History is backfilled from the v18 release history starting at `v18.0.0`. ### Added - Initial uSync release for **Umbraco 18**. - ---- - -> **Note on tags:** the `v18.0.0` and `v18.0.8` git tags both point at the same -> commit, so point releases (18.0.2, 18.0.3) are not individually tagged. The -> versions above are taken from the release build commits on `v18/main`.