diff --git a/.claude/features-done/gettypes-safe.md b/.claude/features-done/gettypes-safe.md new file mode 100644 index 0000000..dc8f803 --- /dev/null +++ b/.claude/features-done/gettypes-safe.md @@ -0,0 +1,19 @@ +# Feature: gettypes-safe + +## Goal +Make `AddCache()` resilient to `ReflectionTypeLoadException` thrown by `Assembly.GetTypes()` when an unrelated assembly has unresolvable metadata references. PlutusWave hit a fatal startup crash from a Quilt4Net/ApplicationInsights mismatch in a third-party dll. + +## Scope +- `Tharga.Cache/CacheRegistrationExtensions.cs` — add `GetTypesSafe(assembly, logger)` helper, replace `assembly.GetTypes()` calls in `RegisterIPersistFromAssembly` and `InvokeAllPersistRegistrations`. +- Add a test verifying `AddCache` does not throw when a loaded assembly has an unresolvable type. + +## Acceptance Criteria +- `AddCache` completes when an in-process assembly throws `ReflectionTypeLoadException` from `GetTypes()`. +- A warning is logged (per affected assembly) so the root cause isn't silently swallowed. +- Existing tests still pass. + +## Done Condition +User confirms the fix is satisfactory. + +## Originating Branch +develop diff --git a/.claude/settings.json b/.claude/settings.json index a302b6c..adf9d29 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,15 +14,16 @@ "Bash(cat:*)", "Skill(update-config)", "Skill(update-config:*)", - "Bash(grep -r [Inject] --include=*.razor.cs --include=*.cs .)", - "Bash(rm .claude/feature.md)", - "Bash(rm .claude/plan.md)", "Bash(xargs grep:*)", - "Bash(git checkout:*)", "Bash(dotnet test:*)", + "Bash(gh pr:*)", + "Bash(gh run:*)", + "Bash(git checkout:*)", "Bash(git add:*)", "Bash(git commit:*)", "Bash(git merge:*)", + "Bash(git rm:*)", + "Bash(git branch:*)", "Bash(git log:*)" ], "additionalDirectories": [ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8458e30..65b76da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,13 +74,43 @@ jobs: echo "previous_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" echo "Computed version: $VERSION (previous: $LATEST_TAG)" - - name: Pack + - name: Compute pre-release version + id: preversion + if: github.event_name == 'pull_request' run: | - dotnet pack Tharga.Cache/Tharga.Cache.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack Tharga.Cache.Redis/Tharga.Cache.Redis.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack Tharga.Cache.File/Tharga.Cache.File.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }} - dotnet pack Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj -c Release --no-build -o ./artifacts /p:PackageVersion=${{ steps.version.outputs.version }} + VERSION="${{ steps.version.outputs.version }}" + PRE_BASE="${VERSION}-pre" + + LATEST_PRE=$(git tag -l "${PRE_BASE}.*" --sort=-v:refname | head -1) + + if [ -z "$LATEST_PRE" ]; then + COUNTER=1 + else + COUNTER=$(echo "$LATEST_PRE" | sed "s/.*-pre\.\([0-9]*\)/\1/") + COUNTER=$((COUNTER + 1)) + fi + + PRE_VERSION="${PRE_BASE}.${COUNTER}" + echo "version=$PRE_VERSION" >> "$GITHUB_OUTPUT" + echo "Computed pre-release version: $PRE_VERSION" + + - name: Pack (stable — push to master) + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + run: | + dotnet pack Tharga.Cache/Tharga.Cache.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack Tharga.Cache.Redis/Tharga.Cache.Redis.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack Tharga.Cache.File/Tharga.Cache.File.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Pack (pre-release — pull request) + if: github.event_name == 'pull_request' + run: | + dotnet pack Tharga.Cache/Tharga.Cache.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }} + dotnet pack Tharga.Cache.Redis/Tharga.Cache.Redis.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }} + dotnet pack Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }} + dotnet pack Tharga.Cache.File/Tharga.Cache.File.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }} + dotnet pack Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj -c Release --no-build -o ./artifacts -p:PackageVersion=${{ steps.preversion.outputs.version }} - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/Sample/Tharga.Cache.WebApi/Controllers/CacheMonitorController.cs b/Sample/Tharga.Cache.WebApi/Controllers/CacheMonitorController.cs index 5ec0803..013fd0e 100644 --- a/Sample/Tharga.Cache.WebApi/Controllers/CacheMonitorController.cs +++ b/Sample/Tharga.Cache.WebApi/Controllers/CacheMonitorController.cs @@ -30,14 +30,10 @@ public Task GetCache() [HttpGet("type/{type}")] public Task GetType(string type) { - //TODO: Make it possible to provide a type. - //var t = Type.GetType(type); - //if (t == null) return BadRequest($"Cannot find type {type}."); - var t1 = Type.GetType(type); - var t = typeof(WeatherForecast[]); + var info = _cacheMonitor.GetInfos().FirstOrDefault(x => x.Type.Name == type); + if (info == null) return Task.FromResult(NotFound($"Cannot find cache type '{type}'.")); - var datas = _cacheMonitor.GetByType(t); - var response = datas.Select(x => new + var response = info.Items.Select(x => new { x.Key, x.Value.AccessCount, diff --git a/Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj b/Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj index 5e003fe..66602f6 100644 --- a/Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj +++ b/Tharga.Cache.Blazor/Tharga.Cache.Blazor.csproj @@ -27,7 +27,7 @@ - + diff --git a/Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj b/Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj index 0cd2118..3e49e49 100644 --- a/Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj +++ b/Tharga.Cache.File.Tests/Tharga.Cache.File.Tests.csproj @@ -7,10 +7,9 @@ - - + + - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,6 +22,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Tharga.Cache.File/Tharga.Cache.File.csproj b/Tharga.Cache.File/Tharga.Cache.File.csproj index 4f3f402..b18cc6b 100644 --- a/Tharga.Cache.File/Tharga.Cache.File.csproj +++ b/Tharga.Cache.File/Tharga.Cache.File.csproj @@ -35,7 +35,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj b/Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj index 7c3d375..2fd7855 100644 --- a/Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj +++ b/Tharga.Cache.MongoDB.Tests/Tharga.Cache.MongoDB.Tests.csproj @@ -7,10 +7,9 @@ - - + + - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,6 +22,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Tharga.Cache.MongoDB/MongoDB.cs b/Tharga.Cache.MongoDB/MongoDB.cs index 87b5a07..b6720f5 100644 --- a/Tharga.Cache.MongoDB/MongoDB.cs +++ b/Tharga.Cache.MongoDB/MongoDB.cs @@ -37,12 +37,12 @@ public async Task> GetAsync(Key key) { var collection = GetCollection(); - var item = await collection.GetOneAsync(x => x.Id == key.Value, OneOption.SingleOrDefault); //TODO: Should be possible to provide option with ID (not predicate). + var item = await collection.GetOneAsync(key.Value); if (item != null) { if (!item.StaleWhileRevalidate && item.FreshSpan.HasValue && item.FreshSpan.Value != TimeSpan.MaxValue && item.CreateTime.Add(item.FreshSpan.Value) < DateTime.UtcNow) { - await collection.DeleteOneAsync(x => x.Id == item.Id, OneOption.SingleOrDefault); //TODO: Here we should not need a predicate + await collection.DeleteOneAsync(item.Id); return null; } @@ -104,7 +104,7 @@ public Task Invalidate(Key key) public async Task DropAsync(Key key) { var collection = GetCollection(); - var item = await collection.DeleteOneAsync(x => x.Id == key.Value, OneOption.SingleOrDefault); //TODO: Here we should not need a predicate + var item = await collection.DeleteOneAsync(key.Value); return item != null; } diff --git a/Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj b/Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj index 57d21dd..9346643 100644 --- a/Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj +++ b/Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj @@ -35,11 +35,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Tharga.Cache.Redis.Tests/ObsoleteTests.cs b/Tharga.Cache.Redis.Tests/ObsoleteTests.cs index 9131031..164969b 100644 --- a/Tharga.Cache.Redis.Tests/ObsoleteTests.cs +++ b/Tharga.Cache.Redis.Tests/ObsoleteTests.cs @@ -1,3 +1,6 @@ +// These tests deliberately reference an obsolete type to verify the [Obsolete] attribute is present. +#pragma warning disable CS0618 + using FluentAssertions; using Xunit; diff --git a/Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj b/Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj index 112950a..beb4656 100644 --- a/Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj +++ b/Tharga.Cache.Redis.Tests/Tharga.Cache.Redis.Tests.csproj @@ -3,14 +3,15 @@ net10.0 enable + + $(NoWarn);xUnit1051 - - + + - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,6 +24,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Tharga.Cache.Redis/Tharga.Cache.Redis.csproj b/Tharga.Cache.Redis/Tharga.Cache.Redis.csproj index 9bdd022..ec18534 100644 --- a/Tharga.Cache.Redis/Tharga.Cache.Redis.csproj +++ b/Tharga.Cache.Redis/Tharga.Cache.Redis.csproj @@ -42,7 +42,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Tharga.Cache.Tests/GetTypesSafeTests.cs b/Tharga.Cache.Tests/GetTypesSafeTests.cs new file mode 100644 index 0000000..f43cbd4 --- /dev/null +++ b/Tharga.Cache.Tests/GetTypesSafeTests.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using FluentAssertions; +using Xunit; + +namespace Tharga.Cache.Tests; + +public class GetTypesSafeTests +{ + [Fact] + public void NormalAssembly_ReturnsAllTypes() + { + //Arrange + var assembly = typeof(GetTypesSafeTests).Assembly; + + //Act + var types = CacheRegistrationExtensions.GetTypesSafe(assembly); + + //Assert + types.Should().NotBeEmpty(); + types.Should().Contain(typeof(GetTypesSafeTests)); + } + + [Fact] + public void AssemblyThrowingReflectionTypeLoadException_ReturnsLoadedTypesOnly() + { + //Arrange + var loadable = new[] { typeof(string), typeof(int) }; + var assembly = new ThrowingAssembly( + loadedTypes: loadable, + unresolvedCount: 2); + + //Act + var types = CacheRegistrationExtensions.GetTypesSafe(assembly); + + //Assert + types.Should().BeEquivalentTo(loadable); + } + + [Fact] + public void AssemblyThrowingReflectionTypeLoadException_DoesNotPropagate() + { + //Arrange + var assembly = new ThrowingAssembly(loadedTypes: [], unresolvedCount: 1); + + //Act + var act = () => CacheRegistrationExtensions.GetTypesSafe(assembly); + + //Assert + act.Should().NotThrow(); + } + + private sealed class ThrowingAssembly : Assembly + { + private readonly Type[] _loadedTypes; + private readonly int _unresolvedCount; + private static int _counter; + private readonly string _name = $"ThrowingAssembly{++_counter}, Version=1.0.0.0"; + + public ThrowingAssembly(Type[] loadedTypes, int unresolvedCount) + { + _loadedTypes = loadedTypes; + _unresolvedCount = unresolvedCount; + } + + public override string FullName => _name; + + public override Type[] GetTypes() + { + var types = new Type[_loadedTypes.Length + _unresolvedCount]; + for (var i = 0; i < _loadedTypes.Length; i++) types[i] = _loadedTypes[i]; + + var loaderExceptions = new Exception[_unresolvedCount]; + for (var i = 0; i < _unresolvedCount; i++) + loaderExceptions[i] = new TypeLoadException("Could not load type 'Missing.Type' for testing."); + + throw new ReflectionTypeLoadException(types, loaderExceptions); + } + } +} diff --git a/Tharga.Cache.Tests/Helper/AllTypes.cs b/Tharga.Cache.Tests/Helper/AllTypes.cs index 98d2bd7..59b55dd 100644 --- a/Tharga.Cache.Tests/Helper/AllTypes.cs +++ b/Tharga.Cache.Tests/Helper/AllTypes.cs @@ -11,15 +11,10 @@ public IEnumerator GetEnumerator() foreach (var evictionPolicy in evictionPolicies) { - //TODO: Return all types automatically - //yield return [typeof(GenericCache), evictionPolicy, false]; - //yield return [typeof(GenericTimeCache), evictionPolicy, false]; yield return [typeof(EternalCache), evictionPolicy, false]; yield return [typeof(TimeToLiveCache), evictionPolicy, false]; yield return [typeof(TimeToIdleCache), evictionPolicy, false]; - //yield return [typeof(GenericCache), evictionPolicy, true]; - //yield return [typeof(GenericTimeCache), evictionPolicy, true]; yield return [typeof(EternalCache), evictionPolicy, true]; yield return [typeof(TimeToLiveCache), evictionPolicy, true]; yield return [typeof(TimeToIdleCache), evictionPolicy, true]; diff --git a/Tharga.Cache.Tests/Tharga.Cache.Tests.csproj b/Tharga.Cache.Tests/Tharga.Cache.Tests.csproj index fac71f4..a7001d3 100644 --- a/Tharga.Cache.Tests/Tharga.Cache.Tests.csproj +++ b/Tharga.Cache.Tests/Tharga.Cache.Tests.csproj @@ -3,14 +3,15 @@ net10.0 enable + + $(NoWarn);xUnit1051 - - + + - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,6 +24,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Tharga.Cache.Tests/TimeToIdleCacheTests.cs b/Tharga.Cache.Tests/TimeToIdleCacheTests.cs index 805b45a..df2ed5e 100644 --- a/Tharga.Cache.Tests/TimeToIdleCacheTests.cs +++ b/Tharga.Cache.Tests/TimeToIdleCacheTests.cs @@ -40,12 +40,14 @@ public async Task KeepIfUsed(bool keep) _cacheMonitor.DataDropEvent += (_, _) => { monitorDropEventCount++; }; //Act + // Timing chosen so every assertion has >= 100ms margin from the TTI boundary on slow CI runners. + // TTI=400ms; access at t=250 resets idle to expire t=650, etc. _ = await sut.GetAsync("a", () => Task.FromResult("a1"), TimeSpan.FromMilliseconds(400)); - await Task.Delay(200); + await Task.Delay(250); _ = await sut.GetAsync("a", () => Task.FromResult("a2"), TimeSpan.FromMilliseconds(400)); - await Task.Delay(200); - if (keep) _ = await sut.GetAsync("a", () => Task.FromResult("a3"), TimeSpan.FromMilliseconds(400)); await Task.Delay(250); + if (keep) _ = await sut.GetAsync("a", () => Task.FromResult("a3"), TimeSpan.FromMilliseconds(400)); + await Task.Delay(300); var result = await sut.GetAsync("a", () => Task.FromResult("a4"), TimeSpan.FromMilliseconds(400)); //Assert diff --git a/Tharga.Cache.Tests/TimeToLiveCacheTests.cs b/Tharga.Cache.Tests/TimeToLiveCacheTests.cs index ee54220..fa4bd09 100644 --- a/Tharga.Cache.Tests/TimeToLiveCacheTests.cs +++ b/Tharga.Cache.Tests/TimeToLiveCacheTests.cs @@ -40,12 +40,14 @@ public async Task DropEvenIfUsed(bool keep) _cacheMonitor.DataDropEvent += (_, _) => { monitorDropEventCount++; }; //Act + // Timing chosen so every assertion has >= 100ms margin from the TTL boundary on slow CI runners. + // TTL=400ms; delays sum to t=250 (fresh), t=500 (expired), t=800 (re-fetched item still fresh). _ = await sut.GetAsync("a", () => Task.FromResult("a1"), TimeSpan.FromMilliseconds(400)); - await Task.Delay(200); + await Task.Delay(250); _ = await sut.GetAsync("a", () => Task.FromResult("a2"), TimeSpan.FromMilliseconds(400)); - await Task.Delay(200); - if (keep) _ = await sut.GetAsync("a", () => Task.FromResult("a3"), TimeSpan.FromMilliseconds(400)); await Task.Delay(250); + if (keep) _ = await sut.GetAsync("a", () => Task.FromResult("a3"), TimeSpan.FromMilliseconds(400)); + await Task.Delay(300); var result = await sut.GetAsync("a", () => Task.FromResult("a4"), TimeSpan.FromMilliseconds(400)); //Assert diff --git a/Tharga.Cache/CacheOptions.cs b/Tharga.Cache/CacheOptions.cs index cc7e791..09a6bef 100644 --- a/Tharga.Cache/CacheOptions.cs +++ b/Tharga.Cache/CacheOptions.cs @@ -16,12 +16,6 @@ public record CacheOptions /// public TimeSpan WatchDogInterval { get; set; } = TimeSpan.FromSeconds(60); - //TODO: Enable this method in fugure version, so that memory will be default. - //public void RegisterType(Action options = null) - //{ - // RegisterType(options); - //} - public void RegisterType(Action options = null) where TPersist : IPersist { var typeOptions = (Default ?? BuildDefault()) with { }; diff --git a/Tharga.Cache/CacheRegistrationExtensions.cs b/Tharga.Cache/CacheRegistrationExtensions.cs index 11de71e..7fbba44 100644 --- a/Tharga.Cache/CacheRegistrationExtensions.cs +++ b/Tharga.Cache/CacheRegistrationExtensions.cs @@ -135,15 +135,17 @@ private static void RegisterAllIPersistImplementations(IServiceCollection servic private static void RegisterIPersistFromAssembly(IServiceCollection services, Assembly assembly, Type ipersistType) { + var types = GetTypesSafe(assembly); + // Find all interfaces extending IPersist (excluding IPersist itself) - var persistInterfaces = assembly.GetTypes() + var persistInterfaces = types .Where(t => t.IsInterface && ipersistType.IsAssignableFrom(t) && t != ipersistType) .ToList(); foreach (var iface in persistInterfaces) { // Find a non-abstract, non-interface class that implements this interface - var implementation = assembly.GetTypes() + var implementation = types .FirstOrDefault(c => c.IsClass && !c.IsInterface && @@ -166,7 +168,7 @@ private static void InvokeAllPersistRegistrations(IServiceCollection services) foreach (var assembly in assemblies) { - foreach (var type in assembly.GetTypes()) + foreach (var type in GetTypesSafe(assembly)) { if (!registrationType.IsAssignableFrom(type)) continue; if (type.IsAbstract || type.IsInterface) continue; @@ -174,7 +176,6 @@ private static void InvokeAllPersistRegistrations(IServiceCollection services) // Check for parameterless constructor if (type.GetConstructor(Type.EmptyTypes) is not ConstructorInfo ctor) { - // Optional: log or throw continue; } @@ -184,4 +185,35 @@ private static void InvokeAllPersistRegistrations(IServiceCollection services) } } } + + private static readonly HashSet _warnedAssemblies = new(); + private static readonly object _warnLock = new(); + + internal static Type[] GetTypesSafe(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + var loaded = ex.Types.Where(t => t != null).Cast().ToArray(); + var missed = ex.Types.Length - loaded.Length; + + // Warn once per assembly so the root cause isn't silently swallowed. + var name = assembly.FullName ?? "(unknown)"; + bool warn = false; + lock (_warnLock) + { + if (_warnedAssemblies.Add(name)) warn = true; + } + if (warn) + { + Console.Error.WriteLine( + $"[Tharga.Cache] Skipped {missed} unresolvable type(s) in '{name}' during assembly scan: {ex.LoaderExceptions.FirstOrDefault()?.Message}"); + } + + return loaded; + } + } } \ No newline at end of file diff --git a/Tharga.Cache/Tharga.Cache.csproj b/Tharga.Cache/Tharga.Cache.csproj index e4902cc..31d44c6 100644 --- a/Tharga.Cache/Tharga.Cache.csproj +++ b/Tharga.Cache/Tharga.Cache.csproj @@ -43,8 +43,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 0cd70f5..0000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,148 +0,0 @@ -trigger: -- master - -pool: - #vmImage: 'windows-latest' - name: 'Default' - -variables: - buildPlatform: 'Any CPU' - buildConfiguration: 'Release' - BQC.ForceNewBaseline: 'false' - majorMinor: '0.3' - -stages: -- stage: Build - displayName: Build - jobs: - - job: Build - displayName: Build and Test - - steps: - - checkout: self - - - template: buildnumber.yml - parameters: - majorMinor: ${{ variables.majorMinor }} - - - task: UseDotNet@2 - displayName: 'Use .NET 10' - inputs: - version: 10.0.x - includePreviewVersions: false - - - task: NuGetToolInstaller@1 - - - task: DotNetCoreCLI@2 - displayName: 'Restore nuget packages' - inputs: - command: 'restore' - projects: '**/*.csproj' - feedsToUse: 'select' - - - task: DotNetCoreCLI@2 - displayName: 'Build' - inputs: - command: 'build' - projects: '**/*.csproj' - arguments: '-c Release --no-restore /p:Version=$(Build.BuildNumber)' - versioningScheme: 'byBuildNumber' - - - task: DotNetCoreCLI@2 - displayName: 'Test' - inputs: - command: 'test' - projects: '**/*Tests.csproj' - arguments: >- - -c $(buildConfiguration) - --no-build - --filter "(Category!=Integration)&(Category!=TimeCritical)" - /p:CollectCoverage=true - /p:CoverletOutputFormat=cobertura - /p:SkipAutoProps=true - /p:ExcludeByAttribute="Obsolete" - - # - task: BuildQualityChecks@8 - # displayName: 'Build Quality Checks' - # inputs: - # checkWarnings: true - # warningFailOption: 'build' - # allowWarningVariance: true - # warningVariance: '5' - # checkCoverage: true - # coverageFailOption: 'build' - # coverageType: 'blocks' - # allowCoverageVariance: true - # coverageVariance: '5' - - - task: DotNetCoreCLI@2 - displayName: 'Pack Tharga.Cache' - inputs: - command: 'pack' - packagesToPack: '**/Tharga.Cache.csproj' - versioningScheme: 'byBuildNumber' - - - task: DotNetCoreCLI@2 - displayName: 'Pack Tharga.Cache.MongoDB' - inputs: - command: 'pack' - packagesToPack: '**/Tharga.Cache.MongoDB.csproj' - versioningScheme: 'byBuildNumber' - - - task: DotNetCoreCLI@2 - displayName: 'Pack Tharga.Cache.Redis' - inputs: - command: 'pack' - packagesToPack: '**/Tharga.Cache.Redis.csproj' - versioningScheme: 'byBuildNumber' - - - task: DotNetCoreCLI@2 - displayName: 'Pack Tharga.Cache.File' - inputs: - command: 'pack' - packagesToPack: '**/Tharga.Cache.File.csproj' - versioningScheme: 'byBuildNumber' - - - task: DotNetCoreCLI@2 - displayName: 'Pack Tharga.Cache.Blazor' - inputs: - command: 'pack' - packagesToPack: '**/Tharga.Cache.Blazor.csproj' - versioningScheme: 'byBuildNumber' - - - task: PublishBuildArtifacts@1 - displayName: 'Publish artifacts' - inputs: - PathtoPublish: '$(Build.ArtifactStagingDirectory)' - ArtifactName: 'drop' - -- stage: Release - displayName: Release - dependsOn: Build - condition: and(succeeded(), eq(variables['Build.SourceBranchName'], 'master')) - jobs: - - job: Release - displayName: Release - - workspace: - clean: all - - steps: - - download: current - artifact: drop - - checkout: self - persistCredentials: true - - - task: NuGetCommand@2 - displayName: 'Push .nupkg to NuGet.org (with symbols)' - inputs: - command: 'push' - packagesToPush: '$(Pipeline.Workspace)/drop/**/*.symbols.nupkg' - nuGetFeedType: 'external' - publishFeedCredentials: 'Nuget.org' - - - script: | - git tag $(Build.BuildNumber) - git push origin $(Build.BuildNumber) - workingDirectory: $(Build.SourcesDirectory) - displayName: Tag diff --git a/buildnumber.yml b/buildnumber.yml deleted file mode 100644 index ea37132..0000000 --- a/buildnumber.yml +++ /dev/null @@ -1,102 +0,0 @@ -# buildnumber.yml (steps template) -parameters: -- name: majorMinor - type: string - -- name: assignBuildVersionScript - type: string - default: | - $branchMaster = "refs/heads/master" - $majorMinor = $env:MAJOR_MINOR - - if ([string]::IsNullOrWhiteSpace($majorMinor)) { - throw "MAJOR_MINOR is empty. Ensure it is passed via env: MAJOR_MINOR." - } - - $orgUrl = $env:SYSTEM_COLLECTIONURI - $project = $env:SYSTEM_TEAMPROJECT - - $defId = $env:DEF_ID - if ([string]::IsNullOrWhiteSpace($defId) -or $defId -eq "-1") { - throw "DEF_ID is empty or -1 (defId='$defId')." - } - - $headers = @{ - Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" - } - - $uri = "$orgUrl$project/_apis/build/builds?definitions=$defId&branchName=$branchMaster&`$top=50&queryOrder=finishTimeDescending&api-version=7.1" - $resp = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get - - $releasePattern = "^$([regex]::Escape($majorMinor))\.(\d+)$" - $anyPattern = "^$([regex]::Escape($majorMinor))\.(\d+)(?:-pre\.\d+)?$" - - $picked = $null - if ($resp -and $resp.value -and $resp.count -ge 1) { - $latestRelease = $resp.value | Where-Object { $_.buildNumber -match $releasePattern } | Select-Object -First 1 - $latestAny = $resp.value | Where-Object { $_.buildNumber -match $anyPattern } | Select-Object -First 1 - - $picked = $latestRelease - if (-not $picked) { $picked = $latestAny } - } - - if (-not $picked) { - $patch = -1 # so nextPatch becomes 0 for a brand-new majorMinor/pipeline - } - else { - $latestBuildNumber = $picked.buildNumber - if ($latestBuildNumber -match $releasePattern) { $patch = $Matches[1] } - elseif ($latestBuildNumber -match $anyPattern) { $patch = $Matches[1] } - else { throw "Internal mismatch: '$latestBuildNumber' should have matched but did not." } - } - - $nextPatch = ([int]$patch) + 1 - - if ($env:BUILD_SOURCEBRANCH -eq $branchMaster) { - $newBuildNumber = "$majorMinor.$nextPatch" - } - else { - $preBase = "$majorMinor.$nextPatch-pre" - $prePattern = "^$([regex]::Escape($preBase))\.(\d+)$" - - $allUri = "$orgUrl$project/_apis/build/builds?definitions=$defId&`$top=100&queryOrder=finishTimeDescending&api-version=7.1" - $allResp = Invoke-RestMethod -Uri $allUri -Headers $headers -Method Get - - $maxCounter = $allResp.value | - Where-Object { $_.buildNumber -match $prePattern } | - ForEach-Object { [int]$Matches[1] } | - Measure-Object -Maximum | - Select-Object -ExpandProperty Maximum - - $preCounter = if ($maxCounter) { $maxCounter + 1 } else { 1 } - $newBuildNumber = "$preBase.$preCounter" - } - - Write-Host "##vso[build.updatebuildnumber]$newBuildNumber" - -steps: -# Windows -- task: PowerShell@2 - displayName: "Assign build version" - condition: eq( variables['Agent.OS'], 'Windows_NT' ) - inputs: - targetType: inline - pwsh: false - script: ${{ parameters.assignBuildVersionScript }} - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - DEF_ID: $(System.DefinitionId) - MAJOR_MINOR: ${{ parameters.majorMinor }} - -# Linux/macOS -- task: PowerShell@2 - displayName: "Assign build version" - condition: ne( variables['Agent.OS'], 'Windows_NT' ) - inputs: - targetType: inline - pwsh: true - script: ${{ parameters.assignBuildVersionScript }} - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - DEF_ID: $(System.DefinitionId) - MAJOR_MINOR: ${{ parameters.majorMinor }}