From b35e4955784a832d33264e0b09399330806b3bcb Mon Sep 17 00:00:00 2001 From: gimlichael Date: Wed, 23 Sep 2026 16:39:19 +0200 Subject: [PATCH 01/29] =?UTF-8?q?=F0=9F=94=84=20update=20ci/cd=20workflow?= =?UTF-8?q?=20references=20and=20codecov=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fa415616..f23f7b72 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ All CI and CD integrations have been migrated away from [Microsoft Azure DevOps] All code quality analysis are done by [SonarCloud](https://sonarcloud.io/) and [CodeCov.io](https://codecov.io/). -![License](https://img.shields.io/github/license/codebeltnet/cuemon) ![Build Status](https://github.com/codebeltnet/cuemon/actions/workflows/ci-pipeline.yml/badge.svg?branch=main) [![codecov](https://codecov.io/gh/codebeltnet/cuemon/branch/development/graph/badge.svg)](https://codecov.io/gh/codebeltnet/cuemon) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Cuemon&metric=coverage)](https://sonarcloud.io/dashboard?id=Cuemon) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)](.github/CODE_OF_CONDUCT.md) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/codebeltnet/cuemon/badge)](https://scorecard.dev/viewer/?uri=github.com/codebeltnet/cuemon) +![License](https://img.shields.io/github/license/codebeltnet/cuemon) ![Build Status](https://github.com/codebeltnet/cuemon/actions/workflows/pr.yml/badge.svg?branch=main) [![codecov](https://codecov.io/gh/codebeltnet/cuemon/branch/main/graph/badge.svg)](https://codecov.io/gh/codebeltnet/cuemon) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Cuemon&metric=coverage)](https://sonarcloud.io/dashboard?id=Cuemon) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)](.github/CODE_OF_CONDUCT.md) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/codebeltnet/cuemon/badge)](https://scorecard.dev/viewer/?uri=github.com/codebeltnet/cuemon) ## Branching Strategy From 480adce6fadedd22c877732194cce7f6e6c2dbe2 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Wed, 23 Sep 2026 16:39:38 +0200 Subject: [PATCH 02/29] =?UTF-8?q?=F0=9F=94=84=20update=20contributing=20gu?= =?UTF-8?q?ide=20with=20new=20ci/cd=20workflow=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 69affd83..6bd3d3b5 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -14,7 +14,9 @@ This repository is part of the Codebelt .NET library estate. The instructions be - `src/` contains production projects. - `test/` contains xUnit v3 test projects. - `Cuemon.slnx` is the solution used for local development. -- `.github/workflows/ci-pipeline.yml` is the CI workflow and the authority for the test matrix. +- `.github/workflows/pr.yml` owns the PR test matrix and blocking quality gates. +- `.github/workflows/release.yml` publishes packages from a human-versioned `main` SHA; post-release assurance and DocFX production run after NuGet publication. +- `.github/workflows/deploy.yml` promotes the published DocFX image without rebuilding it. See `.github/workflows/README.md` for the CI/CD handoff and release behavior. - `testenvironments.json` declares the supported `WSL-Ubuntu` and `Docker-Ubuntu` test environments. ## Build From bc6c34882d46250a5c96e450807794aecb3969a6 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Wed, 23 Sep 2026 16:39:49 +0200 Subject: [PATCH 03/29] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20remove=20obsolete?= =?UTF-8?q?=20ci-pipeline=20workflow=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-pipeline.yml | 404 ------------------------------ 1 file changed, 404 deletions(-) delete mode 100644 .github/workflows/ci-pipeline.yml diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml deleted file mode 100644 index e64fc3d0..00000000 --- a/.github/workflows/ci-pipeline.yml +++ /dev/null @@ -1,404 +0,0 @@ -name: Cuemon CI Pipeline -on: - pull_request: - branches: [main] - workflow_dispatch: - inputs: - configuration: - type: choice - description: The build configuration to use in the deploy stage. - required: true - default: Release - options: - - Debug - - Release - run_mac_tests: - type: boolean - description: Run the macOS test matrix despite the additional cost and runtime. - default: false - -permissions: - contents: read - -jobs: - init: - name: initialize - runs-on: ubuntu-24.04 - outputs: - run-privileged-jobs: ${{ steps.vars.outputs.run-privileged-jobs }} - run-mac-tests: ${{ steps.vars.outputs.run-mac-tests }} - strong-name-key-filename: ${{ steps.vars.outputs.strong-name-key-filename }} - build-switches: ${{ steps.vars.outputs.build-switches }} - steps: - - id: vars - name: calculate workflow variables - shell: bash - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.run_mac_tests }}" == "true" ]]; then - echo "run-mac-tests=true" >> "$GITHUB_OUTPUT" - else - echo "run-mac-tests=false" >> "$GITHUB_OUTPUT" - fi - - if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then - echo "run-privileged-jobs=false" >> "$GITHUB_OUTPUT" - echo "strong-name-key-filename=" >> "$GITHUB_OUTPUT" - echo "build-switches=-p:SkipSignAssembly=true" >> "$GITHUB_OUTPUT" - else - echo "run-privileged-jobs=true" >> "$GITHUB_OUTPUT" - echo "strong-name-key-filename=cuemon.snk" >> "$GITHUB_OUTPUT" - echo "build-switches=" >> "$GITHUB_OUTPUT" - fi - - prepare_test: - name: 📜 Prepare Test - runs-on: ubuntu-24.04 - timeout-minutes: 5 - outputs: - json: ${{ steps.test-projects.outputs.result }} - steps: - - name: Checkout - uses: codebeltnet/git-checkout@v1 - - - id: test-projects - name: Generate matrix for test projects - uses: codebeltnet/shell-globbing@v2 - with: - pattern: | - test/**/*.csproj - !test/**/Cuemon.Data.SqlClient.Tests.csproj - - - name: JSON output - run: echo "${{ steps.test-projects.outputs.result }}" - - build: - name: call-build - needs: [init] - strategy: - matrix: - arch: [X64, ARM64] - configuration: [Debug, Release] - uses: codebeltnet/jobs-dotnet-build/.github/workflows/default.yml@v3 - with: - configuration: ${{ matrix.configuration }} - strong-name-key-filename: ${{ needs.init.outputs.strong-name-key-filename }} - build-switches: ${{ needs.init.outputs.build-switches }} - runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} - upload-build-artifact-name: build-${{ matrix.configuration }}-${{ matrix.arch }} - secrets: - GCP_TOKEN: ${{ secrets.GCP_TOKEN }} - GCP_BUCKETNAME: ${{ secrets.GCP_BUCKETNAME }} - - pack: - name: call-pack - needs: [build] - strategy: - matrix: - configuration: [Debug, Release] - uses: codebeltnet/jobs-dotnet-pack/.github/workflows/default.yml@v3 - with: - configuration: ${{ matrix.configuration }} - version: ${{ needs.build.outputs.version }} - download-build-artifact-pattern: build-${{ matrix.configuration }}-X64 - - - test_linux: - name: call-test-linux - needs: [build, prepare_test] - strategy: - fail-fast: false - matrix: - configuration: [Debug, Release] - project: ${{ fromJson(needs.prepare_test.outputs.json) }} - arch: [X64, ARM64] - uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 - with: - runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} - configuration: ${{ matrix.configuration }} - build-switches: -p:SkipSignAssembly=true - projects: ${{ matrix.project }} - build: true # we need to build due to xUnitv3 - restore: true # we need to restore since we disabled caching - download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} - - test_windows: - name: call-test-windows - needs: [build, prepare_test] - strategy: - fail-fast: false - matrix: - arch: [X64, ARM64] - configuration: [Debug, Release] - project: ${{ fromJson(needs.prepare_test.outputs.json) }} - uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 - with: - runs-on: ${{ matrix.arch == 'ARM64' && 'windows-11-arm' || 'windows-2025' }} - configuration: ${{ matrix.configuration }} - build-switches: -p:SkipSignAssembly=true - projects: ${{ matrix.project }} - build: true # we need to build for .net48 - restore: true # apparently we need to restore for .net48 - download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} - - test_mac: - if: ${{ needs.init.outputs.run-mac-tests == 'true' }} - name: call-test-mac - needs: [init, build, prepare_test] - strategy: - fail-fast: false - matrix: - arch: [X64, ARM64] - configuration: [Debug, Release] - project: ${{ fromJson(needs.prepare_test.outputs.json) }} - uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 - with: - runs-on: ${{ matrix.arch == 'ARM64' && 'macos-26' || 'macos-26-intel' }} - configuration: ${{ matrix.configuration }} - build-switches: -p:SkipSignAssembly=true - projects: ${{ matrix.project }} - build: true # we need to build due to xUnitv3 - restore: true # we need to restore since we disabled caching - download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} - - integration_test: - if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} - name: ⚗️ Integration Test - needs: [init, build] - strategy: - fail-fast: false - matrix: - configuration: [Debug, Release] - project: [ test/**/Cuemon.Data.SqlClient.Tests.csproj ] - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Checkout - uses: codebeltnet/git-checkout@v1 - - - name: Install .NET - uses: codebeltnet/install-dotnet@v3 - - - name: Install .NET Tool - Report Generator - uses: codebeltnet/dotnet-tool-install-reportgenerator@v1 - - - name: Spin up SQL Server test dependency for ${{ matrix.configuration }} build - uses: codebeltnet/docker-compose@v1 - with: - command: up - options: --wait - env: - SA_PASSWORD: ${{ secrets.SA_PASSWORD }} - - - name: Download Build Artifacts - uses: actions/download-artifact@v8 - with: - pattern: build-${{ matrix.configuration }}-X64 - merge-multiple: true - - - name: Fix Linux test apphost permissions - run: | - set -euo pipefail - - echo "=== Context ===" - echo "Runner: $RUNNER_OS / $RUNNER_ARCH" - echo "Configuration: ${{ matrix.configuration }}" - echo "Workspace: $GITHUB_WORKSPACE" - echo "PWD: $(pwd)" - echo "Event: $GITHUB_EVENT_NAME" - echo "Ref: $GITHUB_REF" - echo "SHA: $GITHUB_SHA" - echo - - echo "=== .NET info ===" - dotnet --info || true - echo - - echo "=== Git state ===" - git rev-parse HEAD || true - git status --porcelain || true - git rev-parse --is-shallow-repository || true - echo - - # Paths we care about - BIN_GLOB="*/bin/*/net*/*" - OBJ_GLOB="*/obj/*/net*/*" - - echo "=== Brute-force chmod (bin + obj) ===" - find . -type f \( -path "$BIN_GLOB" -o -path "$OBJ_GLOB" \) -exec chmod a+x {} + 2>/dev/null || true - echo "chmod completed (errors ignored)." - echo - - echo "=== Mount options (look for noexec) ===" - # If binaries live on a noexec mount, chmod won't help. - mount | sed -n '1,200p' || true - echo - - echo "=== Candidate executables (top 200) ===" - # Show what we might execute; exclude obvious managed files - find . -type f -path "$BIN_GLOB" \ - ! -name "*.dll" ! -name "*.pdb" ! -name "*.json" ! -name "*.xml" \ - -printf "%m %u:%g %s %p\n" | head -n 200 || true - echo - - echo "=== Likely xUnit / test hosts (if present) ===" - # These names vary; do not rely on just *Tests* - find . -type f -path "$BIN_GLOB" \( \ - -name "testhost*" -o \ - -name "*xunit*" -o \ - -name "*Tests*" -o \ - -name "*.runsettings" \ - \) -printf "%m %u:%g %s %p\n" | head -n 200 || true - echo - - echo "=== Deep diagnostics for any 'testhost' or apphost candidates ===" - # For each likely executable, show the facts that explain 'permission denied' vs 'exec format error' - while IFS= read -r f; do - echo "--- $f ---" - ls -la "$f" || true - - # Identify file type and architecture - file -L "$f" || true - - # If it's an ELF binary, show its dynamic interpreter and linked libs (exec format errors often show up here) - if file -L "$f" | grep -q "ELF"; then - echo "readelf -l (interpreter):" - readelf -l "$f" 2>/dev/null | sed -n '1,80p' || true - echo "ldd (dependencies):" - ldd "$f" 2>/dev/null || true - fi - - # If it's a script, CRLF in the shebang can cause 'Exec format error' - if head -c 2 "$f" 2>/dev/null | grep -q "#!"; then - echo "shebang:" - head -n 1 "$f" | cat -A || true - fi - - echo - done < <( - find . -type f -path "$BIN_GLOB" \( \ - -name "testhost*" -o \ - -name "*xunit*" -o \ - -name "*Tests*" \ - \) | head -n 50 - ) || true - - echo "=== Done diagnostics step ===" - shell: bash - - - name: Test with ${{ matrix.configuration }} build - uses: codebeltnet/dotnet-test@v4 - with: - projects: ${{ matrix.project }} - configuration: ${{ matrix.configuration }} - build: true # apparently we need to due to xUnitv3 - restore: true # we need to restore since we disabled caching - env: - CONNECTIONSTRINGS__ADVENTUREWORKS: ${{ secrets.DB_ADVENTUREWORKS }} - - - name: Upload Integration Test Results - if: always() - uses: actions/upload-artifact@v7 - with: - name: IntegrationTestResults-${{ matrix.configuration }} - path: ${{ runner.temp }}/TestResults - - - name: Take down SQL Server test dependency for ${{ matrix.configuration }} build - if: always() - uses: codebeltnet/docker-compose@v1 - with: - command: down - - test_qualitygate: - if: ${{ always() }} - name: test-qualitygate - needs: [init, test_linux, test_windows, test_mac, integration_test] - runs-on: ubuntu-24.04 - steps: - - name: Evaluate test results - shell: bash - env: - RUN_MAC_TESTS: ${{ needs.init.outputs.run-mac-tests }} - RUN_PRIVILEGED_JOBS: ${{ needs.init.outputs.run-privileged-jobs }} - TEST_LINUX_RESULT: ${{ needs.test_linux.result }} - TEST_WINDOWS_RESULT: ${{ needs.test_windows.result }} - TEST_MAC_RESULT: ${{ needs.test_mac.result }} - INTEGRATION_TEST_RESULT: ${{ needs.integration_test.result }} - run: | - require_success() { - local job_name="$1" - local job_result="$2" - - if [[ "$job_result" != "success" ]]; then - echo "::error::$job_name finished with '$job_result'." - exit 1 - fi - } - - require_success_or_skip() { - local job_name="$1" - local job_enabled="$2" - local job_result="$3" - - if [[ "$job_enabled" == "true" ]]; then - require_success "$job_name" "$job_result" - return - fi - - if [[ "$job_result" != "success" && "$job_result" != "skipped" ]]; then - echo "::error::$job_name finished with '$job_result' while disabled." - exit 1 - fi - } - - require_success "test_linux" "$TEST_LINUX_RESULT" - require_success "test_windows" "$TEST_WINDOWS_RESULT" - require_success_or_skip "test_mac" "$RUN_MAC_TESTS" "$TEST_MAC_RESULT" - require_success_or_skip "integration_test" "$RUN_PRIVILEGED_JOBS" "$INTEGRATION_TEST_RESULT" - - sonarcloud: - if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} - name: call-sonarcloud - needs: [init, build, test_qualitygate] - uses: codebeltnet/jobs-sonarcloud/.github/workflows/default.yml@v3 - with: - organization: geekle - projectKey: Cuemon - version: ${{ needs.build.outputs.version }} - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - codecov: - if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} - name: call-codecov - needs: [init, build, test_qualitygate] - uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@v1 - with: - repository: codebeltnet/cuemon - secrets: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - codeql: - if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} - name: call-codeql - needs: [init, build, test_qualitygate] - uses: codebeltnet/jobs-codeql/.github/workflows/default.yml@v3 - with: - timeout-minutes: 30 - permissions: - security-events: write - - deploy: - # Avoid skipped optional jobs (for example disabled macOS matrix runs) from suppressing deployment. - if: ${{ always() && github.event_name != 'pull_request' && needs.build.result == 'success' && needs.pack.result == 'success' && needs.test_qualitygate.result == 'success' && needs.sonarcloud.result == 'success' && needs.codecov.result == 'success' && needs.codeql.result == 'success' }} - name: call-nuget - needs: [build, pack, test_qualitygate, sonarcloud, codecov, codeql] - uses: codebeltnet/jobs-nuget-push/.github/workflows/default.yml@v3 - with: - version: ${{ needs.build.outputs.version }} - environment: Production - configuration: ${{ inputs.configuration == '' && 'Release' || inputs.configuration }} - permissions: - contents: read - packages: write - secrets: - NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} From 9d3be7d87975fb946ee01e4be1e3e7b0172f0394 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Wed, 23 Sep 2026 16:42:52 +0200 Subject: [PATCH 04/29] =?UTF-8?q?=F0=9F=93=9D=20add=20.bot=20workspace=20r?= =?UTF-8?q?eadme=20with=20ai=20working=20guidelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bot/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .bot/README.md diff --git a/.bot/README.md b/.bot/README.md new file mode 100644 index 00000000..2cfca899 --- /dev/null +++ b/.bot/README.md @@ -0,0 +1,10 @@ +# .bot Workspace + +This folder is reserved for local-only AI working material such as: + +- brainstorm notes +- draft implementation plans +- design alternatives +- temporary agent state + +Keep this folder out of source control. Move only finalized, non-confidential guidance into `AGENTS.md` or `.github/copilot-instructions.md`. From af5491b657cd176dcf924f7f5d020542da62f7e7 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Wed, 23 Sep 2026 16:43:01 +0200 Subject: [PATCH 05/29] =?UTF-8?q?=F0=9F=A4=96=20add=20.bot=20workspace=20t?= =?UTF-8?q?o=20gitignore=20with=20readme=20exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 7d0f3123..e2c9694d 100644 --- a/.gitignore +++ b/.gitignore @@ -230,3 +230,7 @@ ModelManifest.xml # Tooling /tooling/gse/Surrogates /.vscode + +# Bot workspace (local-only AI agent ideation, PRDs, and agentic loop state) +.bot/* +!.bot/README.md From d5859e8f034ec9e8754e64d682be7bc32ff78a1a Mon Sep 17 00:00:00 2001 From: gimlichael Date: Fri, 2 Oct 2026 21:57:37 +0200 Subject: [PATCH 06/29] =?UTF-8?q?=F0=9F=A7=AA=20expand=20servicecollection?= =?UTF-8?q?extensions=20test=20coverage=20with=20comprehensive=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ServiceCollectionExtensionsTest.cs | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) diff --git a/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs b/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs index 7ef4dee3..8ebe91eb 100644 --- a/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs @@ -20,6 +20,430 @@ public ServiceCollectionExtensionsTest(ITestOutputHelper output) : base(output) { } + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void Registration_ShouldResolveWithSpecifiedLifetime_UsingTypesAndFactories(ServiceLifetime lifetime) + { + Action[] registrations = + { + s => s.Add(lifetime), + s => s.TryAdd(lifetime), + s => s.Add(_ => new DefaultService(), lifetime), + s => s.TryAdd(_ => new DefaultService(), lifetime) + }; + foreach (var register in registrations) + { + var services = new ServiceCollection(); + register(services); + Assert.Equal(lifetime, Assert.Single(services).Lifetime); + using (var provider = services.BuildServiceProvider()) + using (var firstScope = provider.CreateScope()) + using (var secondScope = provider.CreateScope()) + { + var first = firstScope.ServiceProvider.GetRequiredService(); + var repeated = firstScope.ServiceProvider.GetRequiredService(); + var other = secondScope.ServiceProvider.GetRequiredService(); + Assert.IsType(first); + if (lifetime == ServiceLifetime.Transient) { Assert.NotSame(first, repeated); } + else { Assert.Same(first, repeated); } + if (lifetime == ServiceLifetime.Singleton) { Assert.Same(first, other); } + else { Assert.NotSame(first, other); } + } + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Forwarding_ShouldResolveSelectedInterfacesToSameInstance(bool tryAdd, bool factory) + { + var services = new ServiceCollection(); + Action setup = o => o.Lifetime = ServiceLifetime.Singleton; + IServiceCollection result; + if (tryAdd) + { + result = factory ? services.TryAdd(_ => new Foo(), setup) : services.TryAdd(setup); + } + else + { + result = factory ? services.Add(_ => new Foo(), setup) : services.Add(setup); + } + Assert.Same(services, result); + Assert.Equal(3, services.Count); + using (var provider = services.BuildServiceProvider()) + { + Assert.Same(provider.GetRequiredService(), provider.GetRequiredService()); + Assert.Same(provider.GetRequiredService(), provider.GetRequiredService()); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Forwarding_ShouldChooseMarkerInterface_WhenGenericAndNonGenericInterfacesShareName(bool tryAdd, bool factory) + { + var services = new ServiceCollection(); + Action setup = o => o.Lifetime = ServiceLifetime.Singleton; + if (tryAdd) + { + if (factory) { services.TryAdd>(_ => new DefaultService(), setup); } + else { services.TryAdd>(setup); } + } + else + { + if (factory) { services.Add>(_ => new DefaultService(), setup); } + else { services.Add>(setup); } + } + using (var provider = services.BuildServiceProvider()) + { + Assert.Same(provider.GetRequiredService>(), provider.GetRequiredService>()); + Assert.Null(provider.GetService()); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FactoryForwarding_ShouldHonorDisabledForwardingAndPredicate(bool tryAdd) + { + foreach (var disabled in new[] { false, true }) + { + var services = new ServiceCollection(); + Action setup = o => + { + o.UseNestedTypeForwarding = !disabled; + o.NestedTypeSelector = _ => new[] { typeof(IFoo), typeof(IBar) }; + o.NestedTypePredicate = t => t == typeof(IBar); + o.Lifetime = ServiceLifetime.Singleton; + }; + var result = tryAdd ? services.TryAdd(_ => new Foo(), setup) : services.Add(_ => new Foo(), setup); + Assert.Same(services, result); + using (var provider = services.BuildServiceProvider()) + { + Assert.Null(provider.GetService()); + if (disabled) { Assert.Null(provider.GetService()); } + else { Assert.Same(provider.GetRequiredService(), provider.GetRequiredService()); } + } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Registration_ShouldPreserveDuplicatesOnlyForAdd(bool factory) + { + var services = new ServiceCollection(); + var original = new DefaultService(); + services.AddSingleton(original); + var descriptor = services.Single(); + var result = factory + ? services.TryAdd(_ => new DefaultService(), ServiceLifetime.Transient) + : services.TryAdd(ServiceLifetime.Transient); + Assert.Same(services, result); + Assert.Same(descriptor, Assert.Single(services)); + if (factory) { services.Add(_ => new DefaultService(), ServiceLifetime.Transient); } + else { services.Add(ServiceLifetime.Transient); } + using (var provider = services.BuildServiceProvider()) + { + var resolved = provider.GetServices().ToArray(); + Assert.Equal(2, resolved.Length); + Assert.Same(original, resolved[0]); + Assert.NotSame(original, resolved[1]); + } + } + + [Fact] + public void Registration_ShouldLeaveCollectionEmpty_WhenLifetimeIsUnknown() + { + var services = new ServiceCollection(); + var lifetime = (ServiceLifetime)int.MaxValue; + Assert.Same(services, services.Add(lifetime)); + Assert.Same(services, services.TryAdd(lifetime)); + Assert.Same(services, services.Add(_ => new DefaultService(), lifetime)); + Assert.Same(services, services.TryAdd(_ => new DefaultService(), lifetime)); + Assert.Empty(services); + } + + [Fact] + public void TryConfigure_ShouldReturnNull_WhenServicesAreNull() + { + Assert.Null(ServiceCollectionExtensions.TryConfigure(null, o => o.Greeting = "Hello")); + } + + [Fact] + public void TryConfigure_ShouldIgnoreNullSetup_WhenOptionsAlreadyRegistered() + { + var services = new ServiceCollection(); + services.Configure(o => o.Greeting = "First"); + Assert.Same(services, services.TryConfigure(null)); + using (var provider = services.BuildServiceProvider()) + { + Assert.Equal("First", provider.GetRequiredService>().Value.Greeting); + } + } + + [Theory] + [InlineData(false, false, "First")] + [InlineData(false, true, "Second")] + [InlineData(true, false, "First")] + [InlineData(true, true, "First")] + public void RegistrationWithOptions_ShouldHonorExistingConfiguration(bool tryAdd, bool factory, string expected) + { + var services = new ServiceCollection(); + services.Configure(o => o.Greeting = "First"); + IServiceCollection result; + if (tryAdd) + { + result = factory + ? services.TryAdd(_ => new DefaultService(), ServiceLifetime.Singleton, o => o.Greeting = "Second") + : services.TryAdd(ServiceLifetime.Singleton, o => o.Greeting = "Second"); + } + else + { + result = factory + ? services.Add(_ => new DefaultService(), ServiceLifetime.Singleton, o => o.Greeting = "Second") + : services.Add(ServiceLifetime.Singleton, o => o.Greeting = "Second"); + } + Assert.Same(services, result); + using (var provider = services.BuildServiceProvider()) + { + Assert.IsType(provider.GetRequiredService()); + Assert.Equal(expected, provider.GetRequiredService>().Value.Greeting); + } + } + + [Fact] + public void Registration_ShouldRejectNullServices_ForEveryOverload() + { + Func[] registrations = + { + s => s.Add(ServiceLifetime.Singleton), + s => s.Add(ServiceLifetime.Singleton, _ => { }), + s => s.Add(typeof(IService), typeof(DefaultService), ServiceLifetime.Singleton), + s => s.Add(typeof(IService), typeof(DefaultService), ServiceLifetime.Singleton, _ => { }), + s => s.Add(_ => new DefaultService(), ServiceLifetime.Singleton), + s => s.Add(_ => new DefaultService(), ServiceLifetime.Singleton, _ => { }), + s => s.Add(typeof(IService), _ => new DefaultService(), ServiceLifetime.Singleton), + s => s.Add(typeof(IService), _ => new DefaultService(), ServiceLifetime.Singleton, _ => { }), + s => s.Add(), + s => s.Add(), + s => s.Add(typeof(IService), typeof(DefaultService)), + s => s.Add(_ => new Foo()), + s => s.Add(_ => new DefaultService()), + s => s.Add(typeof(IService), _ => new DefaultService()), + s => s.TryAdd(), + s => s.TryAdd(), + s => s.TryAdd(typeof(IService), typeof(DefaultService)), + s => s.TryAdd(_ => new Foo()), + s => s.TryAdd(_ => new DefaultService()), + s => s.TryAdd(typeof(IService), _ => new DefaultService()), + s => s.TryAdd(ServiceLifetime.Singleton), + s => s.TryAdd(ServiceLifetime.Singleton, _ => { }), + s => s.TryAdd(typeof(IService), typeof(DefaultService), ServiceLifetime.Singleton), + s => s.TryAdd(typeof(IService), typeof(DefaultService), ServiceLifetime.Singleton, _ => { }), + s => s.TryAdd(_ => new DefaultService(), ServiceLifetime.Singleton), + s => s.TryAdd(_ => new DefaultService(), ServiceLifetime.Singleton, _ => { }), + s => s.TryAdd(typeof(IService), _ => new DefaultService(), ServiceLifetime.Singleton), + s => s.TryAdd(typeof(IService), _ => new DefaultService(), ServiceLifetime.Singleton, _ => { }) + }; + foreach (var register in registrations) + { + Assert.Equal("services", Assert.Throws(() => register(null)).ParamName); + } + } + + [Fact] + public void Registration_ShouldRejectNullSetup_BeforeAddingService() + { + var services = new ServiceCollection(); + Func[] registrations = + { + () => services.Add(ServiceLifetime.Singleton, null), + () => services.Add(_ => new DefaultService(), ServiceLifetime.Singleton, null), + () => services.TryAdd(ServiceLifetime.Singleton, null), + () => services.TryAdd(_ => new DefaultService(), ServiceLifetime.Singleton, null) + }; + foreach (var register in registrations) + { + Assert.Equal("setup", Assert.Throws(() => register()).ParamName); + Assert.Empty(services); + } + Assert.Equal("configureOptions", Assert.Throws(() => services.TryConfigure(null)).ParamName); + Assert.Empty(services); + } + + [Fact] + public void Forwarding_ShouldRegisterOnlyService_WhenNoNestedTypesMatch() + { + var services = new ServiceCollection(); + Assert.Same(services, services.Add(o => o.NestedTypePredicate = _ => false)); + Assert.Equal(typeof(Foo), Assert.Single(services).ServiceType); + using (var provider = services.BuildServiceProvider()) + { + Assert.NotNull(provider.GetRequiredService()); + Assert.Null(provider.GetService()); + Assert.Null(provider.GetService()); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TryAddWithForwarding_ShouldPreserveExistingServiceAndInterface(bool factory) + { + var services = new ServiceCollection(); + var originalService = new Foo(); + var originalInterface = new Foo(); + services.AddSingleton(originalService); + services.AddSingleton(originalInterface); + var result = factory ? services.TryAdd(_ => new Foo()) : services.TryAdd(); + Assert.Same(services, result); + Assert.Equal(3, services.Count); + using (var provider = services.BuildServiceProvider()) + { + Assert.Same(originalService, provider.GetRequiredService()); + Assert.Same(originalInterface, provider.GetRequiredService()); + Assert.Same(originalService, provider.GetRequiredService()); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Forwarding_ShouldLeavePrimaryRegistration_WhenSelectorOrPredicateIsNull(bool tryAdd, bool factory) + { + foreach (var nullSelector in new[] { false, true }) + { + var services = new ServiceCollection(); + Action setup = o => + { + if (nullSelector) { o.NestedTypeSelector = null; } + else { o.NestedTypePredicate = null; } + }; + // Characterize the current partial registration so the refactor can change it deliberately. + Assert.Throws(() => + { + if (tryAdd) + { + if (factory) { services.TryAdd(_ => new Foo(), setup); } + else { services.TryAdd(setup); } + } + else + { + if (factory) { services.Add(_ => new Foo(), setup); } + else { services.Add(setup); } + } + }); + Assert.Equal(typeof(Foo), Assert.Single(services).ServiceType); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Forwarding_ShouldChooseNonGenericInterface_WhenServiceHasNoMarker(bool tryAdd) + { + var services = new ServiceCollection(); + Action setup = o => + { + o.Lifetime = ServiceLifetime.Singleton; + o.NestedTypeSelector = _ => new[] { typeof(IService), typeof(IService) }; + }; + if (tryAdd) { services.TryAdd>(setup); } + else { services.Add>(setup); } + using (var provider = services.BuildServiceProvider()) + { + Assert.Same(provider.GetRequiredService(), provider.GetRequiredService()); + Assert.Null(provider.GetService>()); + } + } + + [Fact] + public void PostConfigureAllOf_ShouldConfigureOnlyOptionsImplementingInterface() + { + var services = new ServiceCollection(); + services.Configure(o => o.Greeting = "Configured"); + services.Configure(o => o.Greeting = "Unrelated"); + services.PostConfigureAllOf(o => o.Greeting += " then post-configured"); + using (var provider = services.BuildServiceProvider()) + { + Assert.Equal("Configured then post-configured", provider.GetRequiredService>().Value.Greeting); + Assert.Equal("Unrelated", provider.GetRequiredService>().Value.Greeting); + } + } + + [Fact] + public void Registration_ShouldRejectNullServiceAndImplementationTypes() + { + var services = new ServiceCollection(); + Assert.Equal("service", Assert.Throws(() => services.Add(null, typeof(DefaultService))).ParamName); + Assert.Equal("implementation", Assert.Throws(() => services.Add(typeof(IService), (Type)null)).ParamName); + Assert.Equal("service", Assert.Throws(() => services.TryAdd(null, typeof(DefaultService))).ParamName); + Assert.Equal("implementation", Assert.Throws(() => services.TryAdd(typeof(IService), (Type)null)).ParamName); + Assert.Empty(services); + } + + [Fact] + public void TryAdd_ShouldRejectNullFactory_BeforeAddingService() + { + var services = new ServiceCollection(); + Func[] registrations = + { + () => services.Add((Func)null), + () => services.TryAdd((Func)null), + () => services.TryAdd((Func)null, ServiceLifetime.Singleton), + () => services.TryAdd((Func)null, ServiceLifetime.Singleton, _ => { }), + () => services.TryAdd(typeof(IService), (Func)null, ServiceLifetime.Singleton), + () => services.TryAdd(typeof(IService), (Func)null, ServiceLifetime.Singleton, _ => { }) + }; + foreach (var register in registrations) + { + Assert.Equal("implementationFactory", Assert.Throws(() => register()).ParamName); + Assert.Empty(services); + } + } + + [Fact] + public void PostConfigureAllOf_ShouldPreserveNamedOptionsAndRunAfterConfigure() + { + var services = new ServiceCollection(); + services.Configure("named", o => o.Greeting = "Configured"); + services.Configure(o => o.Greeting = "Default"); + Assert.Same(services, services.PostConfigureAllOf(o => o.Greeting += " then post-configured")); + using (var provider = services.BuildServiceProvider()) + { + var options = provider.GetRequiredService>(); + Assert.Equal("Configured then post-configured", options.Get("named").Greeting); + Assert.Equal("", options.CurrentValue.Greeting); + Assert.Equal("Default then post-configured", provider.GetRequiredService>().Value.Greeting); + } + } + + [Fact] + public void PostConfigureAllOf_ShouldSkipUnsupportedConfigureDescriptors() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddSingleton, NonGenericConfigureOptions>(); + services.AddSingleton>(_ => new NonGenericConfigureOptions()); + services.AddSingleton>(new NonGenericConfigureOptions()); + services.AddSingleton>(new GenericConfigureOptions()); + Assert.Same(services, services.PostConfigureAllOf(o => o.Greeting = "Post")); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(IPostConfigureOptions)); + using (var provider = services.BuildServiceProvider()) + { + Assert.Equal("Configured", provider.GetRequiredService>().Value.Greeting); + } + } + [Fact] public void AddWithSetup_ShouldAddServiceToServiceCollectionWithSpecifiedLifetime() { @@ -674,4 +1098,23 @@ public void TryAdd_WithFactory_ShouldOnlyRegisterOptionsOnce_WhenCalledMultipleT Assert.Equal(1, configureOptionsCount); } + + private class NonGenericConfigureOptions : IConfigureOptions + { + public void Configure(FakeOptions options) { options.Greeting = "Configured"; } + } + + public interface IOptionGreeting + { + string Greeting { get; set; } + } + + public class InterfaceOptions : FakeOptions, IOptionGreeting + { + } + + private class GenericConfigureOptions : IConfigureOptions where TOptions : FakeOptions + { + public void Configure(TOptions options) { options.Greeting = "Configured"; } + } } From 271b734e922421d86ddf4a9863f736bd0cc402b1 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 2 Oct 2026 22:44:09 +0200 Subject: [PATCH 07/29] =?UTF-8?q?=E2=9C=A8=20add=20configured=20options=20?= =?UTF-8?q?integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect Parameter Object post-configuration and validation conventions to Microsoft Options while exposing the primary configurator and cached default options instance. --- .../ParameterObjectOptions.cs | 35 ++ .../ServiceCollectionExtensions.cs | 39 +- .../ServiceCollectionExtensionsTest.cs | 492 ++++++++++++++++++ 3 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs diff --git a/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs b/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs new file mode 100644 index 00000000..130b4b84 --- /dev/null +++ b/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs @@ -0,0 +1,35 @@ +using System; +using Cuemon.Configuration; +using Microsoft.Extensions.Options; + +namespace Cuemon.Extensions.DependencyInjection; + +internal sealed class ParameterObjectOptions : IPostConfigureOptions, IValidateOptions + where TOptions : class, IParameterObject, new() +{ + public void PostConfigure(string name, TOptions options) + { + if (options is IPostConfigurableParameterObject postConfigurable) + { + postConfigurable.PostConfigureOptions(); + } + } + + public ValidateOptionsResult Validate(string name, TOptions options) + { + if (options is not IValidatableParameterObject validatable) + { + return ValidateOptionsResult.Skip; + } + + try + { + validatable.ValidateOptions(); + return ValidateOptionsResult.Success; + } + catch (Exception e) when (Patterns.IsRecoverableException(e)) + { + return ValidateOptionsResult.Fail(e.Message); + } + } +} diff --git a/src/Cuemon.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/Cuemon.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index 1cf042c6..ebd84f24 100644 --- a/src/Cuemon.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Cuemon.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Linq; using System.Reflection; +using Cuemon.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -494,6 +495,42 @@ public static IServiceCollection TryAdd(this IServiceCollection servic return services; } + /// + /// Registers the specified with the Microsoft Options pattern and enables direct consumption of and . + /// + /// The options type to be configured. + /// The to extend. + /// The delegate that configures the public read-write properties of . + /// A reference to so that additional configuration calls can be chained. + /// + /// cannot be null. + /// - or - + /// cannot be null. + /// + /// + /// Microsoft Options constructs and configures the options when they are materialized. The and conventions participate in its post-configuration and validation stages for all options names. Recoverable validation exceptions become failures; post-configuration exceptions and fatal validation exceptions propagate without translation. + /// Post-configurators and validators execute in registration order within their respective stages. Cuemon conventions are not forced to run last. + /// Direct consumption is registered as a singleton resolving the default . The normal lifecycles of and are preserved. + /// The first call for an options type registers the exact instance as and as the primary default options configurator. Subsequent calls for that type are ignored. Additional Microsoft Configure registrations still compose in registration order, but are not included in the injectable delegate. Invoking the delegate directly only applies its configuration; it does not perform post-configuration or validation. + /// + public static IServiceCollection AddConfiguredOptions(this IServiceCollection services, Action setup) + where TOptions : class, IParameterObject, new() + { + Validator.ThrowIfNull(services); + Validator.ThrowIfNull(setup); + if (services.Any(descriptor => descriptor.ServiceType == typeof(IPostConfigureOptions) && descriptor.ImplementationType == typeof(ParameterObjectOptions))) + { + return services; + } + + services.Configure(setup); // support for IOptions + services.TryAddEnumerable(ServiceDescriptor.Singleton, ParameterObjectOptions>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, ParameterObjectOptions>()); + services.AddSingleton(setup); // support for Action + services.AddSingleton(provider => provider.GetRequiredService>().Value); // support for TOptions + return services; + } + /// /// Registers an action used to post-configure all instances of a specific type in the collection. /// These are run after . diff --git a/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs b/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs index 8ebe91eb..485c4a20 100644 --- a/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs @@ -1,5 +1,9 @@ using System; using System.Linq; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using Cuemon.Configuration; #if NET9_0_OR_GREATER using Cuemon.AspNetCore.Diagnostics; using Cuemon.AspNetCore.Mvc.Filters.Diagnostics; @@ -20,6 +24,494 @@ public ServiceCollectionExtensionsTest(ITestOutputHelper output) : base(output) { } + [Fact] + public void AddConfiguredOptions_ShouldThrowArgumentNullException_WhenServicesIsNull() + { + IServiceCollection services = null; + + var exception = Assert.Throws(() => services.AddConfiguredOptions(_ => { })); + + Assert.Equal("services", exception.ParamName); + } + + [Fact] + public void AddConfiguredOptions_ShouldThrowArgumentNullException_WhenSetupIsNull() + { + var services = new ServiceCollection(); + + var exception = Assert.Throws(() => services.AddConfiguredOptions(null)); + + Assert.Equal("setup", exception.ParamName); + Assert.Empty(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AddConfiguredOptions_ShouldShareDefaultOptions_WithoutEagerOrRepeatedConfiguration(bool resolveDirectFirst) + { + var services = new ServiceCollection(); + var invocationCount = 0; + ParameterOptions configured = null; + Action setup = options => + { + invocationCount++; + configured = options; + options.Greeting = "Configured"; + }; + + Assert.Same(services, services.AddConfiguredOptions(setup)); + Assert.Equal(0, invocationCount); + Assert.Equal(ServiceLifetime.Singleton, Assert.Single(services, descriptor => descriptor.ServiceType == typeof(ParameterOptions)).Lifetime); + + using (var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true })) + { + var optionsService = provider.GetRequiredService>(); + Assert.Equal(0, invocationCount); + var first = resolveDirectFirst ? provider.GetRequiredService() : optionsService.Value; + var second = resolveDirectFirst ? optionsService.Value : provider.GetRequiredService(); + + Assert.Same(first, second); + Assert.Same(configured, first); + Assert.Equal("Configured", first.Greeting); + Assert.Equal(1, invocationCount); + using (var scope = provider.CreateScope()) + { + Assert.Same(first, scope.ServiceProvider.GetRequiredService()); + } + Assert.Equal(1, invocationCount); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldExposeExactSetup_WithoutRunningConventionsWhenInvokedDirectly() + { + Action setup = options => options.Greeting = "Configured"; + var services = new ServiceCollection().AddConfiguredOptions(setup); + + using (var provider = services.BuildServiceProvider()) + { + var resolved = provider.GetRequiredService>(); + var fresh = new LifecycleOptions(); + resolved(fresh); + + Assert.Same(setup, resolved); + Assert.Same(setup, Assert.Single(provider.GetServices>())); + Assert.Equal("Configured", fresh.Greeting); + Assert.Equal(0, fresh.PostConfigureCount); + Assert.Equal(0, fresh.ValidateCount); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldPostConfigure_WithoutRequiringValidation() + { + var services = new ServiceCollection().AddConfiguredOptions(options => options.Greeting = "Configured"); + + using (var provider = services.BuildServiceProvider()) + { + var options = provider.GetRequiredService>().Value; + + Assert.Equal("Configured post-configured", options.Greeting); + Assert.Equal(1, options.PostConfigureCount); + Assert.Same(options, provider.GetRequiredService()); + Assert.Same(options, options.PostConfiguredInstance); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldValidate_WithoutRequiringPostConfiguration() + { + var services = new ServiceCollection().AddConfiguredOptions(options => options.Greeting = "Configured"); + + using (var provider = services.BuildServiceProvider()) + { + var options = provider.GetRequiredService(); + + Assert.Equal("Configured", options.Greeting); + Assert.Equal(1, options.ValidateCount); + Assert.Same(options, options.ValidatedInstance); + Assert.Same(options, provider.GetRequiredService>().Value); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldRunSetupBeforePostConfigurationBeforeValidation_OnTheExposedInstance() + { + LifecycleOptions configured = null; + var services = new ServiceCollection().AddConfiguredOptions(options => + { + configured = options; + options.Greeting = "Configured"; + options.Stages.Add("setup"); + }); + + using (var provider = services.BuildServiceProvider()) + { + var options = provider.GetRequiredService>().Value; + + Assert.Equal(new[] { "setup", "post-configure", "validate" }, options.Stages); + Assert.Equal("Configured post-configured", options.Greeting); + Assert.Equal(options.Greeting, options.ValidatedGreeting); + Assert.Equal(1, options.PostConfigureCount); + Assert.Equal(1, options.ValidateCount); + Assert.Same(configured, options); + Assert.Same(options, options.PostConfiguredInstance); + Assert.Same(options, options.ValidatedInstance); + Assert.Same(options, provider.GetRequiredService()); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AddConfiguredOptions_ShouldTranslateRecoverableValidationFailure_WhenMaterialized(bool resolveDirect) + { + var failure = new InvalidOperationException("Invalid greeting."); + var invocationCount = 0; + LifecycleOptions configured = null; + var services = new ServiceCollection().AddConfiguredOptions(options => + { + invocationCount++; + configured = options; + options.ValidationFailure = failure; + }); + + using (var provider = services.BuildServiceProvider()) + { + Assert.Equal(0, invocationCount); + var exception = Assert.Throws(() => + { + if (resolveDirect) { provider.GetRequiredService(); } + else { _ = provider.GetRequiredService>().Value; } + }); + + Assert.Equal(Options.DefaultName, exception.OptionsName); + Assert.Equal(typeof(LifecycleOptions), exception.OptionsType); + Assert.Equal(failure.Message, Assert.Single(exception.Failures)); + Assert.Equal(1, invocationCount); + Assert.Equal(1, configured.PostConfigureCount); + Assert.Equal(1, configured.ValidateCount); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void AddConfiguredOptions_ShouldPropagateFatalValidationExceptions(int exceptionKind) + { + Exception[] failures = + { + new OutOfMemoryException("Synthetic failure."), + new StackOverflowException("Synthetic failure."), + new AccessViolationException("Synthetic failure."), + new SEHException("Synthetic failure."), + new ThreadInterruptedException("Synthetic failure.") + }; + var failure = failures[exceptionKind]; + var services = new ServiceCollection().AddConfiguredOptions(options => options.ValidationFailure = failure); + + using (var provider = services.BuildServiceProvider()) + { + var exception = Record.Exception(() => _ = provider.GetRequiredService>().Value); + + Assert.Same(failure, exception); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldPropagatePostConfigurationFailure_WithoutValidation() + { + var failure = new InvalidOperationException("Post-configuration failed."); + LifecycleOptions configured = null; + var services = new ServiceCollection().AddConfiguredOptions(options => + { + configured = options; + options.PostConfigurationFailure = failure; + }); + + using (var provider = services.BuildServiceProvider()) + { + var exception = Assert.Throws(() => _ = provider.GetRequiredService>().Value); + + Assert.Same(failure, exception); + Assert.Equal(1, configured.PostConfigureCount); + Assert.Equal(0, configured.ValidateCount); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldPropagateSetupFailure_WithoutRunningConventions() + { + var failure = new InvalidOperationException("Configuration failed."); + LifecycleOptions configured = null; + var services = new ServiceCollection().AddConfiguredOptions(options => + { + configured = options; + throw failure; + }); + + using (var provider = services.BuildServiceProvider()) + { + var exception = Assert.Throws(() => _ = provider.GetRequiredService>().Value); + + Assert.Same(failure, exception); + Assert.Equal(0, configured.PostConfigureCount); + Assert.Equal(0, configured.ValidateCount); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldKeepFirstSetup_AndRegisterConventionsAndDirectServicesOnce() + { + var services = new ServiceCollection(); + var firstCount = 0; + var secondCount = 0; + Action first = options => { firstCount++; options.Greeting = "First"; }; + Action second = options => { secondCount++; options.Greeting = "Second"; }; + + services.AddConfiguredOptions(first); + Assert.Same(services, services.AddConfiguredOptions(second)); + services.AddConfiguredOptions(first); + + using (var provider = services.BuildServiceProvider()) + { + var options = provider.GetRequiredService(); + + Assert.Equal("First post-configured", options.Greeting); + Assert.Same(first, Assert.Single(provider.GetServices>())); + Assert.Same(options, Assert.Single(provider.GetServices())); + Assert.Single(provider.GetServices>()); + Assert.Single(provider.GetServices>()); + Assert.Equal(1, firstCount); + Assert.Equal(0, secondCount); + Assert.Equal(1, options.PostConfigureCount); + Assert.Equal(1, options.ValidateCount); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldRejectNullSetup_EvenAfterPrimaryRegistration() + { + var services = new ServiceCollection().AddConfiguredOptions(_ => { }); + + Assert.Throws(() => services.AddConfiguredOptions(null)); + } + + [Fact] + public void AddConfiguredOptions_ShouldComposeOrdinaryConfigureRegistrations_WhileExposingOnlyPrimarySetup() + { + Action setup = options => { options.Greeting += " primary"; options.Stages.Add("primary setup"); }; + var services = new ServiceCollection() + .Configure(options => { options.Greeting = "Before"; options.Stages.Add("before setup"); }) + .AddConfiguredOptions(setup) + .Configure(options => { options.Greeting += " after"; options.Stages.Add("after setup"); }) + .AddConfiguredOptions(options => options.Greeting = "Ignored"); + + using (var provider = services.BuildServiceProvider()) + { + var options = provider.GetRequiredService(); + var fresh = new LifecycleOptions(); + provider.GetRequiredService>()(fresh); + + Assert.Equal("Before primary after post-configured", options.Greeting); + Assert.Equal(new[] { "before setup", "primary setup", "after setup", "post-configure", "validate" }, options.Stages); + Assert.Same(setup, provider.GetRequiredService>()); + Assert.Equal(" primary", fresh.Greeting); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldCoexistWithUserPostConfiguratorsAndValidators_InRegistrationOrder() + { + var services = new ServiceCollection(); + services.AddSingleton>(new PostConfigureOptions(Options.DefaultName, options => options.Stages.Add("user post-configure before"))); + services.AddSingleton>(new ValidateOptions(Options.DefaultName, options => { options.Stages.Add("user validate before"); return true; }, "User validation failed.")); + services.AddConfiguredOptions(options => options.Stages.Add("setup")); + services.PostConfigure(options => { options.Stages.Add("user post-configure after"); options.Greeting = "Final"; }); + services.AddSingleton>(new ValidateOptions(Options.DefaultName, options => { options.Stages.Add("user validate after"); return true; }, "User validation failed.")); + + using (var provider = services.BuildServiceProvider()) + { + var options = provider.GetRequiredService(); + + Assert.Equal(new[] { "setup", "user post-configure before", "post-configure", "user post-configure after", "user validate before", "validate", "user validate after" }, options.Stages); + Assert.Equal("Final", options.ValidatedGreeting); + Assert.Equal(1, options.PostConfigureCount); + Assert.Equal(1, options.ValidateCount); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldAggregateUserAndCuemonValidationFailures() + { + var services = new ServiceCollection(); + services.AddSingleton>(new ValidateOptions(Options.DefaultName, _ => false, "User validation failed.")); + services.AddConfiguredOptions(options => options.ValidationFailure = new ArgumentException("Cuemon validation failed.")); + + using (var provider = services.BuildServiceProvider()) + { + var exception = Assert.Throws(() => _ = provider.GetRequiredService>().Value); + + Assert.Equal(new[] { "User validation failed.", "Cuemon validation failed." }, exception.Failures); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldCoexistWithPostConfigureAllOfAndTryConfigure() + { + var services = new ServiceCollection() + .AddConfiguredOptions(options => options.Greeting = "Primary") + .TryConfigure(options => options.Greeting = "Ignored") + .PostConfigureAllOf(options => options.Greeting = "Bulk post-configured"); + + using (var provider = services.BuildServiceProvider()) + { + var options = provider.GetRequiredService(); + + Assert.Equal("Bulk post-configured", options.Greeting); + Assert.Equal("Bulk post-configured", options.ValidatedGreeting); + Assert.Equal(1, options.PostConfigureCount); + Assert.Equal(1, options.ValidateCount); + } + } + + [Fact] + public void AddConfiguredOptions_ShouldPreserveSnapshotAndMonitorLifecycles_AndApplyConventionsToNamedOptions() + { + var invocationCount = 0; + var services = new ServiceCollection() + .AddConfiguredOptions(options => { invocationCount++; options.Greeting = "Default"; }) + .Configure("named", options => options.Greeting = "Named"); + + using (var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true })) + using (var firstScope = provider.CreateScope()) + using (var secondScope = provider.CreateScope()) + { + var direct = provider.GetRequiredService(); + var firstSnapshot = firstScope.ServiceProvider.GetRequiredService>(); + var secondSnapshot = secondScope.ServiceProvider.GetRequiredService>(); + var monitor = provider.GetRequiredService>(); + var monitored = monitor.CurrentValue; + + Assert.Same(direct, provider.GetRequiredService>().Value); + Assert.Same(firstSnapshot, firstScope.ServiceProvider.GetRequiredService>()); + Assert.Same(firstSnapshot.Value, firstSnapshot.Value); + Assert.NotSame(firstSnapshot.Value, secondSnapshot.Value); + Assert.NotSame(direct, firstSnapshot.Value); + Assert.NotSame(direct, monitored); + Assert.Same(monitored, monitor.CurrentValue); + Assert.Equal(4, invocationCount); + Assert.True(provider.GetRequiredService>().TryRemove(Options.DefaultName)); + var refreshed = monitor.CurrentValue; + Assert.NotSame(monitored, refreshed); + Assert.Same(direct, provider.GetRequiredService()); + Assert.Equal(5, invocationCount); + + var named = monitor.Get("named"); + Assert.Same(named, monitor.Get("named")); + Assert.NotSame(named, firstSnapshot.Get("named")); + Assert.Equal("Named post-configured", named.Greeting); + Assert.Equal(named.Greeting, named.ValidatedGreeting); + Assert.Equal(1, named.PostConfigureCount); + Assert.Equal(1, named.ValidateCount); + Assert.Equal(5, invocationCount); + + foreach (var options in new[] { direct, firstSnapshot.Value, secondSnapshot.Value, monitored, refreshed }) + { + Assert.Equal("Default post-configured", options.Greeting); + Assert.Equal(1, options.PostConfigureCount); + Assert.Equal(1, options.ValidateCount); + } + } + } + + [Fact] + public void AddConfiguredOptions_ShouldRegisterIndependentlyForEachOptionsType() + { + var services = new ServiceCollection() + .AddConfiguredOptions(options => options.Greeting = "Plain") + .AddConfiguredOptions(options => options.Greeting = "Lifecycle"); + + using (var provider = services.BuildServiceProvider()) + { + Assert.Equal("Plain", provider.GetRequiredService().Greeting); + Assert.Equal("Lifecycle post-configured", provider.GetRequiredService().Greeting); + } + } + + public class ParameterOptions : IParameterObject + { + public string Greeting { get; set; } + } + + public class PostConfiguredOptions : ParameterOptions, IPostConfigurableParameterObject + { + public int PostConfigureCount { get; private set; } + + public PostConfiguredOptions PostConfiguredInstance { get; private set; } + + public void PostConfigureOptions() + { + PostConfigureCount++; + PostConfiguredInstance = this; + Greeting += " post-configured"; + } + } + + public class ValidatedOptions : ParameterOptions, IValidatableParameterObject + { + public int ValidateCount { get; private set; } + + public ValidatedOptions ValidatedInstance { get; private set; } + + public void ValidateOptions() + { + ValidateCount++; + ValidatedInstance = this; + } + } + + public class LifecycleOptions : ParameterOptions, IPostConfigurableParameterObject, IValidatableParameterObject + { + public List Stages { get; } = new List(); + + public int PostConfigureCount { get; private set; } + + public int ValidateCount { get; private set; } + + public LifecycleOptions PostConfiguredInstance { get; private set; } + + public LifecycleOptions ValidatedInstance { get; private set; } + + public string ValidatedGreeting { get; private set; } + + public Exception PostConfigurationFailure { get; set; } + + public Exception ValidationFailure { get; set; } + + public void PostConfigureOptions() + { + PostConfigureCount++; + PostConfiguredInstance = this; + Stages.Add("post-configure"); + if (PostConfigurationFailure != null) { throw PostConfigurationFailure; } + Greeting += " post-configured"; + } + + public void ValidateOptions() + { + ValidateCount++; + ValidatedInstance = this; + Stages.Add("validate"); + if (ValidationFailure != null) { throw ValidationFailure; } + ValidatedGreeting = Greeting; + } + } + [Theory] [InlineData(ServiceLifetime.Singleton)] [InlineData(ServiceLifetime.Scoped)] From 130b6b4928e8706d4cd966ca11952c448eb483ba Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 2 Oct 2026 22:44:16 +0200 Subject: [PATCH 08/29] =?UTF-8?q?=F0=9F=93=9D=20document=20configured=20op?= =?UTF-8?q?tions=20api=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain how Cuemon parameter object conventions participate in Microsoft Options lifecycles and provide a consumer-ready example for the new registration method. --- .../Cuemon.Extensions.DependencyInjection.md | 10 ++- ...cyInjection.ServiceCollectionExtensions.md | 62 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/.docfx/api/namespaces/Cuemon.Extensions.DependencyInjection.md b/.docfx/api/namespaces/Cuemon.Extensions.DependencyInjection.md index a40489b0..a09f3f7d 100644 --- a/.docfx/api/namespaces/Cuemon.Extensions.DependencyInjection.md +++ b/.docfx/api/namespaces/Cuemon.Extensions.DependencyInjection.md @@ -2,7 +2,13 @@ uid: Cuemon.Extensions.DependencyInjection summary: *content --- -Register services in the Microsoft DI container with or without options, specifying service and implementation types through a rich set of generic extension methods. Use this namespace when you need flexible DI registration with typed options. Start with `Add` for basic registration, or `Add` when your service requires configuration options. +Register service contracts and typed configuration in Microsoft's dependency injection container. Use `Add` to select a service implementation and lifetime, or `Add` to also register its configuration using the existing first-configuration convention. Choose `AddConfiguredOptions` when consumers need Cuemon Parameter Object conventions within the Microsoft Options lifecycle, together with direct options and configurator injection. + +Call `AddConfiguredOptions` when an options type implements Cuemon's `IParameterObject` conventions and needs to participate in the [Microsoft Options pattern](https://learn.microsoft.com/en-us/dotnet/core/extensions/options). Microsoft constructs the options and runs configuration, post-configuration, and validation. Cuemon's `IPostConfigurableParameterObject.PostConfigureOptions()` and `IValidatableParameterObject.ValidateOptions()` participate in the corresponding stages for every options name. Recoverable validation exceptions become `OptionsValidationException` failures when options are materialized; post-configuration exceptions and fatal validation exceptions propagate without translation. + +Direct `TOptions` consumption resolves the same cached default instance as `IOptions.Value`, with singleton semantics. Use `IOptionsSnapshot` for scoped snapshots and `IOptionsMonitor` for named options, invalidation, and change notifications; their Microsoft lifecycles remain independent of direct consumption. Post-configurators and validators execute in registration order within their respective stages, so Cuemon's conventions are not guaranteed to run last. + +The first `AddConfiguredOptions` call registers the primary default configurator and exposes that exact delegate as `Action`. Later calls for the same type are ignored. Ordinary `Configure` registrations before or after it still compose through Microsoft Options, but do not become part of the injectable delegate. Invoking that delegate against a fresh object applies only the primary configuration, without post-configuration or validation. `TryConfigure` retains its existing first-configuration semantics, and `PostConfigureAllOf` can still add bulk post-configuration for compatible options registrations. [!INCLUDE [availability-default](../../includes/availability-default.md)] @@ -12,6 +18,6 @@ Complements: [Microsoft.Extensions.DependencyInjection namespace](https://docs.m |Type|Ext|Methods| |--:|:-:|---| -|IServiceCollection|⬇️|`Add`, `Add`, `Add`, `Add`, `Add`, `TryAdd`, `TryAdd`, `TryAdd`, `TryAdd`, `TryAdd`, `TryConfigure`, `PostConfigureAllOf`| +|IServiceCollection|⬇️|`Add`, `Add`, `Add`, `Add`, `Add`, `TryAdd`, `TryAdd`, `TryAdd`, `TryAdd`, `TryAdd`, `TryConfigure`, `AddConfiguredOptions`, `PostConfigureAllOf`| |IServiceProvider|⬇️|`GetServiceDescriptors`| |type|⬇️|`TryGetDependencyInjectionMarker`| diff --git a/.docfx/api/types/Cuemon.Extensions.DependencyInjection.ServiceCollectionExtensions.md b/.docfx/api/types/Cuemon.Extensions.DependencyInjection.ServiceCollectionExtensions.md index ac6e7284..7e803a7d 100644 --- a/.docfx/api/types/Cuemon.Extensions.DependencyInjection.ServiceCollectionExtensions.md +++ b/.docfx/api/types/Cuemon.Extensions.DependencyInjection.ServiceCollectionExtensions.md @@ -94,3 +94,65 @@ namespace Cuemon.Docs.Samples.DependencyInjection } } ``` + +Configure delivery retries with `AddConfiguredOptions` to let Microsoft Options construct the Parameter Object, calculate its retry budget during post-configuration, and validate the final settings. This example adds an ordinary Microsoft configurator that raises the attempt limit to four, then resolves the cached default options directly and through `IOptions`. The output shows a 15-second retry budget and a shared default instance. The injectable `Action` is the exact primary delegate and sets three attempts on a fresh object; invoking it directly does not calculate the budget or validate the object. Further `AddConfiguredOptions` calls would be ignored, while ordinary `Configure` calls still compose. Cuemon's conventions participate for all options names in registration order, and recoverable validation failures surface as `OptionsValidationException` when Microsoft materializes options. + +```csharp +using System; +using Cuemon.Configuration; +using Cuemon.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Delivery.Configuration; + +public static class DeliveryApplication +{ + public static void Main() + { + Action setup = options => + { + options.MaxAttempts = 3; + options.RetryDelay = TimeSpan.FromSeconds(5); + }; + var services = new ServiceCollection(); + services.AddConfiguredOptions(setup); + services.Configure(options => options.MaxAttempts = 4); + + using (var provider = services.BuildServiceProvider()) + { + var delivery = provider.GetRequiredService(); + var microsoftOptions = provider.GetRequiredService>().Value; + Console.WriteLine($"Attempts: {delivery.MaxAttempts}; retry budget: {delivery.RetryBudget.TotalSeconds} seconds"); + Console.WriteLine($"Shared default instance: {ReferenceEquals(delivery, microsoftOptions)}"); + + var configure = provider.GetRequiredService>(); + var fresh = new DeliveryOptions(); + configure(fresh); + Console.WriteLine($"Primary configurator attempts: {fresh.MaxAttempts}"); + } + } +} + +public sealed class DeliveryOptions : IPostConfigurableParameterObject, IValidatableParameterObject +{ + public int MaxAttempts { get; set; } + + public TimeSpan RetryDelay { get; set; } + + public TimeSpan RetryBudget { get; private set; } + + public void PostConfigureOptions() + { + RetryBudget = TimeSpan.FromTicks(RetryDelay.Ticks * (MaxAttempts - 1)); + } + + public void ValidateOptions() + { + if (MaxAttempts < 1 || RetryDelay < TimeSpan.Zero) + { + throw new InvalidOperationException("Delivery requires at least one attempt and a nonnegative retry delay."); + } + } +} +``` From 0caea60dfa471efef837939e2d4225639aeab064 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 2 Oct 2026 22:44:22 +0200 Subject: [PATCH 09/29] =?UTF-8?q?=F0=9F=93=A6=20add=20configured=20options?= =?UTF-8?q?=20package=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the new options registration API and its Parameter Object integration in the package release metadata. --- .../PackageReleaseNotes.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt index 4c786c32..f87c1b13 100644 --- a/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# New Features +- ADDED ServiceCollectionExtensions.AddConfiguredOptions() method to register a primary Parameter Object configurator with Microsoft Options, bridge Cuemon post-configuration and validation conventions, and expose the default IOptions.Value as a singleton TOptions alongside the original Action; repeated calls retain the first configurator while ordinary Configure registrations still compose + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 From 3fb3bd1d71b45436b8b8ea2e27ec56fbab955b39 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Fri, 2 Oct 2026 22:49:14 +0200 Subject: [PATCH 10/29] =?UTF-8?q?=F0=9F=92=AC=20add=2010.8.0=20changelog?= =?UTF-8?q?=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the surviving API and CI workflow changes for release review. --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 145f318c..3d1b2ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), For more details, please refer to `PackageReleaseNotes.txt` on a per assembly basis in the `.nuget` folder. +## [10.8.0] - 2026-10-02 + +This is a minor release adding Parameter Object integration with Microsoft Options and separating pull request validation, package release, and DocFX deployment workflows. + +### Added + +- `IServiceCollection.AddConfiguredOptions()` integrates Cuemon post-configuration and validation conventions with Microsoft Options and exposes the configured default options instance and primary `Action`, +- Dedicated pull request, release, and deployment workflows assign CI validation, NuGet publication and release assurance, and DocFX image promotion to separate workflows. + +### Changed + +- CI references now use the pull request workflow and `main` Codecov branch, run on Ubuntu 26.04, and document the updated workflow responsibilities. + +### Removed + +- The combined `.github/workflows/ci-pipeline.yml` workflow was removed as CI responsibilities moved to the dedicated pull request, release, and deployment workflows. + ## [10.7.1] - 2026-09-09 This is a patch release focused on modernizing the test infrastructure and consolidating dependencies. The release migrates code coverage collection from coverlet to Microsoft.Testing.Extensions.CodeCoverage, upgrades testing frameworks to their latest major versions, and simplifies test environment configuration while maintaining full compatibility with existing functionality. @@ -1897,6 +1914,7 @@ This release was primarily focused on adapting a more modern way of performing C - XmlWriterUtility class from Cuemon.Xml namespace - XmlWriterUtilityExtensions class from the Cuemon.Xml namespace +[10.8.0]: https://github.com/codebeltnet/cuemon/compare/v10.7.1...v10.8.0 [10.7.1]: https://github.com/codebeltnet/cuemon/compare/v10.7.0...v10.7.1 [10.7.0]: https://github.com/codebeltnet/cuemon/compare/v10.6.0...v10.7.0 [10.6.0]: https://github.com/codebeltnet/cuemon/compare/v10.5.5...v10.6.0 From 38db817f768f036bb800be775eba1a619c01ba1f Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 2 Oct 2026 23:40:11 +0200 Subject: [PATCH 11/29] =?UTF-8?q?=F0=9F=93=9D=20update=20CI=20runner=20gui?= =?UTF-8?q?dance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the documented Linux CI runner baseline with the workflows. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7989ed5d..eb904b59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Follow the repo rules first; do not invent commands or conventions. - Test TFMs: `net10.0;net9.0` on Linux; adds `net48` on Windows. - Benchmark TFMs: `net10.0;net9.0`. - Central package management via `Directory.Packages.props` (`ManagePackageVersionsCentrally=true`). -- CI runs on Linux (ubuntu-24.04) and Windows (windows-2025), both X64 and ARM64. +- CI runs on Linux (ubuntu-26.04) and Windows (windows-2025), both X64 and ARM64. - TFM compatibility is mandatory: proposals and code changes must work for all source TFMs. Do not assume `net9.0`/`net10.0` APIs exist in `netstandard2.0`; use conditional compilation (`#if NET9_0_OR_GREATER`) or compatible fallbacks where needed. ## Build Commands From de455a7e66f5dfd4b5e3ea6ebf096d94f6754201 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Fri, 2 Oct 2026 23:40:18 +0200 Subject: [PATCH 12/29] =?UTF-8?q?=F0=9F=91=B7=20update=20GitHub=20workflow?= =?UTF-8?q?=20runners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move pull request and downstream release workflows to the current Ubuntu runner image. --- .github/workflows/pr.yml | 458 +++++++++++++++++++++++ .github/workflows/trigger-downstream.yml | 2 +- 2 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 00000000..adfed0db --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,458 @@ +name: PR Flow +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + run_mac_tests: + type: boolean + description: Run the macOS test matrix despite the additional cost and runtime. + default: false + +permissions: + contents: read + +jobs: + init: + name: initialize + runs-on: ubuntu-26.04 + outputs: + run-privileged-jobs: ${{ steps.vars.outputs.run-privileged-jobs }} + run-mac-tests: ${{ steps.vars.outputs.run-mac-tests }} + strong-name-key-filename: ${{ steps.vars.outputs.strong-name-key-filename }} + build-switches: ${{ steps.vars.outputs.build-switches }} + steps: + - id: vars + name: calculate workflow variables + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + WORKFLOW_REF: ${{ github.ref }} + RUN_MAC_TESTS: ${{ inputs.run_mac_tests }} + run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$RUN_MAC_TESTS" == "true" ]]; then + echo "run-mac-tests=true" >> "$GITHUB_OUTPUT" + else + echo "run-mac-tests=false" >> "$GITHUB_OUTPUT" + fi + + if [[ "$EVENT_NAME" == "pull_request" && "$HEAD_REPOSITORY" != "$REPOSITORY" ]]; then + echo "run-privileged-jobs=false" >> "$GITHUB_OUTPUT" + echo "strong-name-key-filename=" >> "$GITHUB_OUTPUT" + echo "build-switches=-p:SkipSignAssembly=true" >> "$GITHUB_OUTPUT" + else + echo "run-privileged-jobs=true" >> "$GITHUB_OUTPUT" + echo "strong-name-key-filename=cuemon.snk" >> "$GITHUB_OUTPUT" + echo "build-switches=" >> "$GITHUB_OUTPUT" + fi + + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$WORKFLOW_REF" != "refs/heads/main" ]]; then + echo "run-privileged-jobs=false" >> "$GITHUB_OUTPUT" + echo "strong-name-key-filename=" >> "$GITHUB_OUTPUT" + echo "build-switches=-p:SkipSignAssembly=true" >> "$GITHUB_OUTPUT" + fi + + prepare_test: + name: 📜 Prepare Test + runs-on: ubuntu-26.04 + timeout-minutes: 5 + outputs: + json: ${{ steps.test-projects.outputs.result }} + steps: + - name: Checkout + uses: codebeltnet/git-checkout@v1 + + - id: test-projects + name: Generate matrix for test projects + uses: codebeltnet/shell-globbing@v2 + with: + pattern: | + test/**/*.csproj + !test/**/Cuemon.Data.SqlClient.Tests.csproj + + - name: JSON output + run: echo "${{ steps.test-projects.outputs.result }}" + + build: + name: call-build + needs: [init] + strategy: + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-build/.github/workflows/default.yml@v3 + with: + configuration: ${{ matrix.configuration }} + strong-name-key-filename: ${{ needs.init.outputs.strong-name-key-filename }} + build-switches: ${{ needs.init.outputs.build-switches }} + runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-26.04-arm' || 'ubuntu-26.04' }} + upload-build-artifact-name: build-${{ matrix.configuration }}-${{ matrix.arch }} + secrets: + GCP_TOKEN: ${{ secrets.GCP_TOKEN }} + GCP_BUCKETNAME: ${{ secrets.GCP_BUCKETNAME }} + + pack: + name: call-pack + needs: [build] + strategy: + matrix: + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-pack/.github/workflows/default.yml@v3 + with: + configuration: ${{ matrix.configuration }} + version: ${{ needs.build.outputs.version }} + download-build-artifact-pattern: build-${{ matrix.configuration }}-X64 + + + test_linux: + name: call-test-linux + needs: [build, prepare_test] + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] + project: ${{ fromJson(needs.prepare_test.outputs.json) }} + arch: [X64, ARM64] + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 + with: + runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-26.04-arm' || 'ubuntu-26.04' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + projects: ${{ matrix.project }} + build: true # we need to build due to xUnitv3 + restore: true # we need to restore since we disabled caching + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_windows: + name: call-test-windows + needs: [build, prepare_test] + strategy: + fail-fast: false + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + project: ${{ fromJson(needs.prepare_test.outputs.json) }} + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 + with: + runs-on: ${{ matrix.arch == 'ARM64' && 'windows-11-arm' || 'windows-2025' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + projects: ${{ matrix.project }} + build: true # we need to build for .net48 + restore: true # apparently we need to restore for .net48 + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_mac: + if: ${{ needs.init.outputs.run-mac-tests == 'true' }} + name: call-test-mac + needs: [init, build, prepare_test] + strategy: + fail-fast: false + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + project: ${{ fromJson(needs.prepare_test.outputs.json) }} + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 + with: + runs-on: ${{ matrix.arch == 'ARM64' && 'macos-26' || 'macos-26-intel' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + projects: ${{ matrix.project }} + build: true # we need to build due to xUnitv3 + restore: true # we need to restore since we disabled caching + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + integration_test: + if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} + name: ⚗️ Integration Test + needs: [init, build] + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] + project: [ test/**/Cuemon.Data.SqlClient.Tests.csproj ] + runs-on: ubuntu-26.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: codebeltnet/git-checkout@v1 + + - name: Install .NET + uses: codebeltnet/install-dotnet@v3 + + - name: Install .NET Tool - Report Generator + uses: codebeltnet/dotnet-tool-install-reportgenerator@v1 + + - name: Spin up SQL Server test dependency for ${{ matrix.configuration }} build + uses: codebeltnet/docker-compose@v1 + with: + command: up + options: --wait + env: + SA_PASSWORD: ${{ secrets.SA_PASSWORD }} + + - name: Download Build Artifacts + uses: actions/download-artifact@v8 + with: + pattern: build-${{ matrix.configuration }}-X64 + merge-multiple: true + + - name: Fix Linux test apphost permissions + run: | + set -euo pipefail + + echo "=== Context ===" + echo "Runner: $RUNNER_OS / $RUNNER_ARCH" + echo "Configuration: ${{ matrix.configuration }}" + echo "Workspace: $GITHUB_WORKSPACE" + echo "PWD: $(pwd)" + echo "Event: $GITHUB_EVENT_NAME" + echo "Ref: $GITHUB_REF" + echo "SHA: $GITHUB_SHA" + echo + + echo "=== .NET info ===" + dotnet --info || true + echo + + echo "=== Git state ===" + git rev-parse HEAD || true + git status --porcelain || true + git rev-parse --is-shallow-repository || true + echo + + # Paths we care about + BIN_GLOB="*/bin/*/net*/*" + OBJ_GLOB="*/obj/*/net*/*" + + echo "=== Brute-force chmod (bin + obj) ===" + find . -type f \( -path "$BIN_GLOB" -o -path "$OBJ_GLOB" \) -exec chmod a+x {} + 2>/dev/null || true + echo "chmod completed (errors ignored)." + echo + + echo "=== Mount options (look for noexec) ===" + # If binaries live on a noexec mount, chmod won't help. + mount | sed -n '1,200p' || true + echo + + echo "=== Candidate executables (top 200) ===" + # Show what we might execute; exclude obvious managed files + find . -type f -path "$BIN_GLOB" \ + ! -name "*.dll" ! -name "*.pdb" ! -name "*.json" ! -name "*.xml" \ + -printf "%m %u:%g %s %p\n" | head -n 200 || true + echo + + echo "=== Likely xUnit / test hosts (if present) ===" + # These names vary; do not rely on just *Tests* + find . -type f -path "$BIN_GLOB" \( \ + -name "testhost*" -o \ + -name "*xunit*" -o \ + -name "*Tests*" -o \ + -name "*.runsettings" \ + \) -printf "%m %u:%g %s %p\n" | head -n 200 || true + echo + + echo "=== Deep diagnostics for any 'testhost' or apphost candidates ===" + # For each likely executable, show the facts that explain 'permission denied' vs 'exec format error' + while IFS= read -r f; do + echo "--- $f ---" + ls -la "$f" || true + + # Identify file type and architecture + file -L "$f" || true + + # If it's an ELF binary, show its dynamic interpreter and linked libs (exec format errors often show up here) + if file -L "$f" | grep -q "ELF"; then + echo "readelf -l (interpreter):" + readelf -l "$f" 2>/dev/null | sed -n '1,80p' || true + echo "ldd (dependencies):" + ldd "$f" 2>/dev/null || true + fi + + # If it's a script, CRLF in the shebang can cause 'Exec format error' + if head -c 2 "$f" 2>/dev/null | grep -q "#!"; then + echo "shebang:" + head -n 1 "$f" | cat -A || true + fi + + echo + done < <( + find . -type f -path "$BIN_GLOB" \( \ + -name "testhost*" -o \ + -name "*xunit*" -o \ + -name "*Tests*" \ + \) | head -n 50 + ) || true + + echo "=== Done diagnostics step ===" + shell: bash + + - name: Test with ${{ matrix.configuration }} build + uses: codebeltnet/dotnet-test@v4 + with: + projects: ${{ matrix.project }} + configuration: ${{ matrix.configuration }} + build: true # apparently we need to due to xUnitv3 + restore: true # we need to restore since we disabled caching + env: + CONNECTIONSTRINGS__ADVENTUREWORKS: ${{ secrets.DB_ADVENTUREWORKS }} + + - name: Upload Integration Test Results + if: always() + uses: actions/upload-artifact@v7 + with: + name: IntegrationTestResults-${{ matrix.configuration }} + path: ${{ runner.temp }}/TestResults + + - name: Take down SQL Server test dependency for ${{ matrix.configuration }} build + if: always() + uses: codebeltnet/docker-compose@v1 + with: + command: down + + test_qualitygate: + if: ${{ always() }} + name: test-qualitygate + needs: [init, test_linux, test_windows, test_mac, integration_test] + runs-on: ubuntu-26.04 + steps: + - name: Evaluate test results + shell: bash + env: + RUN_MAC_TESTS: ${{ needs.init.outputs.run-mac-tests }} + RUN_PRIVILEGED_JOBS: ${{ needs.init.outputs.run-privileged-jobs }} + TEST_LINUX_RESULT: ${{ needs.test_linux.result }} + TEST_WINDOWS_RESULT: ${{ needs.test_windows.result }} + TEST_MAC_RESULT: ${{ needs.test_mac.result }} + INTEGRATION_TEST_RESULT: ${{ needs.integration_test.result }} + run: | + require_success() { + local job_name="$1" + local job_result="$2" + + if [[ "$job_result" != "success" ]]; then + echo "::error::$job_name finished with '$job_result'." + exit 1 + fi + } + + require_success_or_skip() { + local job_name="$1" + local job_enabled="$2" + local job_result="$3" + + if [[ "$job_enabled" == "true" ]]; then + require_success "$job_name" "$job_result" + return + fi + + if [[ "$job_result" != "success" && "$job_result" != "skipped" ]]; then + echo "::error::$job_name finished with '$job_result' while disabled." + exit 1 + fi + } + + require_success "test_linux" "$TEST_LINUX_RESULT" + require_success "test_windows" "$TEST_WINDOWS_RESULT" + require_success_or_skip "test_mac" "$RUN_MAC_TESTS" "$TEST_MAC_RESULT" + require_success_or_skip "integration_test" "$RUN_PRIVILEGED_JOBS" "$INTEGRATION_TEST_RESULT" + + sonarcloud: + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} + name: call-sonarcloud + needs: [init, build, test_qualitygate] + uses: codebeltnet/jobs-sonarcloud/.github/workflows/default.yml@v3 + with: + organization: geekle + projectKey: Cuemon + version: ${{ needs.build.outputs.version }} + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + codecov: + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} + name: call-codecov + needs: [init, build, test_qualitygate] + runs-on: ubuntu-26.04 + steps: + - name: Checkout + uses: codebeltnet/git-checkout@v1 + + - name: Upload coverage to Codecov + uses: codebeltnet/codecov-scan@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + repository: codebeltnet/cuemon + configuration: .github/codecov.yml + + codeql: + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} + name: call-codeql + needs: [init, build, test_qualitygate] + uses: codebeltnet/jobs-codeql/.github/workflows/default.yml@v3 + with: + timeout-minutes: 30 + permissions: + contents: read + security-events: write + + pr_quality_gate: + if: ${{ always() }} + name: PR quality gate + needs: [init, build, pack, test_qualitygate, sonarcloud, codecov, codeql] + runs-on: ubuntu-26.04 + steps: + - name: Evaluate PR proof results + shell: bash + env: + RUN_PRIVILEGED_JOBS: ${{ needs.init.outputs.run-privileged-jobs }} + BUILD_RESULT: ${{ needs.build.result }} + PACK_RESULT: ${{ needs.pack.result }} + TEST_RESULT: ${{ needs.test_qualitygate.result }} + SONAR_RESULT: ${{ needs.sonarcloud.result }} + CODECOV_RESULT: ${{ needs.codecov.result }} + CODEQL_RESULT: ${{ needs.codeql.result }} + run: | + set -euo pipefail + + require_success() { + local job_name="$1" + local job_result="$2" + + if [[ "$job_result" != "success" ]]; then + echo "::error::$job_name finished with '$job_result'." + exit 1 + fi + } + + require_skipped() { + local job_name="$1" + local job_result="$2" + + if [[ "$job_result" != "skipped" ]]; then + echo "::error::$job_name finished with '$job_result' for a run that is not authorized to use privileged integrations." + exit 1 + fi + } + + require_success "build" "$BUILD_RESULT" + require_success "pack" "$PACK_RESULT" + require_success "test_qualitygate" "$TEST_RESULT" + + if [[ "$RUN_PRIVILEGED_JOBS" == "true" ]]; then + require_success "sonarcloud" "$SONAR_RESULT" + require_success "codecov" "$CODECOV_RESULT" + require_success "codeql" "$CODEQL_RESULT" + else + require_skipped "sonarcloud" "$SONAR_RESULT" + require_skipped "codecov" "$CODECOV_RESULT" + require_skipped "codeql" "$CODEQL_RESULT" + fi + + { + echo "## PR proof passed" + echo + echo "Build and package validation passed. Required Linux and Windows tests passed; optional macOS and trusted-repository checks followed the workflow inputs and fork policy." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/trigger-downstream.yml b/.github/workflows/trigger-downstream.yml index 29eb29c2..52fe971a 100644 --- a/.github/workflows/trigger-downstream.yml +++ b/.github/workflows/trigger-downstream.yml @@ -7,7 +7,7 @@ on: jobs: dispatch: if: github.event.release.prerelease == false - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 permissions: contents: read From f02e921c72cd7d257ecdb4eaf7d11be2033ecc5d Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 00:01:10 +0200 Subject: [PATCH 13/29] =?UTF-8?q?=F0=9F=90=9B=20propagate=20cancellation?= =?UTF-8?q?=20during=20options=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let cancellation from options validation reach the caller so canceled work is not reported as an ordinary validation failure. --- .../ParameterObjectOptions.cs | 2 +- .../ServiceCollectionExtensionsTest.cs | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs b/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs index 130b4b84..44b4919b 100644 --- a/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs +++ b/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs @@ -27,7 +27,7 @@ public ValidateOptionsResult Validate(string name, TOptions options) validatable.ValidateOptions(); return ValidateOptionsResult.Success; } - catch (Exception e) when (Patterns.IsRecoverableException(e)) + catch (Exception e) when (e is not OperationCanceledException && Patterns.IsRecoverableException(e)) { return ValidateOptionsResult.Fail(e.Message); } diff --git a/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs b/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs index 485c4a20..1f986516 100644 --- a/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading; +using System.Threading.Tasks; using Cuemon.Configuration; #if NET9_0_OR_GREATER using Cuemon.AspNetCore.Diagnostics; @@ -222,6 +223,32 @@ public void AddConfiguredOptions_ShouldPropagateFatalValidationExceptions(int ex } } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void AddConfiguredOptions_ShouldPropagateValidationCancellation_WhenMaterialized(bool resolveDirect, bool useTaskCancellation) + { + var token = new CancellationToken(true); + OperationCanceledException failure = useTaskCancellation + ? new TaskCanceledException(Task.FromCanceled(token)) + : new OperationCanceledException("Validation canceled.", null, token); + var services = new ServiceCollection().AddConfiguredOptions(options => options.ValidationFailure = failure); + + using (var provider = services.BuildServiceProvider()) + { + var exception = Record.Exception(() => + { + if (resolveDirect) { provider.GetRequiredService(); } + else { _ = provider.GetRequiredService>().Value; } + }); + + Assert.Same(failure, exception); + Assert.Equal(token, ((OperationCanceledException)exception).CancellationToken); + } + } + [Fact] public void AddConfiguredOptions_ShouldPropagatePostConfigurationFailure_WithoutValidation() { From 9f8e43c83cd69e622e8c0205058f6d89265f4f9d Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 03:29:34 +0200 Subject: [PATCH 14/29] =?UTF-8?q?=F0=9F=90=9B=20ensure=20unmanaged=20clean?= =?UTF-8?q?up=20after=20managed=20cleanup=20fails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run unmanaged cleanup even when managed cleanup throws, while preserving the original exception and ensuring disposal remains idempotent. --- src/Cuemon.Kernel/Disposable.cs | 12 ++++++-- test/Cuemon.Kernel.Tests/DisposableTest.cs | 35 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/Cuemon.Kernel/Disposable.cs b/src/Cuemon.Kernel/Disposable.cs index d95a1ac9..73a72812 100644 --- a/src/Cuemon.Kernel/Disposable.cs +++ b/src/Cuemon.Kernel/Disposable.cs @@ -51,11 +51,17 @@ protected void Dispose(bool disposing) { if (Disposed) { return; } Disposed = true; - if (disposing) + try { - OnDisposeManagedResources(); + if (disposing) + { + OnDisposeManagedResources(); + } + } + finally + { + OnDisposeUnmanagedResources(); } - OnDisposeUnmanagedResources(); } } } diff --git a/test/Cuemon.Kernel.Tests/DisposableTest.cs b/test/Cuemon.Kernel.Tests/DisposableTest.cs index b1e6fd78..e19e67de 100644 --- a/test/Cuemon.Kernel.Tests/DisposableTest.cs +++ b/test/Cuemon.Kernel.Tests/DisposableTest.cs @@ -50,6 +50,25 @@ public void Dispose_ShouldInvokeManagedAndUnmanagedResourcesWhenDisposingIsTrue( Assert.Equal(1, sut.UnmanagedDisposeCount); } + [Fact] + public void Dispose_ShouldReleaseUnmanagedResourcesAndPreserveException_WhenManagedCleanupThrows() + { + var exception = new InvalidOperationException("Managed cleanup failed."); + var sut = new ThrowingManagedDisposable(exception); + + var actual = Assert.Throws(() => sut.Dispose()); + + Assert.Same(exception, actual); + Assert.True(sut.Disposed); + Assert.Equal(1, sut.ManagedDisposeCount); + Assert.Equal(1, sut.UnmanagedDisposeCount); + + sut.Dispose(); + + Assert.Equal(1, sut.ManagedDisposeCount); + Assert.Equal(1, sut.UnmanagedDisposeCount); + } + [Fact] public async Task Dispose_ShouldBeThreadSafeAndInvokeCallbacksOnce() { @@ -69,4 +88,20 @@ public async Task Dispose_ShouldBeThreadSafeAndInvokeCallbacksOnce() Assert.Equal(1, sut.ManagedDisposeCount); Assert.Equal(1, sut.UnmanagedDisposeCount); } + + private sealed class ThrowingManagedDisposable : TrackingDisposable + { + private readonly Exception _exception; + + public ThrowingManagedDisposable(Exception exception) + { + _exception = exception; + } + + protected override void OnDisposeManagedResources() + { + base.OnDisposeManagedResources(); + throw _exception; + } + } } From cf06640cdf44d3b30afcd3b17155b33add0dc159 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 03:29:40 +0200 Subject: [PATCH 15/29] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20update=20coverlet=20?= =?UTF-8?q?and=20sqlclient=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the shared test coverage and SQL client dependencies with their updated patch and minor releases. --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index dc165c90..41767c81 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + @@ -53,6 +53,6 @@ - + \ No newline at end of file From 10a1a65f0fb95de2d285a1373ce62fe8420a8937 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 03:29:47 +0200 Subject: [PATCH 16/29] =?UTF-8?q?=F0=9F=91=B7=20reuse=20the=20shared=20cod?= =?UTF-8?q?ecov=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Codecov upload to the shared reusable workflow so pull request CI uses the centralized job definition. --- .github/workflows/pr.yml | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index adfed0db..2e8321e4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -375,17 +375,11 @@ jobs: if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} name: call-codecov needs: [init, build, test_qualitygate] - runs-on: ubuntu-26.04 - steps: - - name: Checkout - uses: codebeltnet/git-checkout@v1 - - - name: Upload coverage to Codecov - uses: codebeltnet/codecov-scan@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - repository: codebeltnet/cuemon - configuration: .github/codecov.yml + uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@v1 + with: + repository: codebeltnet/cuemon + configuration: .github/codecov.yml + secrets: inherit codeql: if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} From 9b8e9fd89fe5df924a1fe47f8e832b766da827f7 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 04:11:50 +0200 Subject: [PATCH 17/29] =?UTF-8?q?=F0=9F=93=A6=20update=20package=20release?= =?UTF-8?q?=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the 10.8.0 package changes and compatible dependency updates in each affected package release note. --- .nuget/Cuemon.AspNetCore.App/PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.AspNetCore.Mvc/PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.AspNetCore/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Core.App/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Core/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Data.Integrity/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Data.SqlClient/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Data/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Diagnostics/PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../Cuemon.Extensions.AspNetCore/PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Extensions.Core/PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Extensions.Data/PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 5 ++++- .../PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Extensions.Hosting/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Extensions.IO/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Extensions.Net/PackageReleaseNotes.txt | 6 ++++++ .../Cuemon.Extensions.Reflection/PackageReleaseNotes.txt | 6 ++++++ .../PackageReleaseNotes.txt | 6 ++++++ .../Cuemon.Extensions.Text.Json/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Extensions.Text/PackageReleaseNotes.txt | 6 ++++++ .../Cuemon.Extensions.Threading/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Extensions.Xml/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.IO/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Kernel/PackageReleaseNotes.txt | 9 +++++++++ .nuget/Cuemon.Net/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Resilience/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Runtime.Caching/PackageReleaseNotes.txt | 6 ++++++ .../Cuemon.Security.Cryptography/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Threading/PackageReleaseNotes.txt | 6 ++++++ .nuget/Cuemon.Xml/PackageReleaseNotes.txt | 6 ++++++ 44 files changed, 265 insertions(+), 1 deletion(-) diff --git a/.nuget/Cuemon.AspNetCore.App/PackageReleaseNotes.txt b/.nuget/Cuemon.AspNetCore.App/PackageReleaseNotes.txt index 3ac27048..42f41319 100644 --- a/.nuget/Cuemon.AspNetCore.App/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.AspNetCore.App/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.AspNetCore.Authentication/PackageReleaseNotes.txt b/.nuget/Cuemon.AspNetCore.Authentication/PackageReleaseNotes.txt index c5ce9245..3e2a825b 100644 --- a/.nuget/Cuemon.AspNetCore.Authentication/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.AspNetCore.Authentication/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.AspNetCore.Mvc/PackageReleaseNotes.txt b/.nuget/Cuemon.AspNetCore.Mvc/PackageReleaseNotes.txt index 4d5248f1..03cb0515 100644 --- a/.nuget/Cuemon.AspNetCore.Mvc/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.AspNetCore.Mvc/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.AspNetCore.Razor.TagHelpers/PackageReleaseNotes.txt b/.nuget/Cuemon.AspNetCore.Razor.TagHelpers/PackageReleaseNotes.txt index 45ae65c5..a0bcdc46 100644 --- a/.nuget/Cuemon.AspNetCore.Razor.TagHelpers/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.AspNetCore.Razor.TagHelpers/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.AspNetCore/PackageReleaseNotes.txt b/.nuget/Cuemon.AspNetCore/PackageReleaseNotes.txt index b7d1e0c5..4444e606 100644 --- a/.nuget/Cuemon.AspNetCore/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.AspNetCore/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Core.App/PackageReleaseNotes.txt b/.nuget/Cuemon.Core.App/PackageReleaseNotes.txt index 6a5a13ba..b38397cd 100644 --- a/.nuget/Cuemon.Core.App/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Core.App/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Core/PackageReleaseNotes.txt b/.nuget/Cuemon.Core/PackageReleaseNotes.txt index f1f689cb..880e055c 100644 --- a/.nuget/Cuemon.Core/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Core/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Data.Integrity/PackageReleaseNotes.txt b/.nuget/Cuemon.Data.Integrity/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Data.Integrity/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Data.Integrity/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Data.SqlClient/PackageReleaseNotes.txt b/.nuget/Cuemon.Data.SqlClient/PackageReleaseNotes.txt index d77ad520..a02f1fc4 100644 --- a/.nuget/Cuemon.Data.SqlClient/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Data.SqlClient/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Microsoft.Data.SqlClient from 7.1.0 to 7.1.1 for .NET 9 and .NET 10 + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Data/PackageReleaseNotes.txt b/.nuget/Cuemon.Data/PackageReleaseNotes.txt index 793ff801..5f0fcff9 100644 --- a/.nuget/Cuemon.Data/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Data/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Diagnostics/PackageReleaseNotes.txt b/.nuget/Cuemon.Diagnostics/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Diagnostics/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Diagnostics/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.AspNetCore.Authentication/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.AspNetCore.Authentication/PackageReleaseNotes.txt index 3ac27048..42f41319 100644 --- a/.nuget/Cuemon.Extensions.AspNetCore.Authentication/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.AspNetCore.Authentication/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json/PackageReleaseNotes.txt index 16a24dfb..5acd379c 100644 --- a/.nuget/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Text.Json/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml/PackageReleaseNotes.txt index bc950872..cd2a5575 100644 --- a/.nuget/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.AspNetCore.Mvc.Formatters.Xml/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Extensions.AspNetCore.Mvc.RazorPages/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.AspNetCore.Mvc.RazorPages/PackageReleaseNotes.txt index 3ac27048..42f41319 100644 --- a/.nuget/Cuemon.Extensions.AspNetCore.Mvc.RazorPages/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.AspNetCore.Mvc.RazorPages/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Extensions.AspNetCore.Mvc/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.AspNetCore.Mvc/PackageReleaseNotes.txt index 8dc55a4e..122a4146 100644 --- a/.nuget/Cuemon.Extensions.AspNetCore.Mvc/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.AspNetCore.Mvc/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Extensions.AspNetCore.Text.Json/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.AspNetCore.Text.Json/PackageReleaseNotes.txt index 3ecd2b46..460df4c0 100644 --- a/.nuget/Cuemon.Extensions.AspNetCore.Text.Json/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.AspNetCore.Text.Json/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Extensions.AspNetCore.Xml/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.AspNetCore.Xml/PackageReleaseNotes.txt index dddaa088..dd45b414 100644 --- a/.nuget/Cuemon.Extensions.AspNetCore.Xml/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.AspNetCore.Xml/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Extensions.AspNetCore/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.AspNetCore/PackageReleaseNotes.txt index 352ed032..a64bbbb2 100644 --- a/.nuget/Cuemon.Extensions.AspNetCore/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.AspNetCore/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Cuemon.Extensions.Collections.Generic/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Collections.Generic/PackageReleaseNotes.txt index cbdae826..afb6cd7a 100644 --- a/.nuget/Cuemon.Extensions.Collections.Generic/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Collections.Generic/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Collections.Specialized/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Collections.Specialized/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Extensions.Collections.Specialized/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Collections.Specialized/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Core/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Core/PackageReleaseNotes.txt index e91d129d..881be19e 100644 --- a/.nuget/Cuemon.Extensions.Core/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Core/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Data.Integrity/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Data.Integrity/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Extensions.Data.Integrity/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Data.Integrity/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Data/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Data/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Extensions.Data/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Data/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt index f87c1b13..405b7b94 100644 --- a/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt @@ -1,8 +1,11 @@ Version: 10.8.0 Availability: .NET 10, .NET 9 and .NET Standard 2.0 +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + # New Features -- ADDED ServiceCollectionExtensions.AddConfiguredOptions() method to register a primary Parameter Object configurator with Microsoft Options, bridge Cuemon post-configuration and validation conventions, and expose the default IOptions.Value as a singleton TOptions alongside the original Action; repeated calls retain the first configurator while ordinary Configure registrations still compose +- ADDED ServiceCollectionExtensions.AddConfiguredOptions() to register the primary options configurator, integrate Parameter Object post-configuration and validation conventions, and expose the default options instance and configurator through dependency injection Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Diagnostics/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Diagnostics/PackageReleaseNotes.txt index bc6d8211..7787016e 100644 --- a/.nuget/Cuemon.Extensions.Diagnostics/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Diagnostics/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.FileProviders.Physical/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.FileProviders.Physical/PackageReleaseNotes.txt index 6b156e58..c6b81486 100644 --- a/.nuget/Cuemon.Extensions.FileProviders.Physical/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.FileProviders.Physical/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Hosting/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Hosting/PackageReleaseNotes.txt index 17c3bf50..f7ddf680 100644 --- a/.nuget/Cuemon.Extensions.Hosting/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Hosting/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.IO/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.IO/PackageReleaseNotes.txt index 52453b0d..bf7cac3b 100644 --- a/.nuget/Cuemon.Extensions.IO/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.IO/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9, .NET Standard 2.1 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9, .NET Standard 2.1 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Net/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Net/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Extensions.Net/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Net/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Reflection/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Reflection/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Extensions.Reflection/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Reflection/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Runtime.Caching/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Runtime.Caching/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Extensions.Runtime.Caching/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Runtime.Caching/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Text.Json/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Text.Json/PackageReleaseNotes.txt index 9ffbaac7..0b6159ed 100644 --- a/.nuget/Cuemon.Extensions.Text.Json/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Text.Json/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Text/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Text/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Extensions.Text/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Text/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Threading/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Threading/PackageReleaseNotes.txt index 896b3b20..2d909fda 100644 --- a/.nuget/Cuemon.Extensions.Threading/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Threading/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Extensions.Xml/PackageReleaseNotes.txt b/.nuget/Cuemon.Extensions.Xml/PackageReleaseNotes.txt index 4bd7dc3a..ccaecd7d 100644 --- a/.nuget/Cuemon.Extensions.Xml/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.Xml/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.IO/PackageReleaseNotes.txt b/.nuget/Cuemon.IO/PackageReleaseNotes.txt index 52453b0d..bf7cac3b 100644 --- a/.nuget/Cuemon.IO/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.IO/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9, .NET Standard 2.1 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9, .NET Standard 2.1 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Kernel/PackageReleaseNotes.txt b/.nuget/Cuemon.Kernel/PackageReleaseNotes.txt index 0a06daf8..86e77918 100644 --- a/.nuget/Cuemon.Kernel/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Kernel/PackageReleaseNotes.txt @@ -1,3 +1,12 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +# Bug Fixes +- FIXED Disposable to run unmanaged cleanup when managed cleanup throws while preserving the managed cleanup exception and keeping disposal idempotent + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Net/PackageReleaseNotes.txt b/.nuget/Cuemon.Net/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Net/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Net/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Resilience/PackageReleaseNotes.txt b/.nuget/Cuemon.Resilience/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Resilience/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Resilience/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Runtime.Caching/PackageReleaseNotes.txt b/.nuget/Cuemon.Runtime.Caching/PackageReleaseNotes.txt index ca21ed26..c8bfbdd3 100644 --- a/.nuget/Cuemon.Runtime.Caching/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Runtime.Caching/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Security.Cryptography/PackageReleaseNotes.txt b/.nuget/Cuemon.Security.Cryptography/PackageReleaseNotes.txt index 41168aab..5cbec928 100644 --- a/.nuget/Cuemon.Security.Cryptography/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Security.Cryptography/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Threading/PackageReleaseNotes.txt b/.nuget/Cuemon.Threading/PackageReleaseNotes.txt index 5f2a5a73..f5b267c2 100644 --- a/.nuget/Cuemon.Threading/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Threading/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 diff --git a/.nuget/Cuemon.Xml/PackageReleaseNotes.txt b/.nuget/Cuemon.Xml/PackageReleaseNotes.txt index bd7deb23..bb93c350 100644 --- a/.nuget/Cuemon.Xml/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Xml/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 10.8.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 10.7.1 Availability: .NET 10, .NET 9 and .NET Standard 2.0 From 0bcd6c23522d1f409f6e2e69390eeeb324eed9a7 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 04:11:57 +0200 Subject: [PATCH 18/29] =?UTF-8?q?=F0=9F=92=AC=20update=2010.8.0=20release?= =?UTF-8?q?=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the release date and summarize the shipped options integration, disposal fix, dependency updates, and workflow changes for repository readers. --- CHANGELOG.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1b2ab7..f2c634ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,22 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), For more details, please refer to `PackageReleaseNotes.txt` on a per assembly basis in the `.nuget` folder. -## [10.8.0] - 2026-10-02 +## [10.8.0] - 2026-10-03 -This is a minor release adding Parameter Object integration with Microsoft Options and separating pull request validation, package release, and DocFX deployment workflows. +This is a minor release adding Parameter Object integration with Microsoft Options, improving disposal cleanup, and separating pull request validation, package publication, and documentation deployment. ### Added -- `IServiceCollection.AddConfiguredOptions()` integrates Cuemon post-configuration and validation conventions with Microsoft Options and exposes the configured default options instance and primary `Action`, -- Dedicated pull request, release, and deployment workflows assign CI validation, NuGet publication and release assurance, and DocFX image promotion to separate workflows. +- `IServiceCollection.AddConfiguredOptions()` connects Parameter Object post-configuration and validation to Microsoft Options, exposes the primary `Action`, and registers the cached default options instance for direct injection, +- Dedicated pull request, release, and deployment workflows validate contributions, publish NuGet packages with post-release assurance, and promote the released DocFX image. ### Changed -- CI references now use the pull request workflow and `main` Codecov branch, run on Ubuntu 26.04, and document the updated workflow responsibilities. +- Updated `Codebelt.Coverlet.MTP` from 10.0.1 to 10.1.0 and `Microsoft.Data.SqlClient` from 7.1.0 to 7.1.1, +- Updated CI references and guidance for the pull request workflow, `main` Codecov branch, and Ubuntu 26.04 runners. + +### Fixed + +- `Disposable.Dispose` now runs unmanaged cleanup when managed cleanup throws while preserving the managed cleanup exception and idempotent disposal. ### Removed -- The combined `.github/workflows/ci-pipeline.yml` workflow was removed as CI responsibilities moved to the dedicated pull request, release, and deployment workflows. +- Removed the combined `.github/workflows/ci-pipeline.yml` workflow as pull request, release, and deployment responsibilities moved to dedicated workflows. ## [10.7.1] - 2026-09-09 @@ -1914,6 +1919,7 @@ This release was primarily focused on adapting a more modern way of performing C - XmlWriterUtility class from Cuemon.Xml namespace - XmlWriterUtilityExtensions class from the Cuemon.Xml namespace +[Unreleased]: https://github.com/codebeltnet/cuemon/compare/v10.8.0...HEAD [10.8.0]: https://github.com/codebeltnet/cuemon/compare/v10.7.1...v10.8.0 [10.7.1]: https://github.com/codebeltnet/cuemon/compare/v10.7.0...v10.7.1 [10.7.0]: https://github.com/codebeltnet/cuemon/compare/v10.6.0...v10.7.0 From d56d25a6ae84d2703004cbbd83b49f1cbe406554 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 12:22:36 +0200 Subject: [PATCH 19/29] =?UTF-8?q?=E2=9C=85=20use=20UTC=20validity=20window?= =?UTF-8?q?s=20in=20signed=20URI=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local midnight can already be in the past when interpreted as UTC on runners west of UTC. Derive validity windows from one UTC timestamp so future-start and expiry rejection checks remain reliable while preserving signature and tampering assertions. --- .../Security/StringExtensionsTest.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/Cuemon.Extensions.Net.Tests/Security/StringExtensionsTest.cs b/test/Cuemon.Extensions.Net.Tests/Security/StringExtensionsTest.cs index 33db15b6..2f8b3b52 100644 --- a/test/Cuemon.Extensions.Net.Tests/Security/StringExtensionsTest.cs +++ b/test/Cuemon.Extensions.Net.Tests/Security/StringExtensionsTest.cs @@ -17,8 +17,9 @@ public void ToSignedUri_ShouldSignUriAndVerifyUri() { var uriString = "https://www.google.com/search?q=cuemon&rlz=1C1GCEU_enDK858DK858&oq=cuemon&aqs=chrome..69i57j69i59j35i39j69i60l3j69i65l2.3047j0j9&sourceid=chrome&ie=UTF-8"; var md5Header = "53068c5376dc5a934f1a40b41025148e"; - var signedUri = uriString.ToSignedUri(Secret, DateTime.UtcNow, DateTime.UtcNow.AddDays(1)); - var signedUriWithMd5 = uriString.ToSignedUri(Secret, DateTime.UtcNow, DateTime.UtcNow.AddDays(1), o => o.ContentMd5Header = md5Header); + var utcNow = DateTime.UtcNow; + var signedUri = uriString.ToSignedUri(Secret, utcNow.AddMinutes(-1), utcNow.AddDays(1)); + var signedUriWithMd5 = uriString.ToSignedUri(Secret, utcNow.AddMinutes(-1), utcNow.AddDays(1), o => o.ContentMd5Header = md5Header); TestOutput.WriteLine(signedUri.OriginalString); TestOutput.WriteLine(signedUriWithMd5.OriginalString); @@ -35,13 +36,13 @@ public void ToSignedUri_ShouldSignUriAndVerifyUri() su.Uri.ValidateSignedUri(Secret); }); - signedUri = uriString.ToSignedUri(Secret, DateTime.Today.AddDays(1)); + signedUri = uriString.ToSignedUri(Secret, utcNow.AddDays(1)); Assert.Throws(() => { signedUri.ValidateSignedUri(Secret); }); - signedUri = uriString.ToSignedUri(Secret, expiry: DateTime.Today.AddDays(-1)); + signedUri = uriString.ToSignedUri(Secret, expiry: utcNow.AddDays(-1)); Assert.Throws(() => { signedUri.ValidateSignedUri(Secret); From 98908e993240ef1effb6abf94554316bfd894245 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 12:22:46 +0200 Subject: [PATCH 20/29] =?UTF-8?q?=E2=9C=85=20isolate=20latency=20limits=20?= =?UTF-8?q?from=20retry=20behavior=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduler overhead on loaded CI runners can exceed the shared 500 ms latency budget before retries complete. Use the default budget for retry behavior tests and reserve the strict limit for dedicated latency tests, preserving retry counts, results, and exception assertions. --- .../TransientOperationTest.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/Cuemon.Resilience.Tests/TransientOperationTest.cs b/test/Cuemon.Resilience.Tests/TransientOperationTest.cs index d73356e3..f89f790d 100644 --- a/test/Cuemon.Resilience.Tests/TransientOperationTest.cs +++ b/test/Cuemon.Resilience.Tests/TransientOperationTest.cs @@ -30,7 +30,6 @@ public TransientOperationTest(ITestOutputHelper output) : base(output) o.DetectionStrategy = DetectionStrategyCallback; o.RetryAttempts = ExpectedRetryAttempts; o.RetryStrategy = RetryStrategyCallback; - o.MaximumAllowedLatency = ExpectedMaximumAllowedLatency; }; } @@ -55,6 +54,12 @@ private TimeSpan RetryStrategyCallback(int retry) private Action TransientOperationOptionsCallback { get; } + private void LatencyOperationOptionsCallback(TransientOperationOptions options) + { + TransientOperationOptionsCallback(options); + options.MaximumAllowedLatency = ExpectedMaximumAllowedLatency; + } + [Fact] public void WithFunc_ShouldBypassTransientFaultHandling() { @@ -112,7 +117,7 @@ public void WithFunc_ShouldTriggerLatencyException() var profiler = TimeMeasure.WithAction(() => { - var aex = Assert.Throws(() => TransientOperation.WithFunc(FuncTransientOperation.TriggerLatencyException, id, _retryTracker, TransientOperationOptionsCallback)); + var aex = Assert.Throws(() => TransientOperation.WithFunc(FuncTransientOperation.TriggerLatencyException, id, _retryTracker, LatencyOperationOptionsCallback)); Assert.IsType(aex.InnerExceptions.First()); Assert.Equal(NormalRunIncrement + DescriptiveExceptionCauseIncrement, aex.InnerExceptions.Count); TestOutput.WriteLine(aex.ToString()); @@ -194,7 +199,7 @@ public void WithAction_ShouldTriggerLatencyException() var profiler = TimeMeasure.WithAction(() => { - var aex = Assert.Throws(() => TransientOperation.WithAction(ActionTransientOperation.TriggerLatencyException, id, _retryTracker, TransientOperationOptionsCallback)); + var aex = Assert.Throws(() => TransientOperation.WithAction(ActionTransientOperation.TriggerLatencyException, id, _retryTracker, LatencyOperationOptionsCallback)); Assert.IsType(aex.InnerExceptions.First()); Assert.Equal(NormalRunIncrement + DescriptiveExceptionCauseIncrement, aex.InnerExceptions.Count); TestOutput.WriteLine(aex.ToString()); @@ -276,7 +281,7 @@ public async Task WithActionAsync_ShouldTriggerLatencyException() var profiler = await TimeMeasure.WithActionAsync(async ct => { - var aex = await Assert.ThrowsAsync(() => TransientOperation.WithActionAsync(AsyncActionTransientOperation.TriggerLatencyExceptionAsync, id, _retryTracker, TransientOperationOptionsCallback)); + var aex = await Assert.ThrowsAsync(() => TransientOperation.WithActionAsync(AsyncActionTransientOperation.TriggerLatencyExceptionAsync, id, _retryTracker, LatencyOperationOptionsCallback)); TestOutput.WriteLine(aex.ToString()); @@ -352,7 +357,7 @@ public async Task WithFuncAsync_ShouldTriggerLatencyException() var profiler = await TimeMeasure.WithActionAsync(async ct => { - var aex = await Assert.ThrowsAsync(() => TransientOperation.WithFuncAsync(AsyncFuncTransientOperation.TriggerLatencyExceptionAsync, id, _retryTracker, TransientOperationOptionsCallback)); + var aex = await Assert.ThrowsAsync(() => TransientOperation.WithFuncAsync(AsyncFuncTransientOperation.TriggerLatencyExceptionAsync, id, _retryTracker, LatencyOperationOptionsCallback)); Assert.IsType(aex.InnerExceptions.First()); Assert.Equal(NormalRunIncrement + DescriptiveExceptionCauseIncrement, aex.InnerExceptions.Count); TestOutput.WriteLine(aex.ToString()); From 29f29458d5c6de827135356610d626f85249b1fe Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sat, 3 Oct 2026 13:21:12 +0200 Subject: [PATCH 21/29] original --- .github/workflows/release.yml | 728 ++++++++++++++++++++++++++++++++++ 1 file changed, 728 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..fbcd98e8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,728 @@ +name: Release Flow +on: + workflow_dispatch: + inputs: + version: + type: string + description: SemVer 2.0 release version without the v prefix. Use x.y.z or x.y.z-prerelease; build metadata is not supported. + required: true + +permissions: + contents: read + +concurrency: + group: cuemon-release-${{ inputs.version }} + cancel-in-progress: false + +jobs: + release_preflight: + name: Validate main SHA and release version + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + version: ${{ steps.validate.outputs.version }} + tag: ${{ steps.validate.outputs.tag }} + sha: ${{ steps.validate.outputs.sha }} + steps: + - name: Checkout the dispatch SHA + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ github.sha }} + + - id: validate + name: Validate authoritative release request + shell: bash + env: + RELEASE_REF: ${{ github.ref }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then + echo "::error::Release dispatch must target refs/heads/main; received '$RELEASE_REF'." + exit 1 + fi + + semver_regex='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$' + if [[ ! "$RELEASE_VERSION" =~ $semver_regex ]]; then + echo "::error::'$RELEASE_VERSION' is not a supported SemVer release version. Use x.y.z or x.y.z-prerelease without build metadata." + exit 1 + fi + + checked_out_sha="$(git rev-parse HEAD)" + if [[ "$checked_out_sha" != "$RELEASE_SHA" ]]; then + echo "::error::Checkout SHA '$checked_out_sha' does not match dispatch SHA '$RELEASE_SHA'." + exit 1 + fi + + release_tag="v$RELEASE_VERSION" + assert_absent() { + local endpoint="$1" + local label="$2" + local response + local status + + set +e + response="$(gh api "$endpoint" 2>&1)" + status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "::error::$label '$release_tag' already exists. Select a new human-approved SemVer version." + exit 1 + fi + if [[ "$response" != *"HTTP 404"* ]]; then + echo "::error::Could not verify that $label '$release_tag' is unused." + echo "$response" + exit 1 + fi + } + + assert_absent "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" "Git tag" + assert_absent "repos/$GITHUB_REPOSITORY/releases/tags/$release_tag" "GitHub Release" + + { + echo "version=$RELEASE_VERSION" + echo "tag=$release_tag" + echo "sha=$RELEASE_SHA" + } >> "$GITHUB_OUTPUT" + + { + echo "## Release request validated" + echo + echo "- Version: `$RELEASE_VERSION`" + echo "- Source: `main` at `$RELEASE_SHA`" + echo "- Tag: `$release_tag`" + } >> "$GITHUB_STEP_SUMMARY" + + release_packages: + name: Build and validate Release packages + needs: [release_preflight] + runs-on: ubuntu-26.04 + timeout-minutes: 45 + permissions: + contents: read + env: + MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} + steps: + - name: Checkout the released SHA + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - name: Install .NET + uses: codebeltnet/install-dotnet@v3 + + - name: Download the strong-name signing key + uses: codebeltnet/gcp-download-file@v1 + with: + serviceAccountKey: ${{ secrets.GCP_TOKEN }} + bucketName: ${{ secrets.GCP_BUCKETNAME }} + objectName: cuemon.snk + + - name: Verify signing key is present + shell: bash + run: | + set -euo pipefail + if [[ ! -s cuemon.snk ]]; then + echo "::error::The strong-name key 'cuemon.snk' was not downloaded." + exit 1 + fi + + - name: Restore Release dependencies + uses: codebeltnet/dotnet-restore@v3 + + - name: Build Release packages from the exact SHA + uses: codebeltnet/dotnet-build@v4 + with: + configuration: Release + build-switches: -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + + - name: Pack Release packages + uses: codebeltnet/dotnet-pack@v3 + with: + configuration: Release + + - name: Validate package versions and existing NuGet content + uses: codebeltnet/nuget-release-validate@v1 + with: + package-directory: ${{ runner.temp }}/.nuget + version: ${{ needs.release_preflight.outputs.version }} + install-dotnet: 'false' + - name: Persist validated NuGet packages for protected publication + uses: actions/upload-artifact@v7 + with: + name: NuGet-Release + path: ${{ runner.temp }}/.nuget + if-no-files-found: error + include-hidden-files: true + retention-days: 30 + + publish_nuget: + name: Publish NuGet v${{ needs.release_preflight.outputs.version }} + needs: [release_preflight, release_packages] + uses: codebeltnet/jobs-nuget-push/.github/workflows/default.yml@v3 + with: + version: ${{ needs.release_preflight.outputs.version }} + environment: Production + configuration: Release + download-build-artifact-name: NuGet-Release + permissions: + contents: read + packages: write + secrets: + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} + + tag_and_draft_release: + name: Tag released SHA and create draft GitHub Release + needs: [release_preflight, release_packages, publish_nuget] + runs-on: ubuntu-26.04 + permissions: + contents: write + steps: + - name: Checkout the released SHA + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - name: Create or verify the version tag + shell: bash + env: + RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} + RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + set +e + git ls-remote --exit-code origin "refs/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release-tag-ref.txt" + tag_status=$? + set -e + + if [[ "$tag_status" -eq 0 ]]; then + git fetch --force --quiet origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + existing_sha="$(git rev-parse "$RELEASE_TAG^{commit}")" + if [[ "$existing_sha" != "$RELEASE_SHA" ]]; then + echo "::error::Tag '$RELEASE_TAG' points to '$existing_sha', not released SHA '$RELEASE_SHA'." + exit 1 + fi + echo "Tag '$RELEASE_TAG' already points to the released SHA." + elif [[ "$tag_status" -eq 2 ]]; then + git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "Cuemon $RELEASE_TAG" + git push origin "refs/tags/$RELEASE_TAG" + else + echo "::error::Could not check whether tag '$RELEASE_TAG' exists (git ls-remote exit code $tag_status)." + cat "$RUNNER_TEMP/release-tag-ref.txt" + exit 1 + fi + + - name: Create or verify the draft GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} + RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + run: | + set -euo pipefail + + set +e + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" 2>&1)" + release_status=$? + set -e + + if [[ "$release_status" -eq 0 ]]; then + release_tag="$(jq -r '.tag_name' <<< "$release_json")" + if [[ "$release_tag" != "$RELEASE_TAG" ]]; then + echo "::error::GitHub returned release '$release_tag' for requested tag '$RELEASE_TAG'." + exit 1 + fi + echo "GitHub Release '$RELEASE_TAG' already exists; retaining its current draft/published state." + exit 0 + fi + + if [[ "$release_json" != *"HTTP 404"* ]]; then + echo "::error::Could not check GitHub Release '$RELEASE_TAG'." + echo "$release_json" + exit 1 + fi + + release_args=(--draft --verify-tag --title "Cuemon $RELEASE_TAG" --generate-notes) + if [[ "$RELEASE_VERSION" == *-* ]]; then + release_args+=(--prerelease) + fi + gh release create "$RELEASE_TAG" "${release_args[@]}" + + docfx_oci_build: + name: Build multi-platform DocFX OCI artifact once + needs: [release_preflight, publish_nuget] + runs-on: ubuntu-26.04 + timeout-minutes: 90 + permissions: + contents: read + steps: + - name: Checkout the released SHA + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - name: Download strong-name key for DocFX metadata compilation + uses: codebeltnet/gcp-download-file@v1 + with: + serviceAccountKey: ${{ secrets.GCP_TOKEN }} + bucketName: ${{ secrets.GCP_BUCKETNAME }} + objectName: cuemon.snk + + - name: Verify signing key is present for DocFX metadata + shell: bash + run: | + set -euo pipefail + if [[ ! -s cuemon.snk ]]; then + echo "::error::The strong-name key 'cuemon.snk' was not downloaded for DocFX metadata compilation." + exit 1 + fi + + - name: Install .NET + uses: codebeltnet/install-dotnet@v3 + + - name: Build and verify the multi-platform DocFX OCI artifact + uses: codebeltnet/docfx-oci-build@v1 + with: + version: ${{ needs.release_preflight.outputs.version }} + revision: ${{ needs.release_preflight.outputs.sha }} + output-path: ${{ runner.temp }}/cuemon-docfx-${{ needs.release_preflight.outputs.version }}.oci.tar + docfx-version: 2.78.5 + platforms: linux/amd64,linux/arm64 + + - name: Persist OCI image between release jobs + uses: actions/upload-artifact@v7 + with: + name: DocFX-OCI-${{ needs.release_preflight.outputs.version }} + path: | + ${{ runner.temp }}/cuemon-docfx-${{ needs.release_preflight.outputs.version }}.oci.tar + ${{ runner.temp }}/cuemon-docfx-${{ needs.release_preflight.outputs.version }}.oci.tar.sha256 + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + upload_docfx_release_asset: + name: Attach the built DocFX OCI artifact to its release + needs: [release_preflight, tag_and_draft_release, docfx_oci_build] + runs-on: ubuntu-26.04 + permissions: + contents: write + steps: + - name: Download the already-built OCI artifact + uses: actions/download-artifact@v8 + with: + name: DocFX-OCI-${{ needs.release_preflight.outputs.version }} + path: ${{ runner.temp }}/docfx-release-asset + + - name: Revalidate transferred OCI artifact + uses: codebeltnet/oci-artifact-verify@v1 + with: + archive-path: ${{ runner.temp }}/docfx-release-asset/cuemon-docfx-${{ needs.release_preflight.outputs.version }}.oci.tar + checksum-path: ${{ runner.temp }}/docfx-release-asset/cuemon-docfx-${{ needs.release_preflight.outputs.version }}.oci.tar.sha256 + version: ${{ needs.release_preflight.outputs.version }} + revision: ${{ needs.release_preflight.outputs.sha }} + + - name: Upload immutable image and checksum assets to the draft release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} + RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + run: | + set -euo pipefail + + archive_name="cuemon-docfx-$RELEASE_VERSION.oci.tar" + checksum_name="$archive_name.sha256" + archive="$RUNNER_TEMP/docfx-release-asset/$archive_name" + checksum="$RUNNER_TEMP/docfx-release-asset/$checksum_name" + existing_dir="$RUNNER_TEMP/existing-docfx-assets" + mkdir -p "$existing_dir" + + asset_names="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq '.assets[].name')" + has_archive=false + has_checksum=false + if grep -Fxq "$archive_name" <<< "$asset_names"; then has_archive=true; fi + if grep -Fxq "$checksum_name" <<< "$asset_names"; then has_checksum=true; fi + + if [[ "$has_archive" == "true" ]]; then + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$archive_name" --dir "$existing_dir" + if ! cmp -s "$archive" "$existing_dir/$archive_name"; then + echo "::error::Release asset '$archive_name' already exists with different bytes; refusing to overwrite it." + exit 1 + fi + fi + + if [[ "$has_checksum" == "true" ]]; then + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$checksum_name" --dir "$existing_dir" + if ! cmp -s "$checksum" "$existing_dir/$checksum_name"; then + echo "::error::Release asset '$checksum_name' already exists with different bytes; refusing to overwrite it." + exit 1 + fi + fi + + if [[ "$has_archive" == "false" && "$has_checksum" == "false" ]]; then + gh release upload "$RELEASE_TAG" "$archive" "$checksum" --repo "$GITHUB_REPOSITORY" + elif [[ "$has_archive" == "true" && "$has_checksum" == "false" ]]; then + gh release upload "$RELEASE_TAG" "$checksum" --repo "$GITHUB_REPOSITORY" + elif [[ "$has_archive" == "false" && "$has_checksum" == "true" ]]; then + gh release upload "$RELEASE_TAG" "$archive" --repo "$GITHUB_REPOSITORY" + else + echo "The exact versioned OCI archive and checksum are already attached to '$RELEASE_TAG'." + fi + + publish_github_release: + name: Publish GitHub Release after the OCI asset is attached + needs: [release_preflight, upload_docfx_release_asset] + runs-on: ubuntu-26.04 + permissions: + contents: write + steps: + - name: Publish the versioned GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + run: | + set -euo pipefail + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + release_id="$(jq -r '.id' <<< "$release_json")" + is_draft="$(jq -r '.draft' <<< "$release_json")" + if [[ "$is_draft" == "false" ]]; then + echo "GitHub Release '$RELEASE_TAG' is already published." + exit 0 + fi + if [[ "$is_draft" != "true" || ! "$release_id" =~ ^[0-9]+$ ]]; then + echo "::error::GitHub Release '$RELEASE_TAG' is not in a publishable draft state." + exit 1 + fi + + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" -F draft=false + + prepare_release_tests: + name: Discover post-release test projects + needs: [release_preflight, publish_nuget] + runs-on: ubuntu-26.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + json: ${{ steps.test-projects.outputs.result }} + steps: + - name: Checkout the released SHA + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - id: test-projects + name: Generate matrix for test projects + uses: codebeltnet/shell-globbing@v2 + with: + pattern: | + test/**/*.csproj + !test/**/Cuemon.Data.SqlClient.Tests.csproj + + - name: JSON output + run: echo "${{ steps.test-projects.outputs.result }}" + + post_release_tests: + name: Post-release Release tests - ${{ matrix.project }} + needs: [release_preflight, prepare_release_tests, publish_nuget] + strategy: + fail-fast: false + matrix: + project: ${{ fromJson(needs.prepare_release_tests.outputs.json) }} + runs-on: ubuntu-26.04 + timeout-minutes: 30 + permissions: + contents: read + env: + MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} + steps: + - name: Checkout the released SHA + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - name: Install .NET + uses: codebeltnet/install-dotnet@v3 + + - name: Install .NET Tool - Report Generator + uses: codebeltnet/dotnet-tool-install-reportgenerator@v1 + + - name: Test Release project with xUnit v3 and coverage + uses: codebeltnet/dotnet-test@v4 + with: + projects: ${{ matrix.project }} + configuration: Release + build: true + restore: true + build-switches: -p:SkipSignAssembly=true -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + + - name: Upload post-release test results and coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: TestResults-Release-${{ strategy.job-index }} + path: ${{ runner.temp }}/TestResults + if-no-files-found: warn + include-hidden-files: true + retention-days: 30 + + post_release_integration_test: + name: Post-release SQL Server integration test + needs: [release_preflight, publish_nuget] + runs-on: ubuntu-26.04 + timeout-minutes: 30 + permissions: + contents: read + env: + MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} + steps: + - name: Checkout the released SHA + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - name: Install .NET + uses: codebeltnet/install-dotnet@v3 + + - name: Install .NET Tool - Report Generator + uses: codebeltnet/dotnet-tool-install-reportgenerator@v1 + + - name: Spin up SQL Server test dependency + uses: codebeltnet/docker-compose@v1 + with: + command: up + options: --wait + env: + SA_PASSWORD: ${{ secrets.SA_PASSWORD }} + + - name: Run SQL Server integration tests from the released SHA + uses: codebeltnet/dotnet-test@v4 + with: + projects: test/**/Cuemon.Data.SqlClient.Tests.csproj + configuration: Release + build: true + restore: true + build-switches: -p:SkipSignAssembly=true -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + env: + CONNECTIONSTRINGS__ADVENTUREWORKS: ${{ secrets.DB_ADVENTUREWORKS }} + + - name: Upload SQL Server test results and coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: TestResults-Release-SqlServer + path: ${{ runner.temp }}/TestResults + if-no-files-found: warn + include-hidden-files: true + retention-days: 30 + + - name: Take down SQL Server test dependency + if: always() + uses: codebeltnet/docker-compose@v1 + with: + command: down + + post_release_sonarcloud: + if: ${{ always() && needs.publish_nuget.result == 'success' }} + name: Post-release SonarCloud analysis + needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] + runs-on: ubuntu-26.04 + timeout-minutes: 45 + permissions: + contents: read + env: + MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} + steps: + - name: Checkout the released SHA as main analysis + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - name: Install .NET + uses: codebeltnet/install-dotnet@v3 + + - name: Install .NET Tool - Sonar Scanner + uses: codebeltnet/dotnet-tool-install-sonarscanner@v3 + + - name: Restore released source dependencies + uses: codebeltnet/dotnet-restore@v3 + + - name: Begin SonarCloud analysis for the released version + uses: codebeltnet/sonarcloud-scan@v2 + with: + token: ${{ secrets.SONAR_TOKEN }} + organization: geekle + projectKey: Cuemon + version: ${{ needs.release_preflight.outputs.version }} + + - name: Build released source for SonarCloud + uses: codebeltnet/dotnet-build@v4 + with: + configuration: Release + build-switches: -p:SkipSignAssembly=true -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + + - name: Finalize SonarCloud analysis + uses: codebeltnet/sonarcloud-scan-finalize@v1 + with: + token: ${{ secrets.SONAR_TOKEN }} + + post_release_codecov: + if: ${{ always() && needs.publish_nuget.result == 'success' }} + name: Post-release Codecov upload + needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] + permissions: + contents: read + uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@v1 + with: + repository: codebeltnet/cuemon + configuration: .github/codecov.yml + ref: ${{ needs.release_preflight.outputs.sha }} + secrets: inherit + + post_release_codeql: + if: ${{ always() && needs.publish_nuget.result == 'success' }} + name: Post-release CodeQL analysis + needs: [release_preflight, publish_nuget, prepare_release_tests, post_release_tests, post_release_integration_test] + runs-on: ubuntu-26.04 + timeout-minutes: 45 + permissions: + contents: read + security-events: write + env: + MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} + steps: + - name: Checkout the released SHA as main analysis + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - name: Install .NET + uses: codebeltnet/install-dotnet@v3 + + - name: Restore released source dependencies + uses: codebeltnet/dotnet-restore@v3 + + - name: Begin CodeQL analysis + uses: codebeltnet/codeql-scan@v1 + + - name: Build released source for CodeQL + uses: codebeltnet/dotnet-build@v4 + with: + configuration: Release + build-switches: -p:SkipSignAssembly=true -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + + - name: Finalize CodeQL analysis + uses: codebeltnet/codeql-scan-finalize@v1 + + release_summary: + if: ${{ always() }} + name: Release and post-release status + needs: + - release_preflight + - release_packages + - publish_nuget + - tag_and_draft_release + - docfx_oci_build + - upload_docfx_release_asset + - publish_github_release + - prepare_release_tests + - post_release_tests + - post_release_integration_test + - post_release_sonarcloud + - post_release_codecov + - post_release_codeql + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - name: Report release and assurance status + shell: bash + env: + PREFLIGHT_RESULT: ${{ needs.release_preflight.result }} + RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} + RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} + PACKAGE_BUILD_RESULT: ${{ needs.release_packages.result }} + NUGET_RESULT: ${{ needs.publish_nuget.result }} + TAG_AND_DRAFT_RESULT: ${{ needs.tag_and_draft_release.result }} + DOCFX_BUILD_RESULT: ${{ needs.docfx_oci_build.result }} + DOCFX_ASSET_RESULT: ${{ needs.upload_docfx_release_asset.result }} + GITHUB_RELEASE_RESULT: ${{ needs.publish_github_release.result }} + TEST_DISCOVERY_RESULT: ${{ needs.prepare_release_tests.result }} + TEST_RESULT: ${{ needs.post_release_tests.result }} + INTEGRATION_RESULT: ${{ needs.post_release_integration_test.result }} + SONAR_RESULT: ${{ needs.post_release_sonarcloud.result }} + CODECOV_RESULT: ${{ needs.post_release_codecov.result }} + CODEQL_RESULT: ${{ needs.post_release_codeql.result }} + run: | + set -euo pipefail + + { + echo "## Cuemon release status" + echo + echo "- Version: `${RELEASE_VERSION:-not validated}`" + echo "- Released SHA: `${RELEASE_SHA:-not validated}`" + echo "- Release package build: `$PACKAGE_BUILD_RESULT`" + echo "- NuGet publication: `$NUGET_RESULT`" + echo "- Git tag and draft release: `$TAG_AND_DRAFT_RESULT`" + echo "- DocFX OCI build: `$DOCFX_BUILD_RESULT`" + echo "- DocFX release asset: `$DOCFX_ASSET_RESULT`" + echo "- Published GitHub Release: `$GITHUB_RELEASE_RESULT`" + echo + } >> "$GITHUB_STEP_SUMMARY" + + if [[ "$PREFLIGHT_RESULT" != "success" ]]; then + { + echo "No release was attempted because the main SHA or human-supplied SemVer request failed preflight validation." + } >> "$GITHUB_STEP_SUMMARY" + elif [[ "$PACKAGE_BUILD_RESULT" != "success" ]]; then + { + echo "NuGet publication did not start because the Release package build or package validation failed." + } >> "$GITHUB_STEP_SUMMARY" + elif [[ "$NUGET_RESULT" == "skipped" ]]; then + { + echo "NuGet publication did not start. Check the protected Production environment decision or workflow dependency state." + } >> "$GITHUB_STEP_SUMMARY" + elif [[ "$NUGET_RESULT" != "success" ]]; then + { + echo "NuGet publication did not complete. If the sequential push started, it may already have published some packages. Re-run the failed NuGet publication job on this workflow run to reuse the validated artifact; do not start a fresh run with the same version unless the packages' SHA-512 values match." + } >> "$GITHUB_STEP_SUMMARY" + elif [[ "$TAG_AND_DRAFT_RESULT" != "success" ]]; then + { + echo "NuGet packages were published, but Git tag/GitHub Release finalization did not complete. Check whether the tag or draft exists before retrying; GitHub and NuGet publication cannot be committed transactionally." + } >> "$GITHUB_STEP_SUMMARY" + elif [[ "$DOCFX_BUILD_RESULT" != "success" || "$DOCFX_ASSET_RESULT" != "success" ]]; then + { + echo "NuGet packages and the source tag were published, but the DocFX OCI artifact or release asset upload failed. GitHub Release publication waits for the verified OCI archive and checksum, so the draft remains unpublished." + } >> "$GITHUB_STEP_SUMMARY" + elif [[ "$GITHUB_RELEASE_RESULT" != "success" ]]; then + { + echo "NuGet packages, the source tag and the OCI release assets are ready, but GitHub Release publication failed or could not be confirmed. Check whether the release is still a draft before retrying the idempotent publish job." + } >> "$GITHUB_STEP_SUMMARY" + fi + + if [[ "$NUGET_RESULT" == "success" && \ + ( "$TEST_DISCOVERY_RESULT" != "success" || "$TEST_RESULT" != "success" || \ + "$INTEGRATION_RESULT" != "success" || "$SONAR_RESULT" != "success" || \ + "$CODECOV_RESULT" != "success" || "$CODEQL_RESULT" != "success" ) ]]; then + echo "::warning::Product release succeeded; post-release assurance failed." + { + echo + echo "**Product release succeeded; post-release assurance failed.**" + echo + echo "Post-release test discovery: `$TEST_DISCOVERY_RESULT`; test matrix: `$TEST_RESULT`; SQL Server integration: `$INTEGRATION_RESULT`; SonarCloud: `$SONAR_RESULT`; Codecov: `$CODECOV_RESULT`; CodeQL: `$CODEQL_RESULT`." + echo "The package and tag remain released. These findings do not roll back publication; resolve them against the recorded SHA." + } >> "$GITHUB_STEP_SUMMARY" + fi + + From 8a4d295497b27d6dfca2afc992f5962310d5ab7c Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 18:23:05 +0200 Subject: [PATCH 22/29] =?UTF-8?q?=F0=9F=91=B7=20switch=20release=20automat?= =?UTF-8?q?ion=20to=20validated=20version=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger releases from version tags and validate their identity and main history before publishing. This keeps the released commit tied to the tag that initiated the workflow. --- .github/workflows/release.yml | 184 ++++++++++++++++------------------ 1 file changed, 86 insertions(+), 98 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbcd98e8..8b238d9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,22 +1,19 @@ name: Release Flow on: - workflow_dispatch: - inputs: - version: - type: string - description: SemVer 2.0 release version without the v prefix. Use x.y.z or x.y.z-prerelease; build metadata is not supported. - required: true + push: + tags: + - 'v*' permissions: contents: read concurrency: - group: cuemon-release-${{ inputs.version }} + group: cuemon-release-${{ github.ref }} cancel-in-progress: false jobs: release_preflight: - name: Validate main SHA and release version + name: Validate release tag and authoritative main history runs-on: ubuntu-26.04 permissions: contents: read @@ -25,7 +22,8 @@ jobs: tag: ${{ steps.validate.outputs.tag }} sha: ${{ steps.validate.outputs.sha }} steps: - - name: Checkout the dispatch SHA + # git-checkout fetches full history and tags for MinVer and ancestry validation. + - name: Checkout the triggering SHA uses: codebeltnet/git-checkout@v1 with: ref: ${{ github.sha }} @@ -36,66 +34,83 @@ jobs: env: RELEASE_REF: ${{ github.ref }} RELEASE_SHA: ${{ github.sha }} - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ github.ref_name }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "::error::Release dispatch must target refs/heads/main; received '$RELEASE_REF'." + if [[ "$RELEASE_REF" != "refs/tags/$RELEASE_TAG" ]]; then + echo "::error::Release must be triggered by a Git tag; received '$RELEASE_REF'." + exit 1 + fi + if [[ "$RELEASE_TAG" != v* ]]; then + echo "::error::Release tag '$RELEASE_TAG' must have the v prefix (for example, v10.7.2)." exit 1 fi + RELEASE_VERSION="${RELEASE_TAG#v}" semver_regex='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$' if [[ ! "$RELEASE_VERSION" =~ $semver_regex ]]; then echo "::error::'$RELEASE_VERSION' is not a supported SemVer release version. Use x.y.z or x.y.z-prerelease without build metadata." exit 1 fi + if ! tagged_sha="$(git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}" 2>/dev/null)"; then + echo "::error::Triggering tag '$RELEASE_TAG' is missing or does not resolve to a Git commit." + exit 1 + fi + if ! event_sha="$(git rev-parse --verify "$RELEASE_SHA^{commit}" 2>/dev/null)"; then + echo "::error::Triggering SHA '$RELEASE_SHA' does not resolve to a Git commit." + exit 1 + fi checked_out_sha="$(git rev-parse HEAD)" - if [[ "$checked_out_sha" != "$RELEASE_SHA" ]]; then - echo "::error::Checkout SHA '$checked_out_sha' does not match dispatch SHA '$RELEASE_SHA'." + if [[ "$tagged_sha" != "$event_sha" || "$checked_out_sha" != "$event_sha" ]]; then + echo "::error::Tag '$RELEASE_TAG' resolves to '$tagged_sha', checkout to '$checked_out_sha', and triggering commit to '$event_sha'. The tag may have moved; restore the original tag before retrying." exit 1 fi + RELEASE_SHA="$tagged_sha" - release_tag="v$RELEASE_VERSION" - assert_absent() { - local endpoint="$1" - local label="$2" - local response - local status - - set +e - response="$(gh api "$endpoint" 2>&1)" - status=$? - set -e + if ! git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main'; then + echo "::error::Could not fetch authoritative main history to validate '$RELEASE_TAG'." + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then + echo "::error::Tagged commit '$RELEASE_SHA' is not reachable from authoritative main. Tag a commit in main history." + exit 1 + fi - if [[ "$status" -eq 0 ]]; then - echo "::error::$label '$release_tag' already exists. Select a new human-approved SemVer version." - exit 1 - fi - if [[ "$response" != *"HTTP 404"* ]]; then - echo "::error::Could not verify that $label '$release_tag' is unused." - echo "$response" + set +e + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" 2>&1)" + release_status=$? + set -e + if [[ "$release_status" -eq 0 ]]; then + expected_prerelease=false + if [[ "$RELEASE_VERSION" == *-* ]]; then expected_prerelease=true; fi + if ! jq -e --arg tag "$RELEASE_TAG" --argjson prerelease "$expected_prerelease" \ + '.tag_name == $tag and .prerelease == $prerelease and (.draft | type == "boolean")' <<< "$release_json" > /dev/null; then + echo "::error::Existing GitHub Release conflicts with tag '$RELEASE_TAG' or its prerelease identity. Review the existing release before retrying." exit 1 fi - } - - assert_absent "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" "Git tag" - assert_absent "repos/$GITHUB_REPOSITORY/releases/tags/$release_tag" "GitHub Release" + echo "Compatible GitHub Release '$RELEASE_TAG' already exists; retaining its draft/published state." + elif [[ "$release_json" != *"HTTP 404"* ]]; then + echo "::error::Could not check GitHub Release '$RELEASE_TAG'." + echo "$release_json" + exit 1 + fi { echo "version=$RELEASE_VERSION" - echo "tag=$release_tag" + echo "tag=$RELEASE_TAG" echo "sha=$RELEASE_SHA" } >> "$GITHUB_OUTPUT" { echo "## Release request validated" echo - echo "- Version: `$RELEASE_VERSION`" - echo "- Source: `main` at `$RELEASE_SHA`" - echo "- Tag: `$release_tag`" + printf -- '- Tag: `%s`\n' "$RELEASE_TAG" + printf -- '- Version: `%s`\n' "$RELEASE_VERSION" + echo '- Source: main' + printf -- '- Released SHA: `%s`\n' "$RELEASE_SHA" } >> "$GITHUB_STEP_SUMMARY" release_packages: @@ -105,8 +120,6 @@ jobs: timeout-minutes: 45 permissions: contents: read - env: - MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} steps: - name: Checkout the released SHA uses: codebeltnet/git-checkout@v1 @@ -139,7 +152,6 @@ jobs: uses: codebeltnet/dotnet-build@v4 with: configuration: Release - build-switches: -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} - name: Pack Release packages uses: codebeltnet/dotnet-pack@v3 @@ -176,8 +188,8 @@ jobs: secrets: NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} - tag_and_draft_release: - name: Tag released SHA and create draft GitHub Release + draft_github_release: + name: Create draft GitHub Release for the existing tag needs: [release_preflight, release_packages, publish_nuget] runs-on: ubuntu-26.04 permissions: @@ -188,37 +200,19 @@ jobs: with: ref: ${{ needs.release_preflight.outputs.sha }} - - name: Create or verify the version tag + - name: Verify the existing release tag still identifies the released SHA shell: bash env: - RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} run: | set -euo pipefail - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - set +e - git ls-remote --exit-code origin "refs/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release-tag-ref.txt" - tag_status=$? - set -e - - if [[ "$tag_status" -eq 0 ]]; then - git fetch --force --quiet origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" - existing_sha="$(git rev-parse "$RELEASE_TAG^{commit}")" - if [[ "$existing_sha" != "$RELEASE_SHA" ]]; then - echo "::error::Tag '$RELEASE_TAG' points to '$existing_sha', not released SHA '$RELEASE_SHA'." - exit 1 - fi - echo "Tag '$RELEASE_TAG' already points to the released SHA." - elif [[ "$tag_status" -eq 2 ]]; then - git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "Cuemon $RELEASE_TAG" - git push origin "refs/tags/$RELEASE_TAG" - else - echo "::error::Could not check whether tag '$RELEASE_TAG' exists (git ls-remote exit code $tag_status)." - cat "$RUNNER_TEMP/release-tag-ref.txt" + if ! tagged_sha="$(git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}" 2>/dev/null)"; then + echo "::error::Release tag '$RELEASE_TAG' is missing or does not resolve to a commit; restore the original tag before retrying." + exit 1 + fi + if [[ "$tagged_sha" != "$RELEASE_SHA" ]]; then + echo "::error::Tag '$RELEASE_TAG' points to '$tagged_sha', not released SHA '$RELEASE_SHA'. Restore the original tag before retrying." exit 1 fi @@ -312,7 +306,7 @@ jobs: upload_docfx_release_asset: name: Attach the built DocFX OCI artifact to its release - needs: [release_preflight, tag_and_draft_release, docfx_oci_build] + needs: [release_preflight, draft_github_release, docfx_oci_build] runs-on: ubuntu-26.04 permissions: contents: write @@ -444,8 +438,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - env: - MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} steps: - name: Checkout the released SHA uses: codebeltnet/git-checkout@v1 @@ -465,7 +457,7 @@ jobs: configuration: Release build: true restore: true - build-switches: -p:SkipSignAssembly=true -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + build-switches: -p:SkipSignAssembly=true - name: Upload post-release test results and coverage if: always() @@ -484,8 +476,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - env: - MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} steps: - name: Checkout the released SHA uses: codebeltnet/git-checkout@v1 @@ -513,7 +503,7 @@ jobs: configuration: Release build: true restore: true - build-switches: -p:SkipSignAssembly=true -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + build-switches: -p:SkipSignAssembly=true env: CONNECTIONSTRINGS__ADVENTUREWORKS: ${{ secrets.DB_ADVENTUREWORKS }} @@ -541,8 +531,6 @@ jobs: timeout-minutes: 45 permissions: contents: read - env: - MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} steps: - name: Checkout the released SHA as main analysis uses: codebeltnet/git-checkout@v1 @@ -570,7 +558,7 @@ jobs: uses: codebeltnet/dotnet-build@v4 with: configuration: Release - build-switches: -p:SkipSignAssembly=true -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + build-switches: -p:SkipSignAssembly=true - name: Finalize SonarCloud analysis uses: codebeltnet/sonarcloud-scan-finalize@v1 @@ -599,8 +587,6 @@ jobs: permissions: contents: read security-events: write - env: - MINVERVERSIONOVERRIDE: ${{ needs.release_preflight.outputs.version }} steps: - name: Checkout the released SHA as main analysis uses: codebeltnet/git-checkout@v1 @@ -620,7 +606,7 @@ jobs: uses: codebeltnet/dotnet-build@v4 with: configuration: Release - build-switches: -p:SkipSignAssembly=true -p:MinVerVersionOverride=${{ needs.release_preflight.outputs.version }} + build-switches: -p:SkipSignAssembly=true - name: Finalize CodeQL analysis uses: codebeltnet/codeql-scan-finalize@v1 @@ -632,7 +618,7 @@ jobs: - release_preflight - release_packages - publish_nuget - - tag_and_draft_release + - draft_github_release - docfx_oci_build - upload_docfx_release_asset - publish_github_release @@ -652,9 +638,10 @@ jobs: PREFLIGHT_RESULT: ${{ needs.release_preflight.result }} RELEASE_VERSION: ${{ needs.release_preflight.outputs.version }} RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} + RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} PACKAGE_BUILD_RESULT: ${{ needs.release_packages.result }} NUGET_RESULT: ${{ needs.publish_nuget.result }} - TAG_AND_DRAFT_RESULT: ${{ needs.tag_and_draft_release.result }} + DRAFT_RELEASE_RESULT: ${{ needs.draft_github_release.result }} DOCFX_BUILD_RESULT: ${{ needs.docfx_oci_build.result }} DOCFX_ASSET_RESULT: ${{ needs.upload_docfx_release_asset.result }} GITHUB_RELEASE_RESULT: ${{ needs.publish_github_release.result }} @@ -670,20 +657,21 @@ jobs: { echo "## Cuemon release status" echo - echo "- Version: `${RELEASE_VERSION:-not validated}`" - echo "- Released SHA: `${RELEASE_SHA:-not validated}`" - echo "- Release package build: `$PACKAGE_BUILD_RESULT`" - echo "- NuGet publication: `$NUGET_RESULT`" - echo "- Git tag and draft release: `$TAG_AND_DRAFT_RESULT`" - echo "- DocFX OCI build: `$DOCFX_BUILD_RESULT`" - echo "- DocFX release asset: `$DOCFX_ASSET_RESULT`" - echo "- Published GitHub Release: `$GITHUB_RELEASE_RESULT`" + echo "- Tag: \`${RELEASE_TAG:-not validated}\`" + echo "- Version: \`${RELEASE_VERSION:-not validated}\`" + echo "- Released SHA: \`${RELEASE_SHA:-not validated}\`" + echo "- Release package build: \`$PACKAGE_BUILD_RESULT\`" + echo "- NuGet publication: \`$NUGET_RESULT\`" + echo "- Draft GitHub Release: \`$DRAFT_RELEASE_RESULT\`" + echo "- DocFX OCI build: \`$DOCFX_BUILD_RESULT\`" + echo "- DocFX release asset: \`$DOCFX_ASSET_RESULT\`" + echo "- Published GitHub Release: \`$GITHUB_RELEASE_RESULT\`" echo } >> "$GITHUB_STEP_SUMMARY" if [[ "$PREFLIGHT_RESULT" != "success" ]]; then { - echo "No release was attempted because the main SHA or human-supplied SemVer request failed preflight validation." + echo "No release was attempted because the triggering tag, SemVer, main ancestry or existing release identity failed preflight validation." } >> "$GITHUB_STEP_SUMMARY" elif [[ "$PACKAGE_BUILD_RESULT" != "success" ]]; then { @@ -697,13 +685,13 @@ jobs: { echo "NuGet publication did not complete. If the sequential push started, it may already have published some packages. Re-run the failed NuGet publication job on this workflow run to reuse the validated artifact; do not start a fresh run with the same version unless the packages' SHA-512 values match." } >> "$GITHUB_STEP_SUMMARY" - elif [[ "$TAG_AND_DRAFT_RESULT" != "success" ]]; then + elif [[ "$DRAFT_RELEASE_RESULT" != "success" ]]; then { - echo "NuGet packages were published, but Git tag/GitHub Release finalization did not complete. Check whether the tag or draft exists before retrying; GitHub and NuGet publication cannot be committed transactionally." + echo "NuGet packages were published, but draft GitHub Release creation did not complete. Check the existing tag and release before retrying; GitHub and NuGet publication cannot be committed transactionally." } >> "$GITHUB_STEP_SUMMARY" elif [[ "$DOCFX_BUILD_RESULT" != "success" || "$DOCFX_ASSET_RESULT" != "success" ]]; then { - echo "NuGet packages and the source tag were published, but the DocFX OCI artifact or release asset upload failed. GitHub Release publication waits for the verified OCI archive and checksum, so the draft remains unpublished." + echo "NuGet packages were published for the existing source tag, but the DocFX OCI artifact or release asset upload failed. GitHub Release publication waits for the verified OCI archive and checksum, so the draft remains unpublished." } >> "$GITHUB_STEP_SUMMARY" elif [[ "$GITHUB_RELEASE_RESULT" != "success" ]]; then { @@ -720,7 +708,7 @@ jobs: echo echo "**Product release succeeded; post-release assurance failed.**" echo - echo "Post-release test discovery: `$TEST_DISCOVERY_RESULT`; test matrix: `$TEST_RESULT`; SQL Server integration: `$INTEGRATION_RESULT`; SonarCloud: `$SONAR_RESULT`; Codecov: `$CODECOV_RESULT`; CodeQL: `$CODEQL_RESULT`." + echo "Post-release test discovery: \`$TEST_DISCOVERY_RESULT\`; test matrix: \`$TEST_RESULT\`; SQL Server integration: \`$INTEGRATION_RESULT\`; SonarCloud: \`$SONAR_RESULT\`; Codecov: \`$CODECOV_RESULT\`; CodeQL: \`$CODEQL_RESULT\`." echo "The package and tag remain released. These findings do not roll back publication; resolve them against the recorded SHA." } >> "$GITHUB_STEP_SUMMARY" fi From 83de72a48d2a00a326fd1920473014b97adcbda3 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 18:56:02 +0200 Subject: [PATCH 23/29] =?UTF-8?q?=F0=9F=91=B7=20add=20published=20release?= =?UTF-8?q?=20deployment=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate the published release assets and resolve the tag to its source commit before promoting the DocFX image. This ties the registry artifact to the verified release identity. --- .github/workflows/deploy.yml | 172 +++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..f77a26bb --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,172 @@ +name: Deploy Flow +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + type: string + description: Existing published GitHub Release tag to deploy, e.g. v10.7.2. + required: true + +permissions: + contents: read + +concurrency: + group: cuemon-deploy-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + resolve_release: + name: Resolve published release and authoritative SHA + runs-on: ubuntu-26.04 + permissions: + contents: read + outputs: + version: ${{ steps.resolve.outputs.version }} + tag: ${{ steps.resolve.outputs.tag }} + sha: ${{ steps.resolve.outputs.sha }} + steps: + - id: resolve + name: Validate release identity and resolve the released commit + shell: bash + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW_REF: ${{ github.ref }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + set -euo pipefail + + if [[ "$GITHUB_EVENT_NAME" == "release" ]]; then + if ! jq -e --arg tag "$RELEASE_TAG" '.action == "published" and .release.draft == false and .release.tag_name == $tag' "$GITHUB_EVENT_PATH" >/dev/null; then + echo "::error::Deployment requires a published, non-draft GitHub Release matching '$RELEASE_TAG'." + exit 1 + fi + elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$WORKFLOW_REF" != "refs/heads/main" ]]; then + echo "::error::Deployment dispatch must target refs/heads/main; received '$WORKFLOW_REF'." + exit 1 + fi + else + echo "::error::Unsupported deployment event '$GITHUB_EVENT_NAME'." + exit 1 + fi + + if [[ "$RELEASE_TAG" != v* ]]; then + echo "::error::Release tag '$RELEASE_TAG' must have the v prefix (for example, v10.7.2)." + exit 1 + fi + + RELEASE_VERSION="${RELEASE_TAG#v}" + semver_regex='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$' + if [[ ! "$RELEASE_VERSION" =~ $semver_regex ]]; then + echo "::error::'$RELEASE_VERSION' is not a supported SemVer release version." + exit 1 + fi + + release_tag="$RELEASE_TAG" + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$release_tag")" + if ! jq -e --arg tag "$release_tag" '.tag_name == $tag and .draft == false and (.published_at | type == "string")' <<< "$release_json" >/dev/null; then + echo "::error::GitHub Release '$release_tag' is missing, has a different tag, or is not published." + exit 1 + fi + + archive_name="cuemon-docfx-$RELEASE_VERSION.oci.tar" + checksum_name="$archive_name.sha256" + if ! jq -e --arg archive "$archive_name" --arg checksum "$checksum_name" \ + 'any(.assets[]; .name == $archive and .state == "uploaded") and any(.assets[]; .name == $checksum and .state == "uploaded")' <<< "$release_json" >/dev/null; then + echo "::error::GitHub Release '$release_tag' must contain the immutable OCI archive '$archive_name' and checksum '$checksum_name'." + exit 1 + fi + + ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag")" + object_type="$(jq -r '.object.type' <<< "$ref_json")" + object_sha="$(jq -r '.object.sha' <<< "$ref_json")" + if [[ "$object_type" == "tag" ]]; then + tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object_sha")" + object_type="$(jq -r '.object.type' <<< "$tag_json")" + object_sha="$(jq -r '.object.sha' <<< "$tag_json")" + fi + + if [[ "$object_type" != "commit" || ! "$object_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Release tag '$release_tag' does not resolve to a Git commit." + exit 1 + fi + + { + echo "version=$RELEASE_VERSION" + echo "tag=$release_tag" + echo "sha=$object_sha" + } >> "$GITHUB_OUTPUT" + + { + echo "## DocFX promotion request validated" + echo + echo "- Version: $RELEASE_VERSION" + echo "- Release tag: $release_tag" + echo "- Released SHA: $object_sha" + } >> "$GITHUB_STEP_SUMMARY" + + promote_docfx_image: + name: Promote the released DocFX OCI image to JCR + needs: [resolve_release] + runs-on: ubuntu-26.04 + timeout-minutes: 30 + environment: Production + permissions: + contents: read + steps: + - name: Download the immutable OCI assets from the GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.resolve_release.outputs.version }} + RELEASE_TAG: ${{ needs.resolve_release.outputs.tag }} + run: | + set -euo pipefail + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + if ! jq -e --arg tag "$RELEASE_TAG" '.tag_name == $tag and .draft == false and (.published_at | type == "string")' <<< "$release_json" >/dev/null; then + echo "::error::GitHub Release '$RELEASE_TAG' is no longer published." + exit 1 + fi + + artifact_directory="$RUNNER_TEMP/docfx-release-asset" + mkdir -p "$artifact_directory" + archive_name="cuemon-docfx-$RELEASE_VERSION.oci.tar" + checksum_name="$archive_name.sha256" + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$archive_name" --dir "$artifact_directory" + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$checksum_name" --dir "$artifact_directory" + + - id: publish + name: Promote the verified OCI artifact to JCR and confirm its digest + uses: codebeltnet/oci-artifact-publish@v1 + with: + archive-path: ${{ runner.temp }}/docfx-release-asset/cuemon-docfx-${{ needs.resolve_release.outputs.version }}.oci.tar + checksum-path: ${{ runner.temp }}/docfx-release-asset/cuemon-docfx-${{ needs.resolve_release.outputs.version }}.oci.tar.sha256 + version: ${{ needs.resolve_release.outputs.version }} + revision: ${{ needs.resolve_release.outputs.sha }} + repository: jcr.codebelt.net/geekle/cuemon-docfx + username: ${{ secrets.JCR_USERNAME }} + password: ${{ secrets.JCR_PASSWORD }} + + - name: Record the immutable JCR identity and Kubernetes handoff + shell: bash + env: + RELEASE_VERSION: ${{ needs.resolve_release.outputs.version }} + RELEASE_SHA: ${{ needs.resolve_release.outputs.sha }} + IMAGE: ${{ steps.publish.outputs.image }} + DIGEST: ${{ steps.publish.outputs.digest }} + IMAGE_BY_DIGEST: ${{ steps.publish.outputs.image-by-digest }} + run: | + set -euo pipefail + { + echo "## DocFX image promoted" + echo + echo "- Release: $RELEASE_VERSION" + echo "- Released SHA: $RELEASE_SHA" + echo "- Registry tag: $IMAGE" + echo "- Immutable registry digest: $DIGEST" + echo "- Kubernetes-ready image reference: $IMAGE_BY_DIGEST" + echo + echo "JCR publication succeeded. This POC stops at the immutable registry reference; Kubernetes rollout configuration is not present in this repository." + } >> "$GITHUB_STEP_SUMMARY" From 1950469d1ada96720223420b246976a179ac645f Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 21:43:37 +0200 Subject: [PATCH 24/29] =?UTF-8?q?=F0=9F=92=AC=20update=2010.8.0=20changelo?= =?UTF-8?q?g=20with=20release=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10.8.0 entry described workflows that no longer match the repository, so this corrects the summary to cover the tag-validated release workflow, DocFX image deployment, the pull-request pipeline rename, and the test timing fixes, and drops the stale Unreleased link now that the version is cut. --- CHANGELOG.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c634ec..2e702c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,25 +8,26 @@ For more details, please refer to `PackageReleaseNotes.txt` on a per assembly ba ## [10.8.0] - 2026-10-03 -This is a minor release adding Parameter Object integration with Microsoft Options, improving disposal cleanup, and separating pull request validation, package publication, and documentation deployment. +This is a minor release adding Parameter Object integration with Microsoft Options, tag-validated package releases, and deployment of released DocFX images, alongside disposal cleanup and test timing fixes. ### Added -- `IServiceCollection.AddConfiguredOptions()` connects Parameter Object post-configuration and validation to Microsoft Options, exposes the primary `Action`, and registers the cached default options instance for direct injection, -- Dedicated pull request, release, and deployment workflows validate contributions, publish NuGet packages with post-release assurance, and promote the released DocFX image. +- `IServiceCollection.AddConfiguredOptions()` connects Parameter Object post-configuration and validation to Microsoft Options, exposes the primary `Action`, and registers the cached default options instance for direct injection; recoverable validation failures become `OptionsValidationException`, while cancellation propagates to the caller, +- A dedicated release workflow validates version tags against `main` history, builds and publishes NuGet packages from the tagged commit, runs post-release assurance, and attaches a verified multi-platform DocFX OCI archive and checksum before publishing the GitHub Release, +- A deployment workflow validates published release assets and the tagged source commit, then promotes the released DocFX OCI image to JCR without rebuilding it, +- A documented `.bot` workspace for local AI working material, with its contents ignored except for `README.md`. ### Changed -- Updated `Codebelt.Coverlet.MTP` from 10.0.1 to 10.1.0 and `Microsoft.Data.SqlClient` from 7.1.0 to 7.1.1, -- Updated CI references and guidance for the pull request workflow, `main` Codecov branch, and Ubuntu 26.04 runners. +- Moved `.github/workflows/ci-pipeline.yml` to `pr.yml`, removed package publication from PR validation, and added an aggregate quality gate that enforces required checks and the privileged-integration policy, +- Updated `Codebelt.Coverlet.MTP` from 10.0.1 to 10.1.0 and `Microsoft.Data.SqlClient` from 7.1.0 to 7.1.1 for .NET 9 and .NET 10, +- Updated Linux workflow runners to Ubuntu 26.04, CI references and contributor guidance to the dedicated workflows, and the Codecov badge to `main`, +- Expanded dependency injection tests for service lifetimes, forwarding, duplicate registrations, argument validation, and post-configuration. ### Fixed -- `Disposable.Dispose` now runs unmanaged cleanup when managed cleanup throws while preserving the managed cleanup exception and idempotent disposal. - -### Removed - -- Removed the combined `.github/workflows/ci-pipeline.yml` workflow as pull request, release, and deployment responsibilities moved to dedicated workflows. +- `Disposable.Dispose` now runs unmanaged cleanup even when managed cleanup throws and retains idempotent disposal, +- Signed URI tests now derive validity windows from one UTC timestamp, and retry behavior tests apply the strict latency budget only in dedicated latency scenarios. ## [10.7.1] - 2026-09-09 @@ -1919,7 +1920,6 @@ This release was primarily focused on adapting a more modern way of performing C - XmlWriterUtility class from Cuemon.Xml namespace - XmlWriterUtilityExtensions class from the Cuemon.Xml namespace -[Unreleased]: https://github.com/codebeltnet/cuemon/compare/v10.8.0...HEAD [10.8.0]: https://github.com/codebeltnet/cuemon/compare/v10.7.1...v10.8.0 [10.7.1]: https://github.com/codebeltnet/cuemon/compare/v10.7.0...v10.7.1 [10.7.0]: https://github.com/codebeltnet/cuemon/compare/v10.6.0...v10.7.0 From 79f15884b97478c405265e2f76fa772d4368edf3 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 22:07:13 +0200 Subject: [PATCH 25/29] =?UTF-8?q?=F0=9F=90=9B=20preserve=20exceptions=20fr?= =?UTF-8?q?om=20both=20disposal=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When managed and unmanaged cleanup both fail, preserve both exceptions while still invoking each cleanup hook once. --- src/Cuemon.Kernel/Disposable.cs | 13 ++++- test/Cuemon.Kernel.Tests/DisposableTest.cs | 61 +++++++++++++++++++--- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/Cuemon.Kernel/Disposable.cs b/src/Cuemon.Kernel/Disposable.cs index 73a72812..9a5d0137 100644 --- a/src/Cuemon.Kernel/Disposable.cs +++ b/src/Cuemon.Kernel/Disposable.cs @@ -58,10 +58,19 @@ protected void Dispose(bool disposing) OnDisposeManagedResources(); } } - finally + catch (Exception managedException) { - OnDisposeUnmanagedResources(); + try + { + OnDisposeUnmanagedResources(); + } + catch (Exception unmanagedException) + { + throw new AggregateException(managedException, unmanagedException); + } + throw; } + OnDisposeUnmanagedResources(); } } } diff --git a/test/Cuemon.Kernel.Tests/DisposableTest.cs b/test/Cuemon.Kernel.Tests/DisposableTest.cs index e19e67de..d6cf53e8 100644 --- a/test/Cuemon.Kernel.Tests/DisposableTest.cs +++ b/test/Cuemon.Kernel.Tests/DisposableTest.cs @@ -54,7 +54,7 @@ public void Dispose_ShouldInvokeManagedAndUnmanagedResourcesWhenDisposingIsTrue( public void Dispose_ShouldReleaseUnmanagedResourcesAndPreserveException_WhenManagedCleanupThrows() { var exception = new InvalidOperationException("Managed cleanup failed."); - var sut = new ThrowingManagedDisposable(exception); + var sut = new ThrowingCleanupDisposable(exception); var actual = Assert.Throws(() => sut.Dispose()); @@ -69,6 +69,47 @@ public void Dispose_ShouldReleaseUnmanagedResourcesAndPreserveException_WhenMana Assert.Equal(1, sut.UnmanagedDisposeCount); } + [Fact] + public void Dispose_ShouldPreserveBothExceptions_WhenBothCleanupHooksThrow() + { + var managedException = new InvalidOperationException("Managed cleanup failed."); + var unmanagedException = new InvalidOperationException("Unmanaged cleanup failed."); + var sut = new ThrowingCleanupDisposable(managedException, unmanagedException); + + var actual = Assert.Throws(() => sut.Dispose()); + + Assert.Collection(actual.InnerExceptions, + exception => Assert.Same(managedException, exception), + exception => Assert.Same(unmanagedException, exception)); + Assert.True(sut.Disposed); + Assert.Equal(1, sut.ManagedDisposeCount); + Assert.Equal(1, sut.UnmanagedDisposeCount); + + sut.Dispose(); + + Assert.Equal(1, sut.ManagedDisposeCount); + Assert.Equal(1, sut.UnmanagedDisposeCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DisposeCore_ShouldPreserveException_WhenOnlyUnmanagedCleanupThrows(bool disposing) + { + var exception = new InvalidOperationException("Unmanaged cleanup failed."); + var sut = new ThrowingCleanupDisposable(null, exception); + + Assert.Same(exception, Assert.Throws(() => sut.DisposeCore(disposing))); + Assert.True(sut.Disposed); + Assert.Equal(disposing ? 1 : 0, sut.ManagedDisposeCount); + Assert.Equal(1, sut.UnmanagedDisposeCount); + + sut.DisposeCore(disposing); + + Assert.Equal(disposing ? 1 : 0, sut.ManagedDisposeCount); + Assert.Equal(1, sut.UnmanagedDisposeCount); + } + [Fact] public async Task Dispose_ShouldBeThreadSafeAndInvokeCallbacksOnce() { @@ -89,19 +130,27 @@ public async Task Dispose_ShouldBeThreadSafeAndInvokeCallbacksOnce() Assert.Equal(1, sut.UnmanagedDisposeCount); } - private sealed class ThrowingManagedDisposable : TrackingDisposable + private sealed class ThrowingCleanupDisposable : TrackingDisposable { - private readonly Exception _exception; + private readonly Exception _managedException; + private readonly Exception _unmanagedException; - public ThrowingManagedDisposable(Exception exception) + public ThrowingCleanupDisposable(Exception managedException, Exception unmanagedException = null) { - _exception = exception; + _managedException = managedException; + _unmanagedException = unmanagedException; } protected override void OnDisposeManagedResources() { base.OnDisposeManagedResources(); - throw _exception; + if (_managedException != null) { throw _managedException; } + } + + protected override void OnDisposeUnmanagedResources() + { + base.OnDisposeUnmanagedResources(); + if (_unmanagedException != null) { throw _unmanagedException; } } } } From 26d1890c9b3a5d5867b8d1a33f8847c3f7711679 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 22:08:09 +0200 Subject: [PATCH 26/29] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20verify=20the=20li?= =?UTF-8?q?ve=20release=20tag=20before=20publishing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing the draft must re-resolve the remote tag and match it to the SHA whose packages and image were validated. --- .github/workflows/release.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b238d9d..49094949 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -385,6 +385,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} run: | set -euo pipefail release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" @@ -399,6 +400,20 @@ jobs: exit 1 fi + # Resolve the live remote tag immediately before publication, not the earlier checkout. + ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" + object_type="$(jq -r '.object.type' <<< "$ref_json")" + object_sha="$(jq -r '.object.sha' <<< "$ref_json")" + while [[ "$object_type" == "tag" ]]; do + tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object_sha")" + object_type="$(jq -r '.object.type' <<< "$tag_json")" + object_sha="$(jq -r '.object.sha' <<< "$tag_json")" + done + if [[ "$object_type" != "commit" || ! "$object_sha" =~ ^[0-9a-fA-F]{40}$ || "$object_sha" != "$RELEASE_SHA" ]]; then + echo "::error::Release tag '$RELEASE_TAG' no longer identifies built commit '$RELEASE_SHA'; refusing to publish the draft." + exit 1 + fi + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" -F draft=false prepare_release_tests: From e7ef74328ab8a4e4a5f20e472071031fb878ffe3 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 22:08:37 +0200 Subject: [PATCH 27/29] =?UTF-8?q?=F0=9F=94=A7=20pass=20Codecov=20tokens=20?= =?UTF-8?q?explicitly=20to=20reusable=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable Codecov workflows now get the upload token through an explicit secret mapping, keeping other workflow secrets out of those calls. --- .github/workflows/pr.yml | 3 ++- .github/workflows/release.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2e8321e4..653fd1d4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -379,7 +379,8 @@ jobs: with: repository: codebeltnet/cuemon configuration: .github/codecov.yml - secrets: inherit + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} codeql: if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49094949..6e5789ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -591,7 +591,8 @@ jobs: repository: codebeltnet/cuemon configuration: .github/codecov.yml ref: ${{ needs.release_preflight.outputs.sha }} - secrets: inherit + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} post_release_codeql: if: ${{ always() && needs.publish_nuget.result == 'success' }} From eb5cd84499631d63f5566d99941cc1e73c3f63a9 Mon Sep 17 00:00:00 2001 From: aicia-bot Date: Sat, 3 Oct 2026 22:09:07 +0200 Subject: [PATCH 28/29] =?UTF-8?q?=F0=9F=92=AC=20document=20release=20and?= =?UTF-8?q?=20deployment=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This documents the tag-driven package release and verified image handoff, including how maintainers can retry safely after partial publication. --- .github/CONTRIBUTING.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6bd3d3b5..6756c67b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -15,8 +15,8 @@ This repository is part of the Codebelt .NET library estate. The instructions be - `test/` contains xUnit v3 test projects. - `Cuemon.slnx` is the solution used for local development. - `.github/workflows/pr.yml` owns the PR test matrix and blocking quality gates. -- `.github/workflows/release.yml` publishes packages from a human-versioned `main` SHA; post-release assurance and DocFX production run after NuGet publication. -- `.github/workflows/deploy.yml` promotes the published DocFX image without rebuilding it. See `.github/workflows/README.md` for the CI/CD handoff and release behavior. +- `.github/workflows/release.yml` publishes packages from a version-tagged commit in `main` history; post-release assurance and DocFX production run after NuGet publication. +- `.github/workflows/deploy.yml` promotes the published DocFX image without rebuilding it. See [Release and deployment](#release-and-deployment) for the CI/CD handoff. - `testenvironments.json` declares the supported `WSL-Ubuntu` and `Docker-Ubuntu` test environments. ## Build @@ -71,6 +71,16 @@ dotnet pack "Cuemon.slnx" --configuration Release --no-restore Package-specific release notes live under `.nuget//PackageReleaseNotes.txt` and package README files live beside them. `Directory.Build.targets` imports the release notes during packing. Public API changes also require XML documentation updates; DocFX documentation is built by the repository automation. +## Release and deployment + +After PR validation and merge, a maintainer creates and pushes a `vX.Y.Z` tag (or `vX.Y.Z-prerelease`, without build metadata) for the intended commit in `main` history. The tag push starts `release.yml`, which checks the tag identity and ancestry, builds signed Release packages from that commit, validates their versions and existing NuGet content, and sends the validated package artifact to the protected `Production` publication job. + +After NuGet publication, the workflow runs post-release tests and analysis and builds the multi-platform DocFX OCI image from the same commit. The verified archive and SHA-256 checksum are attached to a draft GitHub Release before that release is published. Post-release assurance failures do not roll back published packages; inspect the release summary and resolve failures against its recorded commit. + +A published GitHub Release starts `deploy.yml`. To retry deployment, dispatch that workflow from `main` with the existing published release tag. Deployment requires the versioned OCI archive and checksum, resolves the tag to its source commit, and promotes the verified image to JCR through `Production` without rebuilding it. The workflow reports the immutable image digest for a Kubernetes handoff; this repository does not perform the Kubernetes rollout. + +If publication fails, inspect the job results before retrying. A partially completed NuGet push may already have published some packages; rerun the failed publication job to reuse its validated artifact. Keep release tags fixed: publication rechecks the live tag against the built commit and rejects a mismatch. + ## Pull requests 1. Create or join an issue before substantial work, then fork the repository and create a branch from `main`. From fca2b1d3be17adaefe9f7ade33ede12ed8503a73 Mon Sep 17 00:00:00 2001 From: gimlichael Date: Sat, 3 Oct 2026 22:13:11 +0200 Subject: [PATCH 29/29] update cl --- CHANGELOG.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e702c4d..47596c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,25 +8,26 @@ For more details, please refer to `PackageReleaseNotes.txt` on a per assembly ba ## [10.8.0] - 2026-10-03 -This is a minor release adding Parameter Object integration with Microsoft Options, tag-validated package releases, and deployment of released DocFX images, alongside disposal cleanup and test timing fixes. +This is a minor release adding Parameter Object integration with Microsoft Options, validated package releases from version tags, and deployment of released DocFX images, alongside dependency updates, disposal exception handling, and test timing fixes. ### Added -- `IServiceCollection.AddConfiguredOptions()` connects Parameter Object post-configuration and validation to Microsoft Options, exposes the primary `Action`, and registers the cached default options instance for direct injection; recoverable validation failures become `OptionsValidationException`, while cancellation propagates to the caller, -- A dedicated release workflow validates version tags against `main` history, builds and publishes NuGet packages from the tagged commit, runs post-release assurance, and attaches a verified multi-platform DocFX OCI archive and checksum before publishing the GitHub Release, +- `IServiceCollection.AddConfiguredOptions()` connects Parameter Object post-configuration and validation to Microsoft Options for all options names, exposes the first registered configurator as `Action`, and registers the cached default options instance for direct injection; recoverable validation failures become `OptionsValidationException`, while cancellation propagates to the caller, +- A dedicated release workflow validates version tags against `main` history, builds and publishes NuGet packages from the tagged commit, runs post-release assurance, and attaches a verified multi-platform DocFX OCI archive and checksum; before publishing the draft GitHub Release, it rechecks that the live tag still identifies the validated commit, - A deployment workflow validates published release assets and the tagged source commit, then promotes the released DocFX OCI image to JCR without rebuilding it, - A documented `.bot` workspace for local AI working material, with its contents ignored except for `README.md`. ### Changed - Moved `.github/workflows/ci-pipeline.yml` to `pr.yml`, removed package publication from PR validation, and added an aggregate quality gate that enforces required checks and the privileged-integration policy, -- Updated `Codebelt.Coverlet.MTP` from 10.0.1 to 10.1.0 and `Microsoft.Data.SqlClient` from 7.1.0 to 7.1.1 for .NET 9 and .NET 10, -- Updated Linux workflow runners to Ubuntu 26.04, CI references and contributor guidance to the dedicated workflows, and the Codecov badge to `main`, +- Updated the test coverage dependency `Codebelt.Coverlet.MTP` from 10.0.1 to 10.1.0 and `Microsoft.Data.SqlClient` from 7.1.0 to 7.1.1 for .NET 9 and .NET 10, +- Updated Linux workflow runners to Ubuntu 26.04, CI references to the dedicated workflows, and the Codecov badge to `main`, +- Expanded contributor guidance for releases from version tags, verified DocFX image promotion, and retries after partial NuGet publication; configured the shared PR Codecov workflow to use `.github/codecov.yml`, - Expanded dependency injection tests for service lifetimes, forwarding, duplicate registrations, argument validation, and post-configuration. ### Fixed -- `Disposable.Dispose` now runs unmanaged cleanup even when managed cleanup throws and retains idempotent disposal, +- `Disposable.Dispose` now runs unmanaged cleanup even when managed cleanup throws, preserves the original exception when only one cleanup hook fails, and preserves both exceptions in an `AggregateException` when both hooks fail; disposal remains idempotent, - Signed URI tests now derive validity windows from one UTC timestamp, and retry behavior tests apply the strict latency budget only in dedicated latency scenarios. ## [10.7.1] - 2026-09-09