From baac04992352ff0df30f2eb9e945a3837a2d58d6 Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:58:16 -0500 Subject: [PATCH 01/10] feat!: target net9.0 and net10.0 with configurable frameworks Library and test projects now read their target frameworks from FirebendTargetFrameworks in Directory.Build.props; the samples use FirebendAppTargetFramework (net10.0). Package versions move to central package management in Directory.Packages.props, with per-framework versions for the Microsoft.Extensions and EF Core packages (9.0.20 on net9.0, 10.0.12 on net10.0) and the other packages updated to their latest stable releases. A repo nuget.config pins restore to nuget.org. CI installs both the 9.0 and 10.0 SDKs, and the checkout and setup-dotnet actions move to v4. Hosted service tests now await ExecuteTask, since BackgroundService.StartAsync no longer runs ExecuteAsync inline on .NET 10. BREAKING CHANGE: packages now target only net9.0 and net10.0. Each Jobba package ships lib/net9.0 and lib/net10.0, and net10.0 consumers now get Microsoft.Extensions.* and Microsoft.EntityFrameworkCore.* 10.0.x dependencies (9.0.20 on net9.0). Minimum dependency versions rise to MassTransit 8.5.11, MongoDB.Driver 3.12.0 and Cronos 0.13.0. On .NET 10, a failing IJobbaReadyGate no longer makes JobbaHostedService.StartAsync throw; the failure surfaces from the background task instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- .config/dotnet-tools.json | 2 +- .github/workflows/build-and-test.yml | 8 ++- .github/workflows/build-test-and-release.yml | 8 ++- Directory.Build.props | 8 +++ Directory.Packages.props | 69 +++++++++++++++++++ Jobba.Core/Jobba.Core.csproj | 10 +-- Jobba.Core/Models/JobRegistration.cs | 10 +-- Jobba.Core/Models/JobbaCoreOptions.cs | 8 +-- Jobba.Cron/Jobba.Cron.csproj | 4 +- .../Jobba.IntegrationTests.csproj | 18 ++--- Jobba.MassTransit/Jobba.MassTransit.csproj | 4 +- Jobba.Redis/Jobba.Redis.csproj | 4 +- Jobba.Sample/Jobba.Sample.csproj | 20 +++--- Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj | 10 +-- .../Jobba.Store.EF.Sqlite.csproj | 10 +-- Jobba.Store.EF/Jobba.Store.EF.csproj | 10 +-- Jobba.Store.Mongo/Jobba.Store.Mongo.csproj | 6 +- .../HostedServices/JobbaHostedServiceTests.cs | 14 +++- Jobba.Tests/Jobba.Tests.csproj | 30 ++++---- .../Mongo/MongoUpdateDefinitionAsserter.cs | 6 +- Jobba.Web.Sample/Jobba.Web.Sample.csproj | 8 +-- Jobba.Web.Sample/Startup.cs | 2 +- nuget.config | 8 +++ 23 files changed, 187 insertions(+), 90 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 nuget.config diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 51c71e54..6ceb3fcf 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.7", + "version": "10.0.12", "commands": [ "dotnet-ef" ] diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index ceefca65..8ad3f3bb 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -13,12 +13,14 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Run dotnet format run: dotnet format jobba.sln --verify-no-changes diff --git a/.github/workflows/build-test-and-release.yml b/.github/workflows/build-test-and-release.yml index 8764aa45..5afd85f8 100644 --- a/.github/workflows/build-test-and-release.yml +++ b/.github/workflows/build-test-and-release.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Git - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: token : ${{ secrets.FIREBEND_GIT_PUSH_PAT}} fetch-depth: 0 @@ -35,9 +35,11 @@ jobs: - name: Setup .NET Core if: steps.versionize.outcome == 'success' - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x - name: Install dependencies if: steps.versionize.outcome == 'success' diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..af8c6cf7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + + net9.0;net10.0 + + net10.0 + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..640d975e --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,69 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Jobba.Core/Jobba.Core.csproj b/Jobba.Core/Jobba.Core.csproj index 605c6604..d8647cb7 100644 --- a/Jobba.Core/Jobba.Core.csproj +++ b/Jobba.Core/Jobba.Core.csproj @@ -4,7 +4,7 @@ 12.0.1 latestmajor disable - net9.0 + $(FirebendTargetFrameworks) 1 @@ -20,10 +20,10 @@ - - - - + + + + diff --git a/Jobba.Core/Models/JobRegistration.cs b/Jobba.Core/Models/JobRegistration.cs index 336fbb5e..6559b11d 100644 --- a/Jobba.Core/Models/JobRegistration.cs +++ b/Jobba.Core/Models/JobRegistration.cs @@ -8,8 +8,10 @@ namespace Jobba.Core.Models; /// public class JobRegistration : IJobbaEntity { - private string _timeZoneId = "UTC"; + // The TimeZoneId setter clears this cache, so it has to stay an explicit field rather than a field-backed property. +#pragma warning disable IDE0032 private TimeZoneInfo _timeZoneInfo; +#pragma warning restore IDE0032 /// /// The Id @@ -91,13 +93,13 @@ public class JobRegistration : IJobbaEntity /// public string TimeZoneId { - get => _timeZoneId; + get; set { - _timeZoneId = value; + field = value; _timeZoneInfo = null; } - } + } = "UTC"; public TimeZoneInfo TimeZoneInfo => _timeZoneInfo ??= TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId ?? "UTC"); diff --git a/Jobba.Core/Models/JobbaCoreOptions.cs b/Jobba.Core/Models/JobbaCoreOptions.cs index f4c9ab3d..3f16a79c 100644 --- a/Jobba.Core/Models/JobbaCoreOptions.cs +++ b/Jobba.Core/Models/JobbaCoreOptions.cs @@ -17,8 +17,6 @@ public class JobbaCoreOptions /// public static readonly TimeSpan DefaultJobWatchInterval = TimeSpan.FromSeconds(10); - private int _staleMultiplier = 3; - /// /// The multiplier applied to JobWatchInterval to determine when an InProgress job is considered stale. /// If a job's LastHeartbeatTime is older than (JobWatchInterval × StaleMultiplier), it will be reclaimed. @@ -27,9 +25,9 @@ public class JobbaCoreOptions /// Thrown when value is less than 1. public int StaleMultiplier { - get => _staleMultiplier; - set => _staleMultiplier = value < 1 + get; + set => field = value < 1 ? throw new ArgumentOutOfRangeException(nameof(StaleMultiplier), "StaleMultiplier must be at least 1.") : value; - } + } = 3; } diff --git a/Jobba.Cron/Jobba.Cron.csproj b/Jobba.Cron/Jobba.Cron.csproj index af201d9c..39f1019e 100644 --- a/Jobba.Cron/Jobba.Cron.csproj +++ b/Jobba.Cron/Jobba.Cron.csproj @@ -1,7 +1,7 @@ - net9.0 + $(FirebendTargetFrameworks) 12.0.1 latestmajor @@ -22,7 +22,7 @@ - + diff --git a/Jobba.IntegrationTests/Jobba.IntegrationTests.csproj b/Jobba.IntegrationTests/Jobba.IntegrationTests.csproj index 25ed85cb..acf3f848 100644 --- a/Jobba.IntegrationTests/Jobba.IntegrationTests.csproj +++ b/Jobba.IntegrationTests/Jobba.IntegrationTests.csproj @@ -1,22 +1,22 @@ - net9.0 + $(FirebendTargetFrameworks) false - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Jobba.MassTransit/Jobba.MassTransit.csproj b/Jobba.MassTransit/Jobba.MassTransit.csproj index 190fea19..94e0ee38 100644 --- a/Jobba.MassTransit/Jobba.MassTransit.csproj +++ b/Jobba.MassTransit/Jobba.MassTransit.csproj @@ -1,7 +1,7 @@ - net9.0 + $(FirebendTargetFrameworks) 12.0.1 latestmajor disable @@ -23,7 +23,7 @@ - + diff --git a/Jobba.Redis/Jobba.Redis.csproj b/Jobba.Redis/Jobba.Redis.csproj index ee3153b1..6985282d 100644 --- a/Jobba.Redis/Jobba.Redis.csproj +++ b/Jobba.Redis/Jobba.Redis.csproj @@ -1,7 +1,7 @@ - net9.0 + $(FirebendTargetFrameworks) 12.0.1 latestmajor disable @@ -19,7 +19,7 @@ - + diff --git a/Jobba.Sample/Jobba.Sample.csproj b/Jobba.Sample/Jobba.Sample.csproj index a059cb6b..2853cf29 100644 --- a/Jobba.Sample/Jobba.Sample.csproj +++ b/Jobba.Sample/Jobba.Sample.csproj @@ -2,22 +2,22 @@ Exe - net9.0 + $(FirebendAppTargetFramework) - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + diff --git a/Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj b/Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj index b95f0437..bf27e65b 100644 --- a/Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj +++ b/Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj @@ -1,7 +1,7 @@ - net9.0 + $(FirebendTargetFrameworks) 12.0.1 latestmajor disable @@ -19,13 +19,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj b/Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj index aa334b22..521acd28 100644 --- a/Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj +++ b/Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj @@ -1,7 +1,7 @@ - net9.0 + $(FirebendTargetFrameworks) 12.0.1 latestmajor disable @@ -19,13 +19,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Jobba.Store.EF/Jobba.Store.EF.csproj b/Jobba.Store.EF/Jobba.Store.EF.csproj index 53cca49d..60b4cc4f 100644 --- a/Jobba.Store.EF/Jobba.Store.EF.csproj +++ b/Jobba.Store.EF/Jobba.Store.EF.csproj @@ -1,7 +1,7 @@ - net9.0 + $(FirebendTargetFrameworks) 12.0.1 latestmajor enable @@ -23,13 +23,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Jobba.Store.Mongo/Jobba.Store.Mongo.csproj b/Jobba.Store.Mongo/Jobba.Store.Mongo.csproj index 22ef97dd..0a5f5fb2 100644 --- a/Jobba.Store.Mongo/Jobba.Store.Mongo.csproj +++ b/Jobba.Store.Mongo/Jobba.Store.Mongo.csproj @@ -1,7 +1,7 @@ - net9.0 + $(FirebendTargetFrameworks) 12.0.1 latestmajor disable @@ -23,8 +23,8 @@ - - + + diff --git a/Jobba.Tests/Core/HostedServices/JobbaHostedServiceTests.cs b/Jobba.Tests/Core/HostedServices/JobbaHostedServiceTests.cs index f63a53ad..c047e158 100644 --- a/Jobba.Tests/Core/HostedServices/JobbaHostedServiceTests.cs +++ b/Jobba.Tests/Core/HostedServices/JobbaHostedServiceTests.cs @@ -36,7 +36,9 @@ public async Task Jobba_Hosted_Service_Should_Execute() var hostedService = fixture.Create(); //act + // On .NET 10 BackgroundService.StartAsync no longer runs ExecuteAsync inline, so wait for the work itself. await hostedService.StartAsync(default); + await hostedService.ExecuteTask; //assert rescheduler.Verify(x => x.RestartFaultedJobsAsync(It.IsAny()), Times.Once); @@ -102,7 +104,11 @@ public async Task Jobba_Hosted_Service_Should_Register_Jobs_With_Store_On_Startu var hostedService = fixture.Create(); //act + // On .NET 10 BackgroundService.StartAsync no longer runs ExecuteAsync inline, so wait for the work itself. await hostedService.StartAsync(default); + await hostedService.ExecuteTask; + + //assert registrationStore.VerifyAll(); } @@ -130,8 +136,12 @@ public async Task Jobba_Hosted_Service_Should_Throw_When_Ready_Gate_Fails() var hostedService = fixture.Create(); //act - await Assert.ThrowsExactlyAsync( - () => hostedService.StartAsync(default)); + // On .NET 9 StartAsync surfaces the failure; on .NET 10 it surfaces through ExecuteTask. + await Assert.ThrowsExactlyAsync(async () => + { + await hostedService.StartAsync(default); + await hostedService.ExecuteTask; + }); //assert readyGate.Verify(x => x.WaitAsync(It.IsAny()), Times.Once); diff --git a/Jobba.Tests/Jobba.Tests.csproj b/Jobba.Tests/Jobba.Tests.csproj index 3266fc77..6742db64 100644 --- a/Jobba.Tests/Jobba.Tests.csproj +++ b/Jobba.Tests/Jobba.Tests.csproj @@ -1,31 +1,31 @@ - net9.0 + $(FirebendTargetFrameworks) false + latestmajor - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + diff --git a/Jobba.Tests/Mongo/MongoUpdateDefinitionAsserter.cs b/Jobba.Tests/Mongo/MongoUpdateDefinitionAsserter.cs index 899d5921..957e19b9 100644 --- a/Jobba.Tests/Mongo/MongoUpdateDefinitionAsserter.cs +++ b/Jobba.Tests/Mongo/MongoUpdateDefinitionAsserter.cs @@ -18,17 +18,15 @@ public static BsonValue SerializeToBsonValue(this T value) public class MongoUpdateDefinitionAsserter { private readonly UpdateDefinition _updateDefinition; - private string _json; - private BsonDocument _setDoc; - public string Json => _json ??= _updateDefinition.Render(new RenderArgs( + public string Json => field ??= _updateDefinition.Render(new RenderArgs( BsonSerializer.SerializerRegistry.GetSerializer(), BsonSerializer.SerializerRegistry) ) .ToBsonDocument() .ToString(); - public BsonDocument SetDoc => _setDoc ??= BsonDocument.Parse(Json)["$set"].AsBsonDocument; + public BsonDocument SetDoc => field ??= BsonDocument.Parse(Json)["$set"].AsBsonDocument; public MongoUpdateDefinitionAsserter(UpdateDefinition updateDefinition) { diff --git a/Jobba.Web.Sample/Jobba.Web.Sample.csproj b/Jobba.Web.Sample/Jobba.Web.Sample.csproj index 227e72e3..57862c46 100644 --- a/Jobba.Web.Sample/Jobba.Web.Sample.csproj +++ b/Jobba.Web.Sample/Jobba.Web.Sample.csproj @@ -1,13 +1,13 @@ - net9.0 + $(FirebendAppTargetFramework) - - - + + + diff --git a/Jobba.Web.Sample/Startup.cs b/Jobba.Web.Sample/Startup.cs index f959773b..34668316 100644 --- a/Jobba.Web.Sample/Startup.cs +++ b/Jobba.Web.Sample/Startup.cs @@ -16,7 +16,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace Jobba.Web.Sample; diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..3e2607ee --- /dev/null +++ b/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + From 8b0c1f64bb16105763ac5d823770dafe479578e4 Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:06:39 -0500 Subject: [PATCH 02/10] chore: correct the changelog header copied from lit-redis Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- .versionize | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.versionize b/.versionize index 77fa5f23..a165f91e 100644 --- a/.versionize +++ b/.versionize @@ -1,6 +1,6 @@ { "changelog": { - "header": "Lit Redis Changelog", + "header": "Jobba Changelog", "includeAllCommits": true, "sections": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index a29be29c..cead949e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -Lit Redis Changelog +Jobba Changelog ## [12.0.1](https://www.github.com/firebend/jobba/releases/tag/v12.0.1) (2026-09-23) From d0914d02984b108f030b86520e0576bb111c8252 Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:06:39 -0500 Subject: [PATCH 03/10] test: pin FluentAssertions to the Apache-licensed 7.x line FluentAssertions 8 requires a commercial license. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- Directory.Packages.props | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 640d975e..6e0ce255 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -57,7 +57,8 @@ - + + From 4b94c4f7e8501366e11121fe4b7418dc4450cfaf Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:06:39 -0500 Subject: [PATCH 04/10] chore: embed package icon and use MIT license expression Replaces the deprecated PackageLicenseUrl and PackageIconUrl (NU5125, NU5048) with PackageLicenseExpression and an embedded nuget-image.png, packed from Directory.Build.props for every project that sets PackageIcon. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- Directory.Build.props | 4 ++++ Jobba.Core/Jobba.Core.csproj | 4 ++-- Jobba.Cron/Jobba.Cron.csproj | 4 ++-- Jobba.MassTransit/Jobba.MassTransit.csproj | 4 ++-- Jobba.Redis/Jobba.Redis.csproj | 4 ++-- Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj | 4 ++-- Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj | 4 ++-- Jobba.Store.EF/Jobba.Store.EF.csproj | 4 ++-- Jobba.Store.Mongo/Jobba.Store.Mongo.csproj | 4 ++-- 9 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index af8c6cf7..d51d7e43 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,4 +5,8 @@ net10.0 + + + + diff --git a/Jobba.Core/Jobba.Core.csproj b/Jobba.Core/Jobba.Core.csproj index d8647cb7..694b4657 100644 --- a/Jobba.Core/Jobba.Core.csproj +++ b/Jobba.Core/Jobba.Core.csproj @@ -14,9 +14,9 @@ firebend;Jobba;job;scheduling;durable;background;jobs Firebend https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/LICENSE + MIT https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/nuget-image.png?raw=true + nuget-image.png diff --git a/Jobba.Cron/Jobba.Cron.csproj b/Jobba.Cron/Jobba.Cron.csproj index 39f1019e..5aa835a6 100644 --- a/Jobba.Cron/Jobba.Cron.csproj +++ b/Jobba.Cron/Jobba.Cron.csproj @@ -12,9 +12,9 @@ firebend;Jobba;job;scheduling;durable;background;jobs;cron Firebend https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/LICENSE + MIT https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/nuget-image.png?raw=true + nuget-image.png diff --git a/Jobba.MassTransit/Jobba.MassTransit.csproj b/Jobba.MassTransit/Jobba.MassTransit.csproj index 94e0ee38..e6aa6755 100644 --- a/Jobba.MassTransit/Jobba.MassTransit.csproj +++ b/Jobba.MassTransit/Jobba.MassTransit.csproj @@ -13,9 +13,9 @@ firebend;Jobba;job;scheduling;durable;background;jobs;masstransit;events Firebend https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/LICENSE + MIT https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/nuget-image.png?raw=true + nuget-image.png diff --git a/Jobba.Redis/Jobba.Redis.csproj b/Jobba.Redis/Jobba.Redis.csproj index 6985282d..793227cc 100644 --- a/Jobba.Redis/Jobba.Redis.csproj +++ b/Jobba.Redis/Jobba.Redis.csproj @@ -13,9 +13,9 @@ firebend;Jobba;job;scheduling;durable;background;jobs;redis;locking;distributed Firebend https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/LICENSE + MIT https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/nuget-image.png?raw=true + nuget-image.png diff --git a/Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj b/Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj index bf27e65b..97789fff 100644 --- a/Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj +++ b/Jobba.Store.EF.Sql/Jobba.Store.EF.Sql.csproj @@ -13,9 +13,9 @@ firebend;Jobba;job;scheduling;durable;background;jobs;ef;sql;data;store Firebend https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/LICENSE + MIT https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/nuget-image.png?raw=true + nuget-image.png diff --git a/Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj b/Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj index 521acd28..4993da66 100644 --- a/Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj +++ b/Jobba.Store.EF.Sqlite/Jobba.Store.EF.Sqlite.csproj @@ -13,9 +13,9 @@ firebend;Jobba;job;scheduling;durable;background;jobs;ef;sqlite;data;store Firebend https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/LICENSE + MIT https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/nuget-image.png?raw=true + nuget-image.png diff --git a/Jobba.Store.EF/Jobba.Store.EF.csproj b/Jobba.Store.EF/Jobba.Store.EF.csproj index 60b4cc4f..932a1ceb 100644 --- a/Jobba.Store.EF/Jobba.Store.EF.csproj +++ b/Jobba.Store.EF/Jobba.Store.EF.csproj @@ -13,9 +13,9 @@ firebend;Jobba;job;scheduling;durable;background;jobs;ef;data;store Firebend https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/LICENSE + MIT https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/nuget-image.png?raw=true + nuget-image.png diff --git a/Jobba.Store.Mongo/Jobba.Store.Mongo.csproj b/Jobba.Store.Mongo/Jobba.Store.Mongo.csproj index 0a5f5fb2..079a03d8 100644 --- a/Jobba.Store.Mongo/Jobba.Store.Mongo.csproj +++ b/Jobba.Store.Mongo/Jobba.Store.Mongo.csproj @@ -13,9 +13,9 @@ firebend;Jobba;job;scheduling;durable;background;jobs;mongo;data;store Firebend https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/LICENSE + MIT https://github.com/firebend/Jobba - https://github.com/firebend/Jobba/blob/main/nuget-image.png?raw=true + nuget-image.png From 8cd2a22969e1d8d800a46f156d4b74acb2323ec1 Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:26:53 -0500 Subject: [PATCH 05/10] build: pin the .NET SDK with global.json global.json pins SDK 10.0.401 with rollForward latestPatch, so local builds and CI run the same analyzer rules and a new SDK feature band can no longer break dotnet format without a code change. CI reads global.json through setup-dotnet and still installs the 9.0 SDK for the net9.0 test runtime. The release workflow now sets up .NET before installing Versionize, so the dotnet tool install runs on the pinned SDK. Dependabot opens monthly dotnet-sdk updates for global.json. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- .github/dependabot.yml | 7 +++++++ .github/workflows/build-and-test.yml | 6 +++--- .github/workflows/build-test-and-release.yml | 16 +++++++--------- global.json | 6 ++++++ 4 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 global.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index eb80fbbd..ab79c217 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,3 +12,10 @@ updates: commit-message: include: scope prefix: "fix: " + - package-ecosystem: "dotnet-sdk" + directory: "/" + schedule: + interval: "monthly" + commit-message: + include: scope + prefix: "chore: " diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 8ad3f3bb..f08e78ac 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -18,9 +18,9 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 9.0.x - 10.0.x + # The .NET 9 SDK supplies the net9.0 runtime for tests; global.json pins the SDK that builds. + dotnet-version: 9.0.x + global-json-file: global.json - name: Run dotnet format run: dotnet format jobba.sln --verify-no-changes diff --git a/.github/workflows/build-test-and-release.yml b/.github/workflows/build-test-and-release.yml index 5afd85f8..816e7b21 100644 --- a/.github/workflows/build-test-and-release.yml +++ b/.github/workflows/build-test-and-release.yml @@ -16,6 +16,13 @@ jobs: token : ${{ secrets.FIREBEND_GIT_PUSH_PAT}} fetch-depth: 0 + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + with: + # The .NET 9 SDK supplies the net9.0 runtime for tests; global.json pins the SDK that builds. + dotnet-version: 9.0.x + global-json-file: global.json + - name: Install Versionize run: dotnet tool install --global Versionize @@ -33,14 +40,6 @@ jobs: if: steps.versionize.outcome != 'success' run: echo "Skipping publishing. No release required." - - name: Setup .NET Core - if: steps.versionize.outcome == 'success' - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 9.0.x - 10.0.x - - name: Install dependencies if: steps.versionize.outcome == 'success' run: dotnet restore jobba.sln @@ -89,7 +88,6 @@ jobs: if: steps.versionize.outcome == 'success' run: dotnet nuget push ./out/pack/*.nupkg --api-key ${{secrets.FIREBEND_NUGET_KEY}} --skip-duplicate --source https://api.nuget.org/v3/index.json - - name: Push version changes if: steps.versionize.outcome == 'success' uses: CasperWA/push-protected@v2 diff --git a/global.json b/global.json new file mode 100644 index 00000000..f3e8020d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestPatch" + } +} From ae7f9c12b5ce83ae892456e3ad0241e7f861643a Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:15:13 -0500 Subject: [PATCH 06/10] docs: document the SDK pin and framework configuration Adds a Building from source section covering global.json and the FirebendTargetFrameworks and FirebendAppTargetFramework properties. Dependabot now ignores major updates to the framework-aligned Microsoft packages, which are versioned per target framework in Directory.Packages.props. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- .github/dependabot.yml | 13 +++++++++++++ README.md | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ab79c217..84e52b87 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,6 +12,19 @@ updates: commit-message: include: scope prefix: "fix: " + # Framework-aligned packages are versioned per target framework in Directory.Packages.props. + # A new major version means a new target framework block, which is added by hand. + ignore: + - dependency-name: "Microsoft.Extensions.*" + update-types: ["version-update:semver-major"] + - dependency-name: "Microsoft.EntityFrameworkCore*" + update-types: ["version-update:semver-major"] + - dependency-name: "Microsoft.AspNetCore.Authentication.JwtBearer" + update-types: ["version-update:semver-major"] + - dependency-name: "Microsoft.AspNetCore.JsonPatch" + update-types: ["version-update:semver-major"] + - dependency-name: "Microsoft.AspNetCore.Mvc.NewtonsoftJson" + update-types: ["version-update:semver-major"] - package-ecosystem: "dotnet-sdk" directory: "/" schedule: diff --git a/README.md b/README.md index 0da1c668..308c0786 100644 --- a/README.md +++ b/README.md @@ -448,3 +448,8 @@ instance can also be wired up as an `IHostedService` (as shown above) to drive i Implementations must be thread-safe, idempotent across concurrent `WaitAsync` callers, and must observe the supplied `CancellationToken`. +## Building from source + +- Building requires the .NET SDK version in [`global.json`](global.json) (10.0.401 or a later 10.0.4xx patch). Running the tests also needs the .NET 9 runtime. +- Target frameworks are set in [`Directory.Build.props`](Directory.Build.props). `FirebendTargetFrameworks` lists the frameworks every library and test project builds for (currently `net9.0;net10.0`). `FirebendAppTargetFramework` is the single framework for samples and other runnable apps. +- Package versions are managed centrally in [`Directory.Packages.props`](Directory.Packages.props). Microsoft framework packages have one version block per target framework, so adding a framework means adding it to `FirebendTargetFrameworks` and adding a matching block there. From 492fc9680fdd0593b92e8681b866eb7c89d3f100 Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:18:23 -0500 Subject: [PATCH 07/10] test: pin ready-gate failure behavior per hosting runtime Assert that StartAsync throws the gate's exception before .NET 10, and that from .NET 10 StartAsync completes while ExecuteTask faults with it, instead of accepting either path. Guard that the Microsoft.Extensions.Hosting.Abstractions major version matches the runtime so the branch reflects the loaded BackgroundService. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- .../HostedServices/JobbaHostedServiceTests.cs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/Jobba.Tests/Core/HostedServices/JobbaHostedServiceTests.cs b/Jobba.Tests/Core/HostedServices/JobbaHostedServiceTests.cs index c047e158..ac5d7b89 100644 --- a/Jobba.Tests/Core/HostedServices/JobbaHostedServiceTests.cs +++ b/Jobba.Tests/Core/HostedServices/JobbaHostedServiceTests.cs @@ -4,11 +4,13 @@ using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; +using FluentAssertions; using Jobba.Core.HostedServices; using Jobba.Core.Interfaces; using Jobba.Core.Interfaces.Repositories; using Jobba.Core.Models; using Jobba.Tests.AutoMoqCustomizations; +using Microsoft.Extensions.Hosting; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -135,15 +137,34 @@ public async Task Jobba_Hosted_Service_Should_Throw_When_Ready_Gate_Fails() var hostedService = fixture.Create(); - //act - // On .NET 9 StartAsync surfaces the failure; on .NET 10 it surfaces through ExecuteTask. - await Assert.ThrowsExactlyAsync(async () => + // BackgroundService comes from Microsoft.Extensions.Hosting.Abstractions, whose version tracks the + // runtime here (9.0.x on net9.0, 10.0.x on net10.0). Guard that so the branch below stays honest. + var hostingMajor = typeof(BackgroundService).Assembly.GetName().Version.Major; + hostingMajor.Should().Be(Environment.Version.Major); + + //act + assert + if (Environment.Version.Major < 10) { - await hostedService.StartAsync(default); - await hostedService.ExecuteTask; - }); + // Before .NET 10, StartAsync runs ExecuteAsync inline up to its first incomplete await, + // so the gate's synchronous failure is thrown from StartAsync and aborts host startup. + var thrown = await Assert.ThrowsExactlyAsync( + () => hostedService.StartAsync(default)); + thrown.Message.Should().Be("gate failed"); + } + else + { + // From .NET 10, StartAsync queues all of ExecuteAsync on the thread pool and returns a completed task; + // the failure surfaces only through ExecuteTask, which the host observes. + var startTask = hostedService.StartAsync(default); + startTask.IsCompletedSuccessfully.Should().BeTrue(); + await startTask; + + hostedService.ExecuteTask.Should().NotBeNull(); + var thrown = await Assert.ThrowsExactlyAsync(() => hostedService.ExecuteTask); + thrown.Message.Should().Be("gate failed"); + hostedService.ExecuteTask.IsFaulted.Should().BeTrue(); + } - //assert readyGate.Verify(x => x.WaitAsync(It.IsAny()), Times.Once); rescheduler.Verify(x => x.RestartFaultedJobsAsync(It.IsAny()), Times.Never); } From b50873065b0a1ed78fa8bcb069951f66e01f2f8d Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:22:39 -0500 Subject: [PATCH 08/10] test: round-trip JobRegistration.TimeZoneId through the Sqlite store TimeZoneId moved to a field-backed property, which changes the backing field EF discovers. Pin that the TimeZoneId column is still written and read, that the UTC default survives persistence, and that re-registering a job updates the stored zone and resets the cached TimeZoneInfo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- .../EF/JobbaEfJobRegistrationStoreTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/Jobba.Tests/EF/JobbaEfJobRegistrationStoreTests.cs b/Jobba.Tests/EF/JobbaEfJobRegistrationStoreTests.cs index d636bb83..8575d732 100644 --- a/Jobba.Tests/EF/JobbaEfJobRegistrationStoreTests.cs +++ b/Jobba.Tests/EF/JobbaEfJobRegistrationStoreTests.cs @@ -9,6 +9,7 @@ using Jobba.Core.Models; using Jobba.Store.EF.DbContexts; using Jobba.Store.EF.Implementations; +using Microsoft.EntityFrameworkCore; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -218,4 +219,77 @@ public async Task Should_Set_Job_Registration_Inactive() jobRegistration.Should().NotBeNull(); jobRegistration!.IsInactive.Should().BeTrue(); } + + [TestMethod] + public async Task Should_Round_Trip_TimeZoneId() + { + //arrange + var registration = _fixture.JobCronRegistrationBuilder() + .With(x => x.TimeZoneId, "America/Chicago") + .Create(); + var store = _fixture.Create(); + + //act + var result = await store.RegisterJobAsync(registration, default); + _dbContext.ChangeTracker.Clear(); + var column = await _dbContext.Database + .SqlQuery($"SELECT TimeZoneId AS Value FROM JobRegistrations WHERE JobName = {registration.JobName}") + .SingleAsync(); + var jobRegistration = await store.GetJobRegistrationAsync(result.Id, default); + + //assert + column.Should().Be("America/Chicago"); + jobRegistration.Should().NotBeNull(); + jobRegistration!.TimeZoneId.Should().Be("America/Chicago"); + jobRegistration.TimeZoneInfo.Id.Should().Be("America/Chicago"); + } + + [TestMethod] + public async Task Should_Default_TimeZoneId_To_Utc_When_Not_Set() + { + //arrange + var registration = _fixture.JobCronRegistrationBuilder() + .Without(x => x.TimeZoneId) + .Create(); + var store = _fixture.Create(); + + //act + var result = await store.RegisterJobAsync(registration, default); + _dbContext.ChangeTracker.Clear(); + var jobRegistration = await store.GetJobRegistrationAsync(result.Id, default); + + //assert + registration.TimeZoneId.Should().Be("UTC"); + jobRegistration.Should().NotBeNull(); + jobRegistration!.TimeZoneId.Should().Be("UTC"); + jobRegistration.TimeZoneInfo.Should().Be(TimeZoneInfo.Utc); + } + + [TestMethod] + public async Task Should_Update_TimeZoneId_And_Reset_TimeZoneInfo_On_Reregister() + { + //arrange + var registration = _fixture.JobCronRegistrationBuilder() + .With(x => x.TimeZoneId, "America/Chicago") + .Create(); + var store = _fixture.Create(); + var original = await store.RegisterJobAsync(registration, default); + original.TimeZoneInfo.Id.Should().Be("America/Chicago"); + + var reRegistration = _fixture.JobCronRegistrationBuilder() + .With(x => x.TimeZoneId, "Asia/Tokyo") + .Create(); + + //act + var updated = await store.RegisterJobAsync(reRegistration, default); + _dbContext.ChangeTracker.Clear(); + var jobRegistration = await store.GetJobRegistrationAsync(original.Id, default); + + //assert + updated.Should().BeSameAs(original); + updated.TimeZoneInfo.Id.Should().Be("Asia/Tokyo"); + jobRegistration.Should().NotBeNull(); + jobRegistration!.TimeZoneId.Should().Be("Asia/Tokyo"); + jobRegistration.TimeZoneInfo.Id.Should().Be("Asia/Tokyo"); + } } From 4be0cbd36fab261d84f3b79e1032adc9c83dc733 Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:35:02 -0500 Subject: [PATCH 09/10] test: wait for MassTransit receive endpoints before publishing The pub/sub tests started the receiver hosted service a second time after harness.Start() had already started it, then published immediately. From .NET 10 BackgroundService.StartAsync returns before ExecuteAsync connects the receive endpoints, so the publish raced the endpoint and the message was dropped, failing intermittently under load. Await the service's ready gate, which completes once every endpoint reports ready, and drop the duplicate start that tried to connect the same endpoint twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JMzvDp6hZ4dnMSGobxdo98 --- .../MassTransit/MassTransitJobEventPublisherTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Jobba.Tests/MassTransit/MassTransitJobEventPublisherTests.cs b/Jobba.Tests/MassTransit/MassTransitJobEventPublisherTests.cs index bfb1867a..ecf1e80c 100644 --- a/Jobba.Tests/MassTransit/MassTransitJobEventPublisherTests.cs +++ b/Jobba.Tests/MassTransit/MassTransitJobEventPublisherTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Jobba.Core.Builders; @@ -95,8 +96,13 @@ private static async Task PublishEventHelper(TMessage message, Func x is MassTransitJobbaReceiverHostedService); hostedService.Should().NotBeNull(); - // ReSharper disable once PossibleNullReferenceException - await hostedService.StartAsync(default); + + // harness.Start() already started every hosted service, including this one. From .NET 10 its StartAsync + // returns before ExecuteAsync connects the receive endpoints, so publishing straight away races the + // endpoint and the message is never consumed. Wait on the service's ready gate instead. + using var readyTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await ((IJobbaReadyGate)hostedService).WaitAsync(readyTimeout.Token); + var publisher = serviceProvider.GetService(); publisher.Should().NotBeNull().And.BeOfType(); From 5b81aaef321c26255636031f4f13728f0f77f300 Mon Sep 17 00:00:00 2001 From: Jacob Atchley <10537213+JacobAtchley@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:21:00 -0500 Subject: [PATCH 10/10] build: bump LitRedis.Core to 6.0.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UvmzsCHKfHVMozvywp42FU --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6e0ce255..718277bd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,7 +40,7 @@ - +