Skip to content
Merged
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
122 changes: 122 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

# Read-only. Nothing here pushes, tags or releases, so the default write-capable token is
# more authority than the job needs.
permissions:
contents: read

jobs:
build:
name: build
runs-on: ubuntu-latest
# The suite is slow for reasons tracked in #11: one test uses 100 retries with no base delay,
# so jitter saturates toward the 30 second cap and it alone accounts for most of the runtime.
# Generous enough not to flake, tight enough that a hang is not a 6 hour job.
timeout-minutes: 30

steps:
# persist-credentials: false because no step runs an authenticated git or gh command.
# Left on, checkout writes the token into .git/config where any later step, including
# anything a dependency pulls in, can read it.
- uses: actions/checkout@v4
Comment thread
arnelirobles marked this conversation as resolved.
with:
persist-credentials: false

- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x

- run: dotnet restore Carom.sln

# Release, because Release is what ships. Testing Debug while packing Release means the
# assembly consumers install has never had a test run against it.
- run: dotnet build Carom.sln --configuration Release --no-restore

# No LogFileName. A solution with four test projects runs four assemblies, and a fixed
# name makes each one overwrite the last in the shared results directory, so only the
# final assembly's trx survives. The step below then counts 196 of 295 and, worse, would
# have counted a green run as complete if the floor happened to be lower.
- name: Test
run: >
dotnet test Carom.sln
--configuration Release
--no-build
--logger "trx"
--results-directory ${{ github.workspace }}/testresults
--verbosity normal

# A run that discovers no tests exits zero, so the whole job can go green while proving
# nothing. Assert a floor and that everything found actually passed.
- name: Confirm tests actually ran
run: |
TOTAL=0
PASSED=0
for TRX in $(find "${{ github.workspace }}/testresults" -name '*.trx'); do
t=$(grep -o 'total="[0-9]*"' "$TRX" | head -1 | grep -o '[0-9]*')
p=$(grep -o 'passed="[0-9]*"' "$TRX" | head -1 | grep -o '[0-9]*')
TOTAL=$((TOTAL + ${t:-0}))
PASSED=$((PASSED + ${p:-0}))
done
echo "total=$TOTAL passed=$PASSED"
test "${TOTAL:-0}" -ge 250 || { echo "::error::Expected at least 250 tests, got ${TOTAL:-0}"; exit 1; }
test "$TOTAL" = "$PASSED" || { echo "::error::Not every test passed"; exit 1; }

- uses: actions/upload-artifact@v4
if: always()
with:
name: testresults
path: ${{ github.workspace }}/testresults

# Packs every packable project so a broken pack is a pull request failure rather than a
# surprise on release day. Three of these were absent from Carom.sln until this branch, so
# they were published while never being built by CI at all.
- name: Pack
run: dotnet pack Carom.sln --configuration Release --no-build --output ./artifacts

# `ls -la` was diagnostic, not a check: it succeeds whenever any package exists, so a
# missing package or a newly-packable example would both have passed. The point of this
# branch is that the set is exactly right, so assert the set.
- name: Verify the package set
shell: bash
run: |
set -uo pipefail
expected="Carom Carom.AspNetCore Carom.DependencyInjection Carom.EntityFramework Carom.Extensions Carom.Http Carom.Telemetry.OpenTelemetry"

# Strip the version and the .nupkg to get the package id. Symbol packages are excluded
# so a .snupkg alongside a .nupkg is not counted as a second package.
actual=$(find ./artifacts -maxdepth 1 -name '*.nupkg' ! -name '*.symbols.nupkg' -exec basename {} \; \
| sed -E 's/\.[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?\.nupkg$//' | sort -u)

echo "packed:"; printf ' %s\n' $actual

fail=0
for want in $expected; do
printf '%s\n' "$actual" | grep -Fxq "$want" || { echo "::error::Missing package: $want"; fail=1; }
done

# Named explicitly rather than inferred. The example is IsPackable=false today, and this
# is what notices the day somebody removes that line.
if printf '%s\n' "$actual" | grep -Fxq "Carom.Examples.WebApi"; then
echo "::error::Carom.Examples.WebApi was packed. The example must not ship as a package."
fail=1
fi

count=$(printf '%s\n' "$actual" | grep -c . || true)
want_count=$(printf '%s\n' $expected | grep -c .)
if [ "$count" -ne "$want_count" ]; then
echo "::error::Expected exactly $want_count packages, found $count. A new packable project needs adding to this list."
fail=1
fi

test "$fail" -eq 0 || exit 1
echo "Package set is exactly right."
17 changes: 16 additions & 1 deletion Carom.sln
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Carom.EntityFramework", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Carom.Telemetry.OpenTelemetry", "src\Carom.Telemetry.OpenTelemetry\Carom.Telemetry.OpenTelemetry.csproj", "{165F5DE0-AD25-479F-B660-D8641C23E0B0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Carom.ApiApproval.Tests", "tests\Carom.ApiApproval.Tests\Carom.ApiApproval.Tests.csproj", "{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -187,6 +189,18 @@ Global
{165F5DE0-AD25-479F-B660-D8641C23E0B0}.Release|x64.Build.0 = Release|Any CPU
{165F5DE0-AD25-479F-B660-D8641C23E0B0}.Release|x86.ActiveCfg = Release|Any CPU
{165F5DE0-AD25-479F-B660-D8641C23E0B0}.Release|x86.Build.0 = Release|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|x64.ActiveCfg = Debug|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|x64.Build.0 = Debug|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|x86.ActiveCfg = Debug|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|x86.Build.0 = Debug|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|Any CPU.Build.0 = Release|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|x64.ActiveCfg = Release|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|x64.Build.0 = Release|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|x86.ActiveCfg = Release|Any CPU
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -197,9 +211,10 @@ Global
{49E6808A-11DE-48ED-A12B-9236B6BE010D} = {92C92E59-A76E-4AB2-842A-AF22E07901C0}
{10DFCF10-62F1-4CFF-A5BF-163FB31508B9} = {1F64DBA5-AC8A-4CA3-8A49-E423468BC3F8}
{E0693F4E-5901-453D-A6AD-E23F9AC0BE71} = {C595A6A7-8B69-4CC0-90FB-DD1D573D7352}
{764E1709-5079-4CD4-95A8-281C03D42832} = {92C92E59-A76E-4AB2-842A-AF22E07901C0}
{58DC8BE3-799C-4BC3-B614-2387BA262BCA} = {C595A6A7-8B69-4CC0-90FB-DD1D573D7352}
{9B527C2A-65D5-4B62-99ED-6ED7F0265B40} = {C595A6A7-8B69-4CC0-90FB-DD1D573D7352}
{165F5DE0-AD25-479F-B660-D8641C23E0B0} = {C595A6A7-8B69-4CC0-90FB-DD1D573D7352}
{764E1709-5079-4CD4-95A8-281C03D42832} = {92C92E59-A76E-4AB2-842A-AF22E07901C0}
{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20} = {92C92E59-A76E-4AB2-842A-AF22E07901C0}
EndGlobalSection
EndGlobal
3 changes: 3 additions & 0 deletions examples/Carom.Examples.WebApi/Carom.Examples.WebApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<!-- Sample code, not a library. Without this, dotnet pack on the solution produces a
Carom.Examples.WebApi package and a publish step would push it to NuGet. -->
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Carom.AspNetCore
{
public sealed class CaromCircuitBreakerHealthCheck : Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck
{
public CaromCircuitBreakerHealthCheck(string serviceName) { }
public System.Threading.Tasks.Task<Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult> CheckHealthAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckContext context, System.Threading.CancellationToken cancellationToken = default) { }
}
public class CaromHealthCheck : Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck
{
public CaromHealthCheck(string name, System.Func<System.Threading.Tasks.Task<bool>> healthCheckFunc) { }
public System.Threading.Tasks.Task<Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult> CheckHealthAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckContext context, System.Threading.CancellationToken cancellationToken = default) { }
}
public static class CaromServiceCollectionExtensions
{
public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCaromCircuitBreaker(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string serviceName, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, string[]? tags = null) { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
namespace Carom.DependencyInjection
{
public static class CaromServiceCollectionExtensions
{
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddCaromResilience(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action<Carom.DependencyInjection.IResiliencePipelineConfigurator> configureAll) { }
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddCaromResilience(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string name, System.Action<Carom.DependencyInjection.ResiliencePipelineBuilder> configure) { }
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddCaromResilienceRegistry(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { }
}
public interface IResiliencePipelineConfigurator
{
Carom.DependencyInjection.IResiliencePipelineConfigurator AddPipeline(string name, System.Action<Carom.DependencyInjection.ResiliencePipelineBuilder> configure);
}
public interface IResiliencePipelineRegistry
{
Carom.DependencyInjection.ResiliencePipeline GetPipeline(string name);
bool TryGetPipeline(string name, out Carom.DependencyInjection.ResiliencePipeline? pipeline);
}
public interface IResilienceStrategy
{
T Execute<T>(System.Func<T> action);
System.Threading.Tasks.Task<T> ExecuteAsync<T>(System.Func<System.Threading.CancellationToken, System.Threading.Tasks.Task<T>> action, System.Threading.CancellationToken ct);
}
public class ResiliencePipeline
{
public string Name { get; }
public void Execute(System.Action action) { }
public T Execute<T>(System.Func<T> action) { }
public System.Threading.Tasks.Task ExecuteAsync(System.Func<System.Threading.Tasks.Task> action, System.Threading.CancellationToken ct = default) { }
public System.Threading.Tasks.Task ExecuteAsync(System.Func<System.Threading.CancellationToken, System.Threading.Tasks.Task> action, System.Threading.CancellationToken ct = default) { }
public System.Threading.Tasks.Task<T> ExecuteAsync<T>(System.Func<System.Threading.Tasks.Task<T>> action, System.Threading.CancellationToken ct = default) { }
public System.Threading.Tasks.Task<T> ExecuteAsync<T>(System.Func<System.Threading.CancellationToken, System.Threading.Tasks.Task<T>> action, System.Threading.CancellationToken ct = default) { }
}
public class ResiliencePipelineBuilder
{
public ResiliencePipelineBuilder(string name) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddBulkhead(string resourceKey, Carom.Extensions.Compartment config) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddBulkhead(string resourceKey, int maxConcurrency, int queueDepth = 0) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddCircuitBreaker(string serviceKey, Carom.Extensions.Cushion config) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddCircuitBreaker(string serviceKey, int failureThreshold, int samplingWindow) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddFallback<TResult>(System.Func<System.Exception, TResult> fallback) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddFallback<TResult>(TResult fallbackValue) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddRateLimit(string serviceKey, Carom.Extensions.Throttle config) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddRateLimit(string serviceKey, int maxRequests, System.TimeSpan timeWindow) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddRetry(Carom.Bounce config) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddRetry(int retries = 3) { }
public Carom.DependencyInjection.ResiliencePipelineBuilder AddTimeout(System.TimeSpan timeout) { }
public Carom.DependencyInjection.ResiliencePipeline Build() { }
}
public class ResiliencePipelineRegistry : Carom.DependencyInjection.IResiliencePipelineRegistry
{
public ResiliencePipelineRegistry() { }
public Carom.DependencyInjection.ResiliencePipeline GetPipeline(string name) { }
public void Register(string name, Carom.DependencyInjection.ResiliencePipeline pipeline) { }
public bool TryGetPipeline(string name, out Carom.DependencyInjection.ResiliencePipeline? pipeline) { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Carom.EntityFramework
{
public static class CaromDbContextExtensions
{
public static System.Threading.Tasks.Task<T> ExecuteWithRetryAsync<T>(this Microsoft.EntityFrameworkCore.DbContext context, System.Func<System.Threading.Tasks.Task<T>> operation, int retries = 3, System.Threading.CancellationToken cancellationToken = default) { }
public static System.Threading.Tasks.Task<int> SaveChangesWithRetryAsync(this Microsoft.EntityFrameworkCore.DbContext context, Carom.Bounce bounce, System.Threading.CancellationToken cancellationToken = default) { }
public static System.Threading.Tasks.Task<int> SaveChangesWithRetryAsync(this Microsoft.EntityFrameworkCore.DbContext context, int retries = 3, System.Threading.CancellationToken cancellationToken = default) { }
}
}
Loading
Loading