Merge remote-tracking branch 'origin/release/18.0'

This commit is contained in:
Jacob Overgaard
2026-06-17 15:51:52 +02:00
8 changed files with 359 additions and 105 deletions
+31 -105
View File
@@ -825,74 +825,31 @@ stages:
publishFeedCredentials: "MyGet - Umbraco Nightly" publishFeedCredentials: "MyGet - Umbraco Nightly"
${{ else }}: ${{ else }}:
publishFeedCredentials: "MyGet - Pre-releases" publishFeedCredentials: "MyGet - Pre-releases"
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
- job: - job:
displayName: Push to pre-release feed (npm) displayName: Push to pre-release feed (npm)
steps: steps:
- checkout: none - template: templates/npm-publish.yml
- download: current parameters:
artifact: npm artifactName: npm
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly" customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: | displayName: Push to npm (MyGet)
# Setup temp npm project to load in defaults from the local .npmrc ${{ if eq(parameters.isNightly, true) }}:
npm init -y registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
# Find the first .tgz file in the current directory and publish it registry: https://www.myget.org/F/umbracoprereleases/npm/
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm
- job: PublishTestHelpersNpm - job: PublishTestHelpersNpm
displayName: Push TestHelpers to pre-release feed (npm) displayName: Push TestHelpers to pre-release feed (npm)
steps: steps:
- checkout: none - template: templates/npm-publish.yml
- download: current parameters:
artifact: npm-testhelpers artifactName: npm-testhelpers
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly" customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: | displayName: Push test helpers to npm (MyGet)
# Setup temp npm project to load in defaults from the local .npmrc ${{ if eq(parameters.isNightly, true) }}:
npm init -y registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
# Find the first .tgz file in the current directory and publish it registry: https://www.myget.org/F/umbracoprereleases/npm/
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
- stage: Deploy_NuGet - stage: Deploy_NuGet
displayName: NuGet release displayName: NuGet release
@@ -942,60 +899,29 @@ stages:
dependsOn: dependsOn:
- Deploy_NuGet - Deploy_NuGet
variables: variables:
# Empty for stable releases; non-empty (e.g. "beta.1", "rc.2") for prereleases. # `latest` for stable releases, `next` for prereleases.
# Drives the dist-tag below so prereleases don't land on `latest`. npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
npmPrereleaseVersion: $[ stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'] ]
jobs: jobs:
- job: Publish - job: Publish
displayName: Push to NPM displayName: Push to NPM
steps: steps:
- checkout: none - template: templates/npm-publish.yml
- download: current parameters:
artifact: npm artifactName: npm
- script: npm config set @umbraco-cms:registry https://registry.npmjs.org/ --location=project registry: https://registry.npmjs.org/
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/npm
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm/.npmrc
customEndpoint: "NPM - Umbraco Backoffice" customEndpoint: "NPM - Umbraco Backoffice"
- script: | displayName: Push to npm
# Prereleases (beta/rc) publish under the `next` dist-tag so `npm install` npmTag: $(npmDistTag)
# without a version still resolves to the most recent stable on `latest`.
if [ -z "$(npmPrereleaseVersion)" ]; then
tag=latest
else
tag=next
fi
npm publish *.tgz --tag "$tag"
displayName: Push to npm
workingDirectory: $(Pipeline.Workspace)/npm
- job: PublishTestHelpers - job: PublishTestHelpers
displayName: Push Test Helpers to NPM displayName: Push Test Helpers to NPM
steps: steps:
- checkout: none - template: templates/npm-publish.yml
- download: current parameters:
artifact: npm-testhelpers artifactName: npm-testhelpers
- script: npm config set @umbraco-cms:registry https://registry.npmjs.org/ --location=project registry: https://registry.npmjs.org/
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
customEndpoint: "NPM - Umbraco Backoffice" customEndpoint: "NPM - Umbraco Backoffice"
- script: | displayName: Push test helpers to npm
# Prereleases (beta/rc) publish under the `next` dist-tag so `npm install` npmTag: $(npmDistTag)
# without a version still resolves to the most recent stable on `latest`.
if [ -z "$(npmPrereleaseVersion)" ]; then
tag=latest
else
tag=next
fi
npm publish *.tgz --tag "$tag"
displayName: Push test helpers to npm
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
- stage: Upload_API_Docs - stage: Upload_API_Docs
pool: pool:
+28
View File
@@ -0,0 +1,28 @@
parameters:
- name: artifactName # "npm" or "npm-testhelpers"
type: string
- name: registry # scoped-registry URL to publish to
type: string
- name: customEndpoint # npmAuthenticate service connection(s)
type: string
- name: displayName # label for the publish step
type: string
- name: npmTag # dist-tag to publish under
type: string
default: latest
steps:
- checkout: none
- download: current
artifact: ${{ parameters.artifactName }}
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
customEndpoint: ${{ parameters.customEndpoint }}
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
displayName: ${{ parameters.displayName }}
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
#endregion #endregion
#region ElementContainerCacheRefresher
/// <summary>
/// Invalidates the id/key map for the specified deleted element containers (folders).
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="deletedContainers">The element containers that were deleted.</param>
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
=> dc.RefreshByPayload(
ElementContainerCacheRefresher.UniqueId,
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
#endregion
#region Published Snapshot #region Published Snapshot
/// <summary> /// <summary>
@@ -0,0 +1,43 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
/// </summary>
/// <remarks>
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
/// itself, so without this handler the container's stale id/key mapping survives until the next app
/// restart (see #23072).
/// </remarks>
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
{
private readonly DistributedCache _distributedCache;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
/// </summary>
/// <param name="distributedCache">The distributed cache.</param>
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
=> _distributedCache = distributedCache;
/// <inheritdoc />
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
{
EntityContainer[] elementContainers = entities
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
.ToArray();
if (elementContainers.Length == 0)
{
return;
}
_distributedCache.RemoveElementContainerCache(elementContainers);
}
}
@@ -0,0 +1,109 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Provides cache refresh functionality for element containers (folders).
/// </summary>
/// <remarks>
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
/// </remarks>
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
{
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
/// </summary>
public ElementContainerCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory)
: base(appCaches, serializer, eventAggregator, factory)
=> _idKeyMap = idKeyMap;
#region Json
/// <summary>
/// Represents a JSON-serializable payload identifying an element container that changed.
/// </summary>
public class JsonPayload
{
/// <summary>
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
/// </summary>
/// <param name="id">The unique integer identifier for the container.</param>
/// <param name="key">The unique GUID key associated with the container.</param>
public JsonPayload(int id, Guid key)
{
Id = id;
Key = key;
}
/// <summary>
/// Gets the unique integer identifier for the container.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the unique GUID key associated with the container.
/// </summary>
public Guid Key { get; }
}
#endregion
#region Define
/// <summary>
/// Represents a unique identifier for the cache refresher.
/// </summary>
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
/// <inheritdoc/>
public override Guid RefresherUniqueId => UniqueId;
/// <inheritdoc/>
public override string Name => "Element Container Cache Refresher";
#endregion
#region Refresher
/// <inheritdoc/>
public override void Refresh(JsonPayload[] payloads)
{
foreach (JsonPayload payload in payloads)
{
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
_idKeyMap.ClearCache(payload.Id);
}
base.Refresh(payloads);
}
// These events should never trigger. Everything should be PAYLOAD/JSON.
/// <inheritdoc/>
public override void RefreshAll() => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(int id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(Guid id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Remove(int id) => throw new NotSupportedException();
#endregion
}
@@ -0,0 +1,19 @@
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the Element Container Cache Refresher.
/// </summary>
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
/// </summary>
/// <param name="messageObject">The refresher payload.</param>
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
: base(messageObject, messageType)
{
}
}
@@ -466,6 +466,7 @@ public static partial class UmbracoBuilderExtensions
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>() .AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>() .AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>() .AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
; ;
// add notification handlers for auditing // add notification handlers for auditing
@@ -0,0 +1,114 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Cache;
/// <summary>
/// Tests for <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/>.
/// </summary>
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, WithApplication = true)]
internal sealed class ElementContainerDeletedDistributedCacheNotificationHandlerTests : UmbracoIntegrationTest
{
private IElementContainerService ElementContainerService => GetRequiredService<IElementContainerService>();
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
private IElementService ElementService => GetRequiredService<IElementService>();
private IEntityService EntityService => GetRequiredService<IEntityService>();
private static readonly UmbracoObjectTypes[] _treeObjectTypes =
[UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element];
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
// Integration tests use a no-op server messenger and do not register the distributed cache
// notification handlers by default, so opt in to the element handlers under test and a messenger
// that delivers cache refreshes locally.
builder.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>();
builder.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>();
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
}
/// <summary>
/// Regression test for https://github.com/umbraco/Umbraco-CMS/issues/23072: the element tree's children
/// query resolves the container key to an id via <see cref="IIdKeyMap"/>. When a container is deleted the
/// handler must evict its mapping, otherwise a container recreated under the same key resolves to the old
/// (now non-existent) id and nested elements stay invisible in the tree until the application is restarted.
/// </summary>
[Test]
public async Task Can_Resolve_Children_After_Container_Recreated_Under_Same_Key()
{
IContentType elementType = await CreateElementTypeAsync();
var containerKey = Guid.NewGuid();
// Create the container and resolve its children once, so its key->id mapping is cached in IdKeyMap.
EntityContainer firstContainer = await CreateContainerAsync(containerKey, "Container v1");
Attempt<int> warmResolve = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer);
Assert.IsTrue(warmResolve.Success, "Expected IdKeyMap to resolve the newly created container key.");
Assert.AreEqual(firstContainer.Id, warmResolve.Result);
// Delete and recreate under the same key - the recreated container gets a new id.
Attempt<EntityContainer?, EntityContainerOperationStatus> deleteResult =
await ElementContainerService.DeleteAsync(containerKey, Constants.Security.SuperUserKey);
Assert.IsTrue(deleteResult.Success, $"Failed to delete container: {deleteResult.Status}");
EntityContainer secondContainer = await CreateContainerAsync(containerKey, "Container v2");
Assert.AreNotEqual(firstContainer.Id, secondContainer.Id, "Recreated container should have a new id.");
IElement element = CreateElementUnder(secondContainer.Id, elementType);
// Without the fix, the stale containerKey->firstContainer.Id mapping survives and the children query
// resolves to the old (now non-existent) parent id, returning nothing.
Attempt<int> resolvedAfter = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer);
Assert.IsTrue(resolvedAfter.Success, "Expected IdKeyMap to resolve the recreated container key.");
Assert.AreEqual(secondContainer.Id, resolvedAfter.Result, "Container key should resolve to the recreated container id.");
AssertChildrenContains(containerKey, element.Key);
}
private void AssertChildrenContains(Guid containerKey, Guid expectedElementKey)
{
IEntitySlim[] children = EntityService
.GetPagedChildren(containerKey, _treeObjectTypes, _treeObjectTypes, 0, 100, false, out var total)
.ToArray();
Assert.AreEqual(1, total, "Expected the element tree children query to return the nested element.");
Assert.IsTrue(children.Any(child => child.Key == expectedElementKey), "Nested element was not returned by the children query.");
}
private async Task<IContentType> CreateElementTypeAsync()
{
IContentType elementType = ContentTypeBuilder.CreateSimpleElementType();
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
return elementType;
}
private async Task<EntityContainer> CreateContainerAsync(Guid key, string name)
{
Attempt<EntityContainer?, EntityContainerOperationStatus> result =
await ElementContainerService.CreateAsync(key, name, null, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success, $"Failed to create container: {result.Status}");
return result.Result!;
}
private IElement CreateElementUnder(int parentId, IContentType elementType)
{
var element = new Element($"Element {Guid.NewGuid():N}", parentId, elementType);
OperationResult saveResult = ElementService.Save(element);
Assert.IsTrue(saveResult.Success, "Failed to save element.");
return element;
}
}