diff --git a/.bot/README.md b/.bot/README.md new file mode 100644 index 000000000..2cfca8997 --- /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`. diff --git a/.docfx/api/namespaces/Cuemon.Extensions.DependencyInjection.md b/.docfx/api/namespaces/Cuemon.Extensions.DependencyInjection.md index a40489b0b..a09f3f7df 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 ac6e7284b..7e803a7d9 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."); + } + } +} +``` diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 69affd83f..6756c67bc 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 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 @@ -69,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`. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..f77a26bb1 --- /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" diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/pr.yml similarity index 79% rename from .github/workflows/ci-pipeline.yml rename to .github/workflows/pr.yml index e64fc3d06..653fd1d46 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/pr.yml @@ -1,17 +1,9 @@ -name: Cuemon CI Pipeline +name: PR Flow 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. @@ -23,7 +15,7 @@ permissions: jobs: init: name: initialize - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 outputs: run-privileged-jobs: ${{ steps.vars.outputs.run-privileged-jobs }} run-mac-tests: ${{ steps.vars.outputs.run-mac-tests }} @@ -33,14 +25,20 @@ jobs: - 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 [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.run_mac_tests }}" == "true" ]]; then + 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 [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then + 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" @@ -50,9 +48,15 @@ jobs: 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-24.04 + runs-on: ubuntu-26.04 timeout-minutes: 5 outputs: json: ${{ steps.test-projects.outputs.result }} @@ -83,7 +87,7 @@ jobs: 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' }} + 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 }} @@ -113,7 +117,7 @@ jobs: 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' }} + runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-26.04-arm' || 'ubuntu-26.04' }} configuration: ${{ matrix.configuration }} build-switches: -p:SkipSignAssembly=true projects: ${{ matrix.project }} @@ -169,7 +173,7 @@ jobs: matrix: configuration: [Debug, Release] project: [ test/**/Cuemon.Data.SqlClient.Tests.csproj ] - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 timeout-minutes: 15 steps: - name: Checkout @@ -312,7 +316,7 @@ jobs: if: ${{ always() }} name: test-qualitygate needs: [init, test_linux, test_windows, test_mac, integration_test] - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - name: Evaluate test results shell: bash @@ -374,6 +378,7 @@ jobs: uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@v1 with: repository: codebeltnet/cuemon + configuration: .github/codecov.yml secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -385,20 +390,64 @@ jobs: with: timeout-minutes: 30 permissions: + contents: read 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 }} + 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/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..6e5789ce2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,732 @@ +name: Release Flow +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: cuemon-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release_preflight: + name: Validate release tag and authoritative main history + 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: + # 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 }} + + - id: validate + name: Validate authoritative release request + shell: bash + env: + RELEASE_REF: ${{ github.ref }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + 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 [[ "$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" + + 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 + + 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 + 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 "sha=$RELEASE_SHA" + } >> "$GITHUB_OUTPUT" + + { + echo "## Release request validated" + echo + 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: + name: Build and validate Release packages + needs: [release_preflight] + runs-on: ubuntu-26.04 + timeout-minutes: 45 + permissions: + contents: read + 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 + + - 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 }} + + 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: + contents: write + steps: + - name: Checkout the released SHA + uses: codebeltnet/git-checkout@v1 + with: + ref: ${{ needs.release_preflight.outputs.sha }} + + - name: Verify the existing release tag still identifies the released SHA + shell: bash + env: + RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} + run: | + set -euo pipefail + 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 + + - 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, draft_github_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 }} + RELEASE_SHA: ${{ needs.release_preflight.outputs.sha }} + 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 + + # 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: + 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 + 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 + + - 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 + 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 + 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 + 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 + + - 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: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + 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 + 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 + + - 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 + - draft_github_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 }} + RELEASE_TAG: ${{ needs.release_preflight.outputs.tag }} + PACKAGE_BUILD_RESULT: ${{ needs.release_packages.result }} + NUGET_RESULT: ${{ needs.publish_nuget.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 }} + 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 "- 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 triggering tag, SemVer, main ancestry or existing release identity 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 [[ "$DRAFT_RELEASE_RESULT" != "success" ]]; then + { + 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 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 + { + 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 + + diff --git a/.github/workflows/trigger-downstream.yml b/.github/workflows/trigger-downstream.yml index 29eb29c2b..52fe971ae 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 diff --git a/.gitignore b/.gitignore index 7d0f31239..e2c9694db 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 diff --git a/.nuget/Cuemon.AspNetCore.App/PackageReleaseNotes.txt b/.nuget/Cuemon.AspNetCore.App/PackageReleaseNotes.txt index 3ac270485..42f413193 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 c5ce9245a..3e2a825b1 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 4d5248f12..03cb05154 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 45ae65c5f..a0bcdc46a 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 b7d1e0c56..4444e6067 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 6a5a13bac..b38397cdc 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 f1f689cb7..880e055cc 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 ca21ed263..c8bfbdd3a 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 d77ad5201..a02f1fc40 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 793ff8011..5f0fcff91 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 ca21ed263..c8bfbdd3a 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 3ac270485..42f413193 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 16a24dfbc..5acd379cb 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 bc9508726..cd2a5575f 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 3ac270485..42f413193 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 8dc55a4e8..122a4146c 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 3ecd2b468..460df4c05 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 dddaa0885..dd45b4140 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 352ed0324..a64bbbb22 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 cbdae8266..afb6cd7ac 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 ca21ed263..c8bfbdd3a 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 e91d129d0..881be19e5 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 ca21ed263..c8bfbdd3a 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 ca21ed263..c8bfbdd3a 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 4c786c32f..405b7b947 100644 --- a/.nuget/Cuemon.Extensions.DependencyInjection/PackageReleaseNotes.txt +++ b/.nuget/Cuemon.Extensions.DependencyInjection/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) + +# New Features +- 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 bc6d82119..7787016e9 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 6b156e58b..c6b81486c 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 17c3bf504..f7ddf6800 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 52453b0d0..bf7cac3b1 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 ca21ed263..c8bfbdd3a 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 ca21ed263..c8bfbdd3a 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 ca21ed263..c8bfbdd3a 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 9ffbaac74..0b6159edd 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 ca21ed263..c8bfbdd3a 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 896b3b204..2d909fdaf 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 4bd7dc3a9..ccaecd7d5 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 52453b0d0..bf7cac3b1 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 0a06daf82..86e779182 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 ca21ed263..c8bfbdd3a 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 ca21ed263..c8bfbdd3a 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 ca21ed263..c8bfbdd3a 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 41168aabd..5cbec9288 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 5f2a5a73b..f5b267c20 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 bd7deb237..bb93c350e 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 diff --git a/AGENTS.md b/AGENTS.md index 7989ed5d5..eb904b594 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 145f318ca..47596c30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ 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-03 + +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 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 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, 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 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 +1921,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 diff --git a/Directory.Packages.props b/Directory.Packages.props index dc165c904..41767c811 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + @@ -53,6 +53,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index fa415616a..f23f7b722 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 diff --git a/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs b/src/Cuemon.Extensions.DependencyInjection/ParameterObjectOptions.cs new file mode 100644 index 000000000..44b4919ba --- /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 (e is not OperationCanceledException && 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 1cf042c62..ebd84f245 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/src/Cuemon.Kernel/Disposable.cs b/src/Cuemon.Kernel/Disposable.cs index d95a1ac9c..9a5d01376 100644 --- a/src/Cuemon.Kernel/Disposable.cs +++ b/src/Cuemon.Kernel/Disposable.cs @@ -51,9 +51,24 @@ protected void Dispose(bool disposing) { if (Disposed) { return; } Disposed = true; - if (disposing) + try { - OnDisposeManagedResources(); + if (disposing) + { + OnDisposeManagedResources(); + } + } + catch (Exception managedException) + { + try + { + OnDisposeUnmanagedResources(); + } + catch (Exception unmanagedException) + { + throw new AggregateException(managedException, unmanagedException); + } + throw; } OnDisposeUnmanagedResources(); } diff --git a/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs b/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs index 7ef4dee30..1f986516a 100644 --- a/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs +++ b/test/Cuemon.Extensions.DependencyInjection.Tests/ServiceCollectionExtensionsTest.cs @@ -1,5 +1,10 @@ using System; using System.Linq; +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; using Cuemon.AspNetCore.Mvc.Filters.Diagnostics; @@ -20,6 +25,944 @@ 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); + } + } + + [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() + { + 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)] + [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 +1617,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"; } + } } diff --git a/test/Cuemon.Extensions.Net.Tests/Security/StringExtensionsTest.cs b/test/Cuemon.Extensions.Net.Tests/Security/StringExtensionsTest.cs index 33db15b68..2f8b3b52e 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); diff --git a/test/Cuemon.Kernel.Tests/DisposableTest.cs b/test/Cuemon.Kernel.Tests/DisposableTest.cs index b1e6fd783..d6cf53e82 100644 --- a/test/Cuemon.Kernel.Tests/DisposableTest.cs +++ b/test/Cuemon.Kernel.Tests/DisposableTest.cs @@ -50,6 +50,66 @@ 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 ThrowingCleanupDisposable(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 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() { @@ -69,4 +129,28 @@ public async Task Dispose_ShouldBeThreadSafeAndInvokeCallbacksOnce() Assert.Equal(1, sut.ManagedDisposeCount); Assert.Equal(1, sut.UnmanagedDisposeCount); } + + private sealed class ThrowingCleanupDisposable : TrackingDisposable + { + private readonly Exception _managedException; + private readonly Exception _unmanagedException; + + public ThrowingCleanupDisposable(Exception managedException, Exception unmanagedException = null) + { + _managedException = managedException; + _unmanagedException = unmanagedException; + } + + protected override void OnDisposeManagedResources() + { + base.OnDisposeManagedResources(); + if (_managedException != null) { throw _managedException; } + } + + protected override void OnDisposeUnmanagedResources() + { + base.OnDisposeUnmanagedResources(); + if (_unmanagedException != null) { throw _unmanagedException; } + } + } } diff --git a/test/Cuemon.Resilience.Tests/TransientOperationTest.cs b/test/Cuemon.Resilience.Tests/TransientOperationTest.cs index d73356e36..f89f790d8 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());