Eventually got around to trying something more CI/CD led by the git commit hash set to the semantic version… adding to locall extension projects and injecting v={pacakgeVersion} for any 3rd party packages that didn’t do the manual approach, with a little cacheing as it’s a bit more than just reading json for every backoffice url visited. ![]()
dotnet build "%DEPLOYMENT_SOURCE%\Web.csproj" -c Release -r win-x64 -f net10.0 --no-restore --self-contained false -m:1 --disable-build-servers -p:InformationalVersion=%SCM_COMMIT_ID%
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using System.Text.Json.Nodes;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Manifest;
using Umbraco.Cms.Infrastructure.Manifest;
namespace Central.Code.Extensions.ResponseCacheing
{
public class GlobalPackageManifestInterceptor : IPackageManifestReader
{
private readonly IEnumerable<IPackageManifestReader> _underlyingReaders;
private readonly IEntryAssemblyMetadata _entryAssemblyMetadata;
// Lightweight cache to avoid parsing JSON strings on every single backoffice visit
private IEnumerable<PackageManifest>? _cachedManifests;
private readonly SemaphoreSlim _cacheLock = new(1, 1);
// Accepts the isolated array of original readers directly from the composer
public GlobalPackageManifestInterceptor(
IEnumerable<IPackageManifestReader> underlyingReaders,
IEntryAssemblyMetadata entryAssemblyMetadata)
{
_underlyingReaders = underlyingReaders;
_entryAssemblyMetadata = entryAssemblyMetadata;
}
public async Task<IEnumerable<PackageManifest>> ReadPackageManifestsAsync()
{
if (_cachedManifests != null)
{
return _cachedManifests;
}
await _cacheLock.WaitAsync();
try
{
if (_cachedManifests != null)
{
return _cachedManifests;
}
// 1. Safely run the original scanners without duplicating results in the global container
var manifests = new List<PackageManifest>();
foreach (var reader in _underlyingReaders)
{
manifests.AddRange(await reader.ReadPackageManifestsAsync());
}
// 2. Grab your version safely from Umbraco's metadata tracker
var commitVersion = _entryAssemblyMetadata.InformationalVersion ?? "1.0.0";
if (commitVersion.Length > 10 && !commitVersion.Contains("-local"))
{
commitVersion = commitVersion.Substring(0, 10);
}
foreach (var manifest in manifests)
{
// Skip your own package if you are already handling it natively
//if (manifest.Id == "SM.UmbracoForms.CIJApiM") continue;
// Determine the best version context for this specific manifest
var targetVersion = commitVersion;
if (!string.IsNullOrWhiteSpace(manifest.Version) &&
manifest.Version != "0.0.0" &&
manifest.Version != "1.0.0")
{
targetVersion = manifest.Version;
}
if (manifest.Extensions == null) continue;
var updatedExtensions = new List<object>();
foreach (var extension in manifest.Extensions)
{
var jsonString = JsonSerializer.Serialize(extension);
if (JsonNode.Parse(jsonString) is JsonObject jsonObject)
{
bool modified = false;
if (jsonObject["js"] is JsonValue jsValue && jsValue.TryGetValue<string>(out var jsPath))
{
jsonObject["js"] = AppendCacheBuster(jsPath, targetVersion);
modified = true;
}
if (jsonObject["css"] is JsonValue cssValue && cssValue.TryGetValue<string>(out var cssPath))
{
jsonObject["css"] = AppendCacheBuster(cssPath, targetVersion);
modified = true;
}
// Account for modern property editors and custom elements
if (jsonObject["element"] is JsonValue elementValue && elementValue.TryGetValue<string>(out var elementPath))
{
jsonObject["element"] = AppendCacheBuster(elementPath, targetVersion);
modified = true;
}
updatedExtensions.Add(modified ? jsonObject : extension);
}
else
{
updatedExtensions.Add(extension);
}
}
manifest.Extensions = updatedExtensions.ToArray();
manifest.Version = targetVersion;
}
_cachedManifests = manifests;
return _cachedManifests;
}
finally
{
_cacheLock.Release();
}
}
private static string AppendCacheBuster(string path, string version)
{
if (string.IsNullOrWhiteSpace(path)) return path;
var splitPath = path.Split('?');
var basePath = splitPath[0];
if (splitPath.Length > 1)
{
var queryString = splitPath[1];
var parts = queryString.Split('&');
var updatedParts = new List<string>();
bool hasVersionParam = false;
bool shouldKeepOriginal = false;
foreach (var part in parts)
{
if (part.StartsWith("v=", StringComparison.OrdinalIgnoreCase))
{
hasVersionParam = true;
var val = part.Substring(2);
if (!string.IsNullOrEmpty(val) && val != "0.0.0" && val != "1.0.0")
{
shouldKeepOriginal = true;
break;
}
updatedParts.Add($"v={version}");
}
else
{
updatedParts.Add(part);
}
}
if (shouldKeepOriginal)
{
return path;
}
if (!hasVersionParam)
{
updatedParts.Add($"v={version}");
}
return $"{basePath}?{string.Join("&", updatedParts)}";
}
// No query string existed at all, append our target version
return $"{basePath}?v={version}";
}
}
public class CacheBusterComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
// 1. Find all currently registered manifest readers
var descriptorTargets = builder.Services
.Where(x => x.ServiceType == typeof(IPackageManifestReader))
.ToList();
// 2. Remove them from the global DI pool so Umbraco doesn't run them twice
foreach (var descriptor in descriptorTargets)
{
builder.Services.Remove(descriptor);
}
// 3. Register your interceptor as the single source of truth, passing the original descriptors to it
builder.Services.AddSingleton<IPackageManifestReader>(provider =>
{
var originalReaders = new List<IPackageManifestReader>();
foreach (var descriptor in descriptorTargets)
{
if (descriptor.ImplementationType != null)
{
var reader = (IPackageManifestReader)ActivatorUtilities.GetServiceOrCreateInstance(provider, descriptor.ImplementationType);
originalReaders.Add(reader);
}
else if (descriptor.ImplementationFactory != null)
{
var reader = (IPackageManifestReader)descriptor.ImplementationFactory(provider);
originalReaders.Add(reader);
}
else if (descriptor.ImplementationInstance != null)
{
originalReaders.Add((IPackageManifestReader)descriptor.ImplementationInstance);
}
}
// Explicitly pass the isolated core readers into your interceptor
return ActivatorUtilities.CreateInstance<GlobalPackageManifestInterceptor>(provider, (IEnumerable<IPackageManifestReader>)originalReaders);
});
}
}
}