diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index de81a77..30ee41d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,33 +1,174 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net - name: Publish -on: [workflow_dispatch] +on: + workflow_dispatch: -jobs: - build: +permissions: + # Settings > Actions > General must allow GitHub Actions to create pull requests. + contents: write + pull-requests: write - runs-on: ubuntu-latest +concurrency: + group: publish + cancel-in-progress: false +jobs: + publish: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - name: Setup .NET - id: setup-dotnet - uses: actions/setup-dotnet@v5 - with: - dotnet-version: | - 8.0.x - 10.0.x - - name: Select .NET 10 SDK - run: dotnet new globaljson --sdk-version "${{ steps.setup-dotnet.outputs.dotnet-version }}" --roll-forward latestPatch --force - - name: Show .NET SDK - run: dotnet --version - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --configuration Release --no-restore - - name: Test - run: dotnet test --configuration Release --no-build --verbosity normal - - name: Publish - run: dotnet nuget push "**/bin/Release/*.nupkg" --source "https://api.nuget.org/v3/index.json" --api-key "${{ secrets.NUGET_API_KEY }}" + - name: Require the default branch + shell: bash + run: | + if [[ "${GITHUB_REF_NAME}" != "${{ github.event.repository.default_branch }}" ]]; then + echo "Releases must run from ${{ github.event.repository.default_branch }}, not ${GITHUB_REF_NAME}." >&2 + exit 1 + fi + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + id: setup-dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Select .NET 10 SDK + run: dotnet new globaljson --sdk-version "${{ steps.setup-dotnet.outputs.dotnet-version }}" --roll-forward latestPatch --force + + - name: Show .NET SDK + run: dotnet --version + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore -p:PackageOutputPath="$GITHUB_WORKSPACE/artifacts/packages" + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Test release automation + shell: pwsh + run: ./build/tests/Test-ReleaseAutomation.ps1 + + - name: Validate release artifacts + id: package + shell: pwsh + run: >- + ./build/Get-ReleaseArtifacts.ps1 + -SearchRoot artifacts/packages + -StagingDirectory "artifacts/release-${{ github.run_id }}" + -GitHubOutput $env:GITHUB_OUTPUT + + - name: Validate release metadata + shell: bash + env: + VERSION: ${{ steps.package.outputs.version }} + PRERELEASE: ${{ steps.package.outputs.prerelease }} + run: | + if [[ -z "$VERSION" ]]; then + echo "Release version output is empty." >&2 + exit 1 + fi + + if [[ "$PRERELEASE" == "true" && "$VERSION" != *-* ]]; then + echo "Version $VERSION is marked prerelease but has no prerelease suffix." >&2 + exit 1 + fi + + if [[ "$PRERELEASE" != "true" && "$VERSION" == *-* ]]; then + echo "Version $VERSION has a prerelease suffix but is not marked prerelease." >&2 + exit 1 + fi + + echo "Validated release tag/version metadata for $VERSION." + + - name: Publish packages + shell: bash + run: | + for package in "${{ steps.package.outputs.directory }}"/*.nupkg "${{ steps.package.outputs.directory }}"/*.snupkg; do + dotnet nuget push "$package" \ + --source "https://api.nuget.org/v3/index.json" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --skip-duplicate + done + + - name: Create or verify GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.package.outputs.version }} + PRERELEASE: ${{ steps.package.outputs.prerelease }} + ARTIFACT_DIRECTORY: ${{ steps.package.outputs.directory }} + run: | + if gh release view "$VERSION" >/dev/null 2>&1; then + git fetch --force origin "refs/tags/$VERSION:refs/tags/$VERSION" + target=$(git rev-list -n 1 "$VERSION") + if [[ "$target" != "$GITHUB_SHA" ]]; then + echo "Release $VERSION already exists for $target, not $GITHUB_SHA." >&2 + exit 1 + fi + gh release upload "$VERSION" "$ARTIFACT_DIRECTORY"/* --clobber + else + options=(--target "$GITHUB_SHA" --title "$VERSION" --generate-notes) + if [[ "$PRERELEASE" == "true" ]]; then + options+=(--prerelease) + fi + gh release create "$VERSION" "$ARTIFACT_DIRECTORY"/* "${options[@]}" + fi + + - name: Promote public API baselines + if: steps.package.outputs.prerelease != 'true' + shell: pwsh + run: ./build/Promote-PublicApi.ps1 + + # Pull requests opened by GITHUB_TOKEN do not start another workflow run, + # so validate the exact promoted tree before committing it. + - name: Validate promoted baselines + if: steps.package.outputs.prerelease != 'true' + run: dotnet build --configuration Release --no-restore + + - name: Open public API promotion pull request + if: steps.package.outputs.prerelease != 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.package.outputs.version }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + branch="automation/public-api-$VERSION" + if git diff --quiet -- '*/PublicAPI.Shipped.txt' '*/PublicAPI.Unshipped.txt'; then + echo "Public API baselines are already promoted." + exit 0 + fi + + existing=$(gh pr list --head "$branch" --state open --json url --jq '.[0].url // empty') + if [[ -n "$existing" ]]; then + echo "Promotion pull request already exists: $existing" + exit 0 + fi + + if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + echo "Remote branch $branch already exists without an open pull request." >&2 + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add -- '*/PublicAPI.Shipped.txt' '*/PublicAPI.Unshipped.txt' + git commit -m "Mark public APIs shipped in $VERSION" + git push origin "HEAD:refs/heads/$branch" + gh pr create \ + --base "$DEFAULT_BRANCH" \ + --head "$branch" \ + --title "Mark public APIs shipped in $VERSION" \ + --body "Promotes the public API baselines after the [$VERSION release]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$VERSION)." + + - name: Skip baseline promotion for prerelease + if: steps.package.outputs.prerelease == 'true' + run: echo "Prerelease detected; skipping public API baseline promotion." diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..07a1935 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/TaskFlow.Extensions.Microsoft.DependencyInjection/PublicAPI.Shipped.txt b/TaskFlow.Extensions.Microsoft.DependencyInjection/PublicAPI.Shipped.txt new file mode 100644 index 0000000..8dd3005 --- /dev/null +++ b/TaskFlow.Extensions.Microsoft.DependencyInjection/PublicAPI.Shipped.txt @@ -0,0 +1,22 @@ +#nullable enable +System.Threading.Tasks.Flow.IDefaultTaskFlowFactory +System.Threading.Tasks.Flow.IDefaultTaskFlowFactory.Create(System.Threading.Tasks.Flow.TaskFlowOptions! options) -> System.Threading.Tasks.Flow.ITaskFlow! +System.Threading.Tasks.Flow.INamedConfigureTaskFlowChain +System.Threading.Tasks.Flow.INamedConfigureTaskFlowChain.ConfigureChain(System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler) -> System.Threading.Tasks.Flow.ITaskScheduler! +System.Threading.Tasks.Flow.INamedConfigureTaskFlowChain.Name.get -> string! +System.Threading.Tasks.Flow.INamedConfigureTaskFlowOptions +System.Threading.Tasks.Flow.INamedConfigureTaskFlowOptions.Configure() -> System.Threading.Tasks.Flow.TaskFlowOptions! +System.Threading.Tasks.Flow.INamedConfigureTaskFlowOptions.Name.get -> string! +System.Threading.Tasks.Flow.INamedTaskFlowFactory +System.Threading.Tasks.Flow.INamedTaskFlowFactory.Create(System.Threading.Tasks.Flow.TaskFlowOptions! options) -> System.Threading.Tasks.Flow.ITaskFlow! +System.Threading.Tasks.Flow.INamedTaskFlowFactory.Name.get -> string! +System.Threading.Tasks.Flow.ITaskFlowFactory +System.Threading.Tasks.Flow.ITaskFlowFactory.CreateTaskFlow(string? name = null) -> System.Threading.Tasks.Flow.ITaskFlow! +System.Threading.Tasks.Flow.ServiceCollectionExtensions +System.Threading.Tasks.Flow.TaskFlowFactory +System.Threading.Tasks.Flow.TaskFlowFactory.CreateTaskFlow(string? name = null) -> System.Threading.Tasks.Flow.ITaskFlow! +System.Threading.Tasks.Flow.TaskFlowFactory.TaskFlowFactory(System.Collections.Generic.IEnumerable! namedTaskFlowFactories, System.Collections.Generic.IEnumerable! namedConfigureTaskFlowChains, System.Collections.Generic.IEnumerable! namedConfigureTaskFlowOptions, System.Threading.Tasks.Flow.IDefaultTaskFlowFactory! defaultTaskFlowFactory) -> void +static System.Threading.Tasks.Flow.ServiceCollectionExtensions.AddTaskFlow(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static System.Threading.Tasks.Flow.ServiceCollectionExtensions.AddTaskFlow(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Threading.Tasks.Flow.TaskFlowOptions! options) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static System.Threading.Tasks.Flow.ServiceCollectionExtensions.AddTaskFlow(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string? name, System.Func? baseTaskFlowFactory = null, System.Func? configureOptions = null, System.Func? configureSchedulerChain = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static System.Threading.Tasks.Flow.ServiceCollectionExtensions.AddTaskFlow(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Threading.Tasks.Flow.TaskFlowOptions! options) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/TaskFlow.Extensions.Microsoft.DependencyInjection/PublicAPI.Unshipped.txt b/TaskFlow.Extensions.Microsoft.DependencyInjection/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/TaskFlow.Extensions.Microsoft.DependencyInjection/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/TaskFlow.Extensions.Microsoft.Logging/PublicAPI.Shipped.txt b/TaskFlow.Extensions.Microsoft.Logging/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/TaskFlow.Extensions.Microsoft.Logging/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/TaskFlow.Extensions.Microsoft.Logging/PublicAPI.Unshipped.txt b/TaskFlow.Extensions.Microsoft.Logging/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..b4aeba0 --- /dev/null +++ b/TaskFlow.Extensions.Microsoft.Logging/PublicAPI.Unshipped.txt @@ -0,0 +1,17 @@ +#nullable enable +System.Threading.Tasks.Flow.LoggingTaskSchedulerExtensions +System.Threading.Tasks.Flow.TaskFlowLoggingOptions +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.CancellationRequestedLogLevel.get -> Microsoft.Extensions.Logging.LogLevel +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.CancellationRequestedLogLevel.set -> void +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.EnqueuedLogLevel.get -> Microsoft.Extensions.Logging.LogLevel +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.EnqueuedLogLevel.set -> void +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.FailedLogLevel.get -> Microsoft.Extensions.Logging.LogLevel +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.FailedLogLevel.set -> void +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.FinishedLogLevel.get -> Microsoft.Extensions.Logging.LogLevel +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.FinishedLogLevel.set -> void +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.StartedLogLevel.get -> Microsoft.Extensions.Logging.LogLevel +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.StartedLogLevel.set -> void +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.SucceededLogLevel.get -> Microsoft.Extensions.Logging.LogLevel +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.SucceededLogLevel.set -> void +System.Threading.Tasks.Flow.TaskFlowLoggingOptions.TaskFlowLoggingOptions() -> void +static System.Threading.Tasks.Flow.LoggingTaskSchedulerExtensions.WithLogging(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, Microsoft.Extensions.Logging.ILogger! logger, System.Action? configure = null) -> System.Threading.Tasks.Flow.ITaskScheduler! diff --git a/TaskFlow.Extensions.Time/PublicAPI.Shipped.txt b/TaskFlow.Extensions.Time/PublicAPI.Shipped.txt new file mode 100644 index 0000000..ec6ef1d --- /dev/null +++ b/TaskFlow.Extensions.Time/PublicAPI.Shipped.txt @@ -0,0 +1,3 @@ +#nullable enable +System.Threading.Tasks.Flow.ThrottlingTaskSchedulerExtensions +static System.Threading.Tasks.Flow.ThrottlingTaskSchedulerExtensions.WithDebounce(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.TimeSpan interval, System.TimeProvider? timeProvider = null) -> System.Threading.Tasks.Flow.ITaskScheduler! diff --git a/TaskFlow.Extensions.Time/PublicAPI.Unshipped.txt b/TaskFlow.Extensions.Time/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..d8b459c --- /dev/null +++ b/TaskFlow.Extensions.Time/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +#nullable enable +*REMOVED*static System.Threading.Tasks.Flow.ThrottlingTaskSchedulerExtensions.WithDebounce(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.TimeSpan interval, System.TimeProvider? timeProvider = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ThrottlingTaskSchedulerExtensions.WithThrottle(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.TimeSpan interval, System.TimeProvider? timeProvider = null) -> System.Threading.Tasks.Flow.ITaskScheduler! diff --git a/TaskFlow.Extensions.Time/TaskFlow.Extensions.Time.csproj b/TaskFlow.Extensions.Time/TaskFlow.Extensions.Time.csproj index d14de4d..6bc120a 100644 --- a/TaskFlow.Extensions.Time/TaskFlow.Extensions.Time.csproj +++ b/TaskFlow.Extensions.Time/TaskFlow.Extensions.Time.csproj @@ -1,6 +1,7 @@ netstandard2.0 + $(DefineConstants);TASKFLOW_EXTENSIONS_TIME System.Threading.Tasks.Flow TaskFlow.Extensions.Time Leading-edge admission throttling for TaskFlow on netstandard2.0 with TimeProvider-based deterministic timing. diff --git a/TaskFlow/DedicatedThreadTaskFlow.cs b/TaskFlow/DedicatedThreadTaskFlow.cs index 31b5758..53ad4b0 100644 --- a/TaskFlow/DedicatedThreadTaskFlow.cs +++ b/TaskFlow/DedicatedThreadTaskFlow.cs @@ -45,9 +45,8 @@ public sealed class DedicatedThreadTaskFlow : ThreadTaskFlow private readonly Thread _thread; /// - /// Initializes a new instance of the class with default options and an optional name. + /// Initializes a new instance of the class with default options and the default thread name. /// - /// Optional name for the dedicated thread. If null or empty, uses the class name. /// /// /// This constructor uses the default options from . @@ -56,17 +55,41 @@ public sealed class DedicatedThreadTaskFlow : ThreadTaskFlow /// The task flow immediately creates and starts a dedicated background thread for processing tasks. /// /// - public DedicatedThreadTaskFlow(string? name = default) + public DedicatedThreadTaskFlow() + : this(TaskFlowOptions.Default, null) + { + } + + /// + /// Initializes a new instance of the class with default options and the specified thread name. + /// + /// The dedicated thread name. If null or empty, the class name is used. + /// + /// The task flow immediately creates and starts a dedicated background thread using the default options from + /// . + /// + public DedicatedThreadTaskFlow(string? name) : this(TaskFlowOptions.Default, name) { } /// - /// Initializes a new instance of the class with the specified options and an optional name. + /// Initializes a new instance of the class with the specified options and the default thread name. + /// + /// The options that configure the behavior of this task flow. + /// is null. + /// The task flow immediately creates and starts a dedicated background thread. + public DedicatedThreadTaskFlow(TaskFlowOptions options) + : this(options, null) + { + } + + /// + /// Initializes a new instance of the class with the specified options and thread name. /// /// The options that configure the behavior of this task flow. - /// Optional name for the dedicated thread. If null or empty, uses the class name. - /// Thrown when is null. + /// The dedicated thread name. If null or empty, the class name is used. + /// is null. /// /// /// The task flow immediately creates and starts a dedicated background thread for processing tasks. @@ -75,7 +98,7 @@ public DedicatedThreadTaskFlow(string? name = default) /// The thread name is useful for debugging and thread identification in thread dumps or profiling tools. /// /// - public DedicatedThreadTaskFlow(TaskFlowOptions options, string? name = default) + public DedicatedThreadTaskFlow(TaskFlowOptions options, string? name) : base(options) { _thread = new Thread(ThreadStart) diff --git a/TaskFlow/Extensions/ExceptionTaskSchedulerExtensions.cs b/TaskFlow/Extensions/ExceptionTaskSchedulerExtensions.cs index 62e8a42..b822916 100644 --- a/TaskFlow/Extensions/ExceptionTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/ExceptionTaskSchedulerExtensions.cs @@ -109,7 +109,6 @@ public static class ExceptionTaskSchedulerExtensions /// The type of exception to handle. Must derive from . /// The task scheduler to wrap with error handling. /// The action to execute when an exception of type occurs. Receives the scheduler instance, the exception, and the operation name annotation if available. - /// An optional predicate to filter which exceptions should be handled. If null, all exceptions of the specified type are handled. /// An that observes matching exceptions according to the specified parameters. /// Thrown when is null. /// @@ -117,7 +116,20 @@ public static class ExceptionTaskSchedulerExtensions /// making it convenient for scenarios where operation names are used for error logging and diagnostics. /// The error handler receives the scheduler instance, allowing for reactive error handling patterns. /// - public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction, Func? errorFilter = null) + public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction) + where TException : Exception + { + return OnError(taskScheduler, errorAction, null); + } + + /// Creates a scheduler that observes exceptions of the specified type when they satisfy a filter. + /// The exception type to observe. + /// The scheduler to decorate. + /// The callback that receives the registration scheduler and matching exception. + /// The predicate used to select matching exceptions. A null value observes every exception of the specified type. + /// A scheduler that invokes for matching failures and then propagates the current failure. + /// or is null. + public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction, Func? errorFilter) where TException : Exception { return taskScheduler.UseMiddleware(new AnnotatedExceptionMiddleware(errorFilter ?? DefaultErrorFilter, (scheduler, exception, _) => errorAction(scheduler, exception))); @@ -129,14 +141,26 @@ public static ITaskScheduler OnError(this ITaskScheduler taskSchedul /// The type of exception to handle. Must derive from . /// The task scheduler to wrap with error handling. /// The action to execute when an exception of type occurs. Receives only the exception instance. - /// An optional predicate to filter which exceptions should be handled. If null, all exceptions of the specified type are handled. /// An that observes matching exceptions according to the specified parameters. /// Thrown when or is null. /// /// This is the simplest error handling overload, suitable for basic error logging or notification scenarios /// where scheduler access and annotation context are not needed. /// - public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction, Func? errorFilter = null) + public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction) + where TException : Exception + { + return OnError(taskScheduler, errorAction, null); + } + + /// Creates a scheduler that observes exceptions of the specified type when they satisfy a filter. + /// The exception type to observe. + /// The scheduler to decorate. + /// The callback that receives each matching exception. + /// The predicate used to select matching exceptions. A null value observes every exception of the specified type. + /// A scheduler that invokes for matching failures and then propagates the current failure. + /// or is null. + public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction, Func? errorFilter) where TException : Exception { Argument.NotNull(errorAction); @@ -182,14 +206,26 @@ public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, ActionThe type of exception to handle. Must derive from . /// The task scheduler to wrap with error handling. /// The action to execute when an exception occurs. Receives the scheduler, exception, and operation name annotation. - /// An optional predicate to filter which exceptions should be handled. If null, all exceptions of the specified type are handled. /// An that observes matching exceptions with operation name context. /// Thrown when is null. /// /// This overload explicitly provides access, making it ideal for /// scenarios where operation names are consistently used and needed for error context. /// - public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction, Func? errorFilter = null) + public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction) + where TException : Exception + { + return OnError(taskScheduler, errorAction, null); + } + + /// Creates a scheduler that observes filtered exceptions with operation-name metadata. + /// The exception type to observe. + /// The scheduler to decorate. + /// The callback receiving the registration scheduler, matching exception, and captured operation-name annotation. + /// The predicate used to select matching exceptions. A null value observes every exception of the specified type. + /// A scheduler that invokes for matching failures and then propagates the current failure. + /// or is null. + public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction, Func? errorFilter) where TException : Exception { return taskScheduler.UseMiddleware(new AnnotatedExceptionMiddleware(errorFilter ?? DefaultErrorFilter, errorAction)); @@ -202,7 +238,6 @@ public static ITaskScheduler OnError(this ITaskScheduler taskSchedul /// The type of operation annotation to provide to the error handler. Must implement . /// The task scheduler to wrap with error handling. /// The action to execute when an exception occurs. Receives the scheduler, exception, and custom annotation. - /// An optional predicate to filter which exceptions should be handled. If null, all exceptions of the specified type are handled. /// An that observes matching exceptions with custom annotation context. /// Thrown when is null. /// @@ -210,7 +245,22 @@ public static ITaskScheduler OnError(this ITaskScheduler taskSchedul /// that implements . This enables rich contextual error handling /// with application-specific metadata. /// - public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction, Func? errorFilter = null) + public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction) + where TException : Exception + where TAnnotation : IOperationAnnotation + { + return OnError(taskScheduler, errorAction, null); + } + + /// Creates a scheduler that observes filtered exceptions with custom annotation metadata. + /// The exception type to observe. + /// The captured annotation type supplied to the callback. + /// The scheduler to decorate. + /// The callback receiving the registration scheduler, matching exception, and captured annotation. + /// The predicate used to select matching exceptions. A null value observes every exception of the specified type. + /// A scheduler that invokes for matching failures and then propagates the current failure. + /// or is null. + public static ITaskScheduler OnError(this ITaskScheduler taskScheduler, Action errorAction, Func? errorFilter) where TException : Exception where TAnnotation : IOperationAnnotation { diff --git a/TaskFlow/Extensions/ThrottlingTaskSchedulerExtensions.cs b/TaskFlow/Extensions/ThrottlingTaskSchedulerExtensions.cs index 2493b65..4767c26 100644 --- a/TaskFlow/Extensions/ThrottlingTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/ThrottlingTaskSchedulerExtensions.cs @@ -1,5 +1,6 @@ namespace System.Threading.Tasks.Flow { + using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks.Flow.Annotations; /// @@ -58,6 +59,9 @@ namespace System.Threading.Tasks.Flow /// await testScheduler.Enqueue(() => SomeOperation()); // Should succeed /// /// +#if !TASKFLOW_EXTENSIONS_TIME + [SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", Justification = "This API is conditionally unavailable on netstandard2.0 and predates the shared API baseline.")] +#endif public static class ThrottlingTaskSchedulerExtensions { /// diff --git a/TaskFlow/PublicAPI.Shipped.txt b/TaskFlow/PublicAPI.Shipped.txt new file mode 100644 index 0000000..745dbd6 --- /dev/null +++ b/TaskFlow/PublicAPI.Shipped.txt @@ -0,0 +1,144 @@ +#nullable enable +abstract System.Threading.Tasks.Flow.TaskFlowBase.Enqueue(System.Func>! taskFunc, object? state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +abstract System.Threading.Tasks.Flow.TaskFlowBase.GetCompletionTask() -> System.Threading.Tasks.Task! +abstract System.Threading.Tasks.Flow.TaskFlowBase.GetInitializationTask() -> System.Threading.Tasks.Task! +abstract System.Threading.Tasks.Flow.ThreadTaskFlow.ThreadId.get -> int +override System.Threading.Tasks.Flow.CurrentThreadTaskFlow.ThreadId.get -> int +override System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.ThreadId.get -> int +override System.Threading.Tasks.Flow.TaskFlow.Enqueue(System.Func>! taskFunc, object? state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override System.Threading.Tasks.Flow.TaskFlowOptions.Equals(object? obj) -> bool +override System.Threading.Tasks.Flow.TaskFlowOptions.GetHashCode() -> int +override System.Threading.Tasks.Flow.TaskFlowOptions.ToString() -> string! +override System.Threading.Tasks.Flow.TaskFlowSynchronizationContext.Post(System.Threading.SendOrPostCallback! d, object? state) -> void +override System.Threading.Tasks.Flow.TaskFlowSynchronizationContext.Send(System.Threading.SendOrPostCallback! d, object? state) -> void +override System.Threading.Tasks.Flow.ThreadTaskFlow.Enqueue(System.Func>! taskFunc, object? state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override System.Threading.Tasks.Flow.ThreadTaskFlow.GetCompletionTask() -> System.Threading.Tasks.Task! +override System.Threading.Tasks.Flow.ThreadTaskFlow.GetInitializationTask() -> System.Threading.Tasks.Task! +override System.Threading.Tasks.Flow.ThreadTaskFlow.OnDisposeAfterWaitForCompletion() -> void +override System.Threading.Tasks.Flow.ThreadTaskFlow.OnDisposeBeforeWaitForCompletion() -> void +static System.Threading.Tasks.Flow.AnnotatingTaskSchedulerExtensions.AnnotatedEnqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func>! taskFunc, object? state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.AnnotatingTaskSchedulerExtensions.WithOperationName(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, string! operationName) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.CancellationScopeTaskSchedulerExtensions.CreateCancellationScope(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Threading.CancellationToken scopeCancellationToken) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.CancelPreviousTaskSchedulerExtensions.CreateCancelPrevious(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.InterceptionTaskSchedulerExtensions.Intercept(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Threading.Tasks.Flow.IAsyncTaskSchedulerInterceptor! interceptor) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.InterceptionTaskSchedulerExtensions.Intercept(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, TInterceptor interceptor) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.TaskFlowOptions.Default.get -> System.Threading.Tasks.Flow.TaskFlowOptions! +static System.Threading.Tasks.Flow.TaskFlowOptions.Default.set -> void +static System.Threading.Tasks.Flow.TaskFlowOptions.operator !=(System.Threading.Tasks.Flow.TaskFlowOptions? left, System.Threading.Tasks.Flow.TaskFlowOptions? right) -> bool +static System.Threading.Tasks.Flow.TaskFlowOptions.operator ==(System.Threading.Tasks.Flow.TaskFlowOptions? left, System.Threading.Tasks.Flow.TaskFlowOptions? right) -> bool +static System.Threading.Tasks.Flow.TaskFlowSynchronizationContext.For(System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler) -> System.Threading.SynchronizationContext! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! action) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! action, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! action) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! action, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! taskFunc) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! taskFunc, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! valueTaskFunc, System.Threading.CancellationToken cancellationToken, System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.DummyParameter? _ = null) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! valueTaskFunc, System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.DummyParameter? _ = null) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! taskFunc) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! valueTaskFunc, System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.DummyParameter? _ = null) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func!>! taskFunc, TState state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func>! taskFunc, TState state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func!>! taskFunc) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func!>! taskFunc, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func>! taskFunc, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func>! valueTaskFunc, System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.DummyParameter? _ = null) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! func, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func!>! taskFunc) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func>! valueTaskFunc, System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.DummyParameter? _ = null) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! func) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! taskFunc, TState state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.Enqueue(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Func! taskFunc, TState state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext.GetAnnotation(object? state) -> TAnnotation? +static System.Threading.Tasks.Flow.TimeoutTaskSchedulerExtensions.WithTimeout(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.TimeSpan timeout) -> System.Threading.Tasks.Flow.ITaskScheduler! +System.Threading.Tasks.Flow.AnnotatingTaskSchedulerExtensions +System.Threading.Tasks.Flow.CancellationScopeTaskSchedulerExtensions +System.Threading.Tasks.Flow.CancelPreviousTaskSchedulerExtensions +System.Threading.Tasks.Flow.CurrentThreadTaskFlow +System.Threading.Tasks.Flow.CurrentThreadTaskFlow.CurrentThreadTaskFlow() -> void +System.Threading.Tasks.Flow.CurrentThreadTaskFlow.CurrentThreadTaskFlow(System.Threading.Tasks.Flow.TaskFlowOptions! options) -> void +System.Threading.Tasks.Flow.CurrentThreadTaskFlow.Run() -> void +System.Threading.Tasks.Flow.DedicatedThreadTaskFlow +System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.DedicatedThreadTaskFlow(string? name = null) -> void +System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.DedicatedThreadTaskFlow(System.Threading.Tasks.Flow.TaskFlowOptions! options, string? name = null) -> void +System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions +System.Threading.Tasks.Flow.IAsyncTaskInterceptor +System.Threading.Tasks.Flow.IAsyncTaskInterceptor.OnBeforeAsync(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context) -> System.Threading.Tasks.ValueTask +System.Threading.Tasks.Flow.IAsyncTaskInterceptor.OnErrorAsync(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context, System.Exception! exception) -> System.Threading.Tasks.ValueTask +System.Threading.Tasks.Flow.IAsyncTaskInterceptor.OnFinallyAsync(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context) -> System.Threading.Tasks.ValueTask +System.Threading.Tasks.Flow.IAsyncTaskInterceptor.OnSuccessAsync(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context, TResult result) -> System.Threading.Tasks.ValueTask +System.Threading.Tasks.Flow.IAsyncTaskSchedulerInterceptor +System.Threading.Tasks.Flow.IAsyncTaskSchedulerInterceptor.CreateInterceptor(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context) -> System.Threading.Tasks.Flow.IAsyncTaskInterceptor! +System.Threading.Tasks.Flow.InterceptionTaskSchedulerExtensions +System.Threading.Tasks.Flow.IOperationAnnotation +System.Threading.Tasks.Flow.ITaskFlow +System.Threading.Tasks.Flow.ITaskFlow.Dispose(System.TimeSpan timeout) -> bool +System.Threading.Tasks.Flow.ITaskFlowInfo +System.Threading.Tasks.Flow.ITaskFlowInfo.Options.get -> System.Threading.Tasks.Flow.TaskFlowOptions! +System.Threading.Tasks.Flow.ITaskScheduler +System.Threading.Tasks.Flow.ITaskScheduler.Enqueue(System.Func>! taskFunc, object? state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +System.Threading.Tasks.Flow.ITaskSchedulerInterceptor +System.Threading.Tasks.Flow.ITaskSchedulerInterceptor.OnBefore(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context) -> void +System.Threading.Tasks.Flow.ITaskSchedulerInterceptor.OnError(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context, System.Exception! exception) -> void +System.Threading.Tasks.Flow.ITaskSchedulerInterceptor.OnFinally(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context) -> void +System.Threading.Tasks.Flow.ITaskSchedulerInterceptor.OnSuccess(System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext context, TResult result) -> void +System.Threading.Tasks.Flow.OperationNameAnnotation +System.Threading.Tasks.Flow.OperationNameAnnotation.OperationName.get -> string! +System.Threading.Tasks.Flow.OperationThrottledException +System.Threading.Tasks.Flow.OperationThrottledException.OperationThrottledException() -> void +System.Threading.Tasks.Flow.OperationThrottledException.OperationThrottledException(string! message) -> void +System.Threading.Tasks.Flow.OperationThrottledException.OperationThrottledException(string! message, System.Exception! innerException) -> void +System.Threading.Tasks.Flow.TaskFlow +System.Threading.Tasks.Flow.TaskFlow.TaskFlow() -> void +System.Threading.Tasks.Flow.TaskFlow.TaskFlow(System.Threading.Tasks.Flow.TaskFlowOptions! options) -> void +System.Threading.Tasks.Flow.TaskFlowBase +System.Threading.Tasks.Flow.TaskFlowBase.CheckDisposed() -> void +System.Threading.Tasks.Flow.TaskFlowBase.CompletionToken.get -> System.Threading.CancellationToken +System.Threading.Tasks.Flow.TaskFlowBase.Dispose() -> void +System.Threading.Tasks.Flow.TaskFlowBase.Dispose(System.TimeSpan timeout) -> bool +System.Threading.Tasks.Flow.TaskFlowBase.DisposeAsync() -> System.Threading.Tasks.ValueTask +System.Threading.Tasks.Flow.TaskFlowBase.Options.get -> System.Threading.Tasks.Flow.TaskFlowOptions! +System.Threading.Tasks.Flow.TaskFlowBase.Ready() -> void +System.Threading.Tasks.Flow.TaskFlowBase.Starting() -> void +System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowBase(System.Threading.Tasks.Flow.TaskFlowOptions! options) -> void +System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState +System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState.Disposed = 4 -> System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState +System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState.Disposing = 3 -> System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState +System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState.NotStarted = 0 -> System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState +System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState.Running = 2 -> System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState +System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState.Starting = 1 -> System.Threading.Tasks.Flow.TaskFlowBase.TaskFlowState +System.Threading.Tasks.Flow.TaskFlowBase.ThisLock.get -> object! +System.Threading.Tasks.Flow.TaskFlowOptions +System.Threading.Tasks.Flow.TaskFlowOptions.$() -> System.Threading.Tasks.Flow.TaskFlowOptions! +System.Threading.Tasks.Flow.TaskFlowOptions.Equals(System.Threading.Tasks.Flow.TaskFlowOptions? other) -> bool +System.Threading.Tasks.Flow.TaskFlowOptions.SynchronousDisposeTimeout.get -> System.TimeSpan +System.Threading.Tasks.Flow.TaskFlowOptions.SynchronousDisposeTimeout.init -> void +System.Threading.Tasks.Flow.TaskFlowOptions.TaskFlowOptions() -> void +System.Threading.Tasks.Flow.TaskFlowOptions.TaskScheduler.get -> System.Threading.Tasks.TaskScheduler! +System.Threading.Tasks.Flow.TaskFlowOptions.TaskScheduler.init -> void +System.Threading.Tasks.Flow.TaskFlowSchedulerAdapter +System.Threading.Tasks.Flow.TaskFlowSchedulerAdapter.Enqueue(System.Func>! taskFunc, object? state, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +System.Threading.Tasks.Flow.TaskFlowSchedulerAdapter.TaskFlowSchedulerAdapter(System.Threading.Tasks.TaskScheduler! taskScheduler) -> void +System.Threading.Tasks.Flow.TaskFlowSynchronizationContext +System.Threading.Tasks.Flow.TaskFlowSynchronizationContext.TaskFlowSynchronizationContext(System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler) -> void +System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions +System.Threading.Tasks.Flow.TaskSchedulerEnqueueExtensions.DummyParameter +System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext +System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext.CancellationToken.get -> System.Threading.CancellationToken +System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext.GetAnnotation() -> TAnnotation? +System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext.State.get -> object? +System.Threading.Tasks.Flow.TaskSchedulerInterceptionContext.TaskSchedulerInterceptionContext() -> void +System.Threading.Tasks.Flow.ThreadTaskFlow +System.Threading.Tasks.Flow.ThreadTaskFlow.ThreadStart(object? _) -> void +System.Threading.Tasks.Flow.ThreadTaskFlow.ThreadTaskFlow(System.Threading.Tasks.Flow.TaskFlowOptions! options) -> void +System.Threading.Tasks.Flow.TimeoutTaskSchedulerExtensions +virtual System.Threading.Tasks.Flow.TaskFlowBase.Dispose(bool disposing) -> void +virtual System.Threading.Tasks.Flow.TaskFlowBase.DisposeAsyncCore() -> System.Threading.Tasks.ValueTask +virtual System.Threading.Tasks.Flow.TaskFlowBase.OnDisposeAfterWaitForCompletion() -> void +virtual System.Threading.Tasks.Flow.TaskFlowBase.OnDisposeBeforeWaitForCompletion() -> void diff --git a/TaskFlow/PublicAPI.Unshipped.txt b/TaskFlow/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..3736a7a --- /dev/null +++ b/TaskFlow/PublicAPI.Unshipped.txt @@ -0,0 +1,57 @@ +#nullable enable +*REMOVED*static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +*REMOVED*static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +*REMOVED*static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +*REMOVED*static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter = null) -> System.Threading.Tasks.Flow.ITaskScheduler! +*REMOVED*System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.DedicatedThreadTaskFlow(string? name = null) -> void +*REMOVED*System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.DedicatedThreadTaskFlow(System.Threading.Tasks.Flow.TaskFlowOptions! options, string? name = null) -> void +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.ExceptionTaskSchedulerExtensions.OnError(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Action! errorAction, System.Func? errorFilter) -> System.Threading.Tasks.Flow.ITaskScheduler! +System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.DedicatedThreadTaskFlow() -> void +System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.DedicatedThreadTaskFlow(string? name) -> void +System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.DedicatedThreadTaskFlow(System.Threading.Tasks.Flow.TaskFlowOptions! options) -> void +System.Threading.Tasks.Flow.DedicatedThreadTaskFlow.DedicatedThreadTaskFlow(System.Threading.Tasks.Flow.TaskFlowOptions! options, string? name) -> void +static System.Threading.Tasks.Flow.TaskSchedulerMiddlewareExtensions.UseMiddleware(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, System.Threading.Tasks.Flow.ITaskSchedulerMiddleware! middleware) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.TaskSchedulerMiddlewareExtensions.WithAnnotation(this System.Threading.Tasks.Flow.ITaskScheduler! taskScheduler, TAnnotation! annotation) -> System.Threading.Tasks.Flow.ITaskScheduler! +static System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome.FromException(System.Exception! exception) -> System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome +static System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome.FromResult(TResult result) -> System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome +System.Threading.Tasks.Flow.ITaskSchedulerCompletionMiddleware +System.Threading.Tasks.Flow.ITaskSchedulerCompletionMiddleware.InvokeAsync(System.Threading.Tasks.Flow.TaskSchedulerOperationContext! context, System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome outcome, System.Threading.Tasks.Flow.TaskSchedulerCompletionDelegate! continuation) -> System.Threading.Tasks.ValueTask> +System.Threading.Tasks.Flow.ITaskSchedulerEnqueueMiddleware +System.Threading.Tasks.Flow.ITaskSchedulerEnqueueMiddleware.InvokeAsync(System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext! context, System.Threading.Tasks.Flow.TaskSchedulerEnqueueDelegate! continuation) -> System.Threading.Tasks.Task! +System.Threading.Tasks.Flow.ITaskSchedulerExecutionMiddleware +System.Threading.Tasks.Flow.ITaskSchedulerExecutionMiddleware.InvokeAsync(System.Threading.Tasks.Flow.TaskSchedulerOperationContext! context, System.Threading.Tasks.Flow.TaskSchedulerExecutionDelegate! continuation) -> System.Threading.Tasks.ValueTask +System.Threading.Tasks.Flow.ITaskSchedulerMiddleware +System.Threading.Tasks.Flow.TaskSchedulerCompletionDelegate +System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext +System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext.CallerCancellationToken.get -> System.Threading.CancellationToken +System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext.CancellationToken.get -> System.Threading.CancellationToken +System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext.GetAnnotation() -> TAnnotation? +System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext.GetLocalState() -> TState? +System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext.GetOrCreateLocalState(System.Func! stateFactory) -> TState! +System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext.State.get -> object? +System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext.WithCancellationToken(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext! +System.Threading.Tasks.Flow.TaskSchedulerEnqueueDelegate +System.Threading.Tasks.Flow.TaskSchedulerExecutionDelegate +System.Threading.Tasks.Flow.TaskSchedulerMiddlewareExtensions +System.Threading.Tasks.Flow.TaskSchedulerOperationContext +System.Threading.Tasks.Flow.TaskSchedulerOperationContext.CallerCancellationToken.get -> System.Threading.CancellationToken +System.Threading.Tasks.Flow.TaskSchedulerOperationContext.CancellationToken.get -> System.Threading.CancellationToken +System.Threading.Tasks.Flow.TaskSchedulerOperationContext.GetAnnotation() -> TAnnotation? +System.Threading.Tasks.Flow.TaskSchedulerOperationContext.GetLocalState() -> TState? +System.Threading.Tasks.Flow.TaskSchedulerOperationContext.GetOrCreateLocalState(System.Func! stateFactory) -> TState! +System.Threading.Tasks.Flow.TaskSchedulerOperationContext.State.get -> object? +System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome +System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome.Exception.get -> System.Exception? +System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome.IsSuccess.get -> bool +System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome.Result.get -> TResult +System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome.TaskSchedulerOperationOutcome() -> void +virtual System.Threading.Tasks.Flow.TaskSchedulerCompletionDelegate.Invoke(System.Threading.Tasks.Flow.TaskSchedulerOperationContext! context, System.Threading.Tasks.Flow.TaskSchedulerOperationOutcome outcome) -> System.Threading.Tasks.ValueTask> +virtual System.Threading.Tasks.Flow.TaskSchedulerEnqueueDelegate.Invoke(System.Threading.Tasks.Flow.TaskSchedulerEnqueueContext! context) -> System.Threading.Tasks.Task! +virtual System.Threading.Tasks.Flow.TaskSchedulerExecutionDelegate.Invoke(System.Threading.Tasks.Flow.TaskSchedulerOperationContext! context) -> System.Threading.Tasks.ValueTask diff --git a/TaskFlow/TaskSchedulerEnqueueExtensions.cs b/TaskFlow/TaskSchedulerEnqueueExtensions.cs index 7f957ac..15cecde 100644 --- a/TaskFlow/TaskSchedulerEnqueueExtensions.cs +++ b/TaskFlow/TaskSchedulerEnqueueExtensions.cs @@ -352,6 +352,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFun /// result to match the scheduler's signature requirement. The dummy parameter is used /// to avoid method signature conflicts with other overloads. /// + [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] public static async Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, CancellationToken cancellationToken, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); @@ -380,6 +381,7 @@ async ValueTask TaskFunc(CancellationToken token) /// A direct async lambda may be ambiguous with the corresponding /// overload; use a named function or an explicit delegate cast. /// + [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] public static async Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); @@ -401,6 +403,7 @@ public static async Task Enqueue(this ITaskScheduler taskScheduler, Func for the cancellation token. The dummy parameter /// is used to avoid method signature conflicts with other overloads. /// + [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] public static Task Enqueue(this ITaskScheduler taskScheduler, Func> valueTaskFunc, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); @@ -423,6 +426,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func + [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] public static Task Enqueue(this ITaskScheduler taskScheduler, Func> valueTaskFunc, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); @@ -444,6 +448,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func + [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] public static Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); diff --git a/build/Get-ReleaseArtifacts.ps1 b/build/Get-ReleaseArtifacts.ps1 new file mode 100644 index 0000000..40e9bff --- /dev/null +++ b/build/Get-ReleaseArtifacts.ps1 @@ -0,0 +1,97 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $SearchRoot, + [Parameter(Mandatory)] + [string] $StagingDirectory, + [string] $Configuration, + [string[]] $ExpectedPackageIds = @( + 'TaskFlow', + 'TaskFlow.Microsoft.Extensions.DependencyInjection', + 'TaskFlow.Microsoft.Extensions.Logging', + 'TaskFlow.Extensions.Time' + ), + [string] $GitHubOutput +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Read-PackageIdentity([string] $Path) { + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + $nuspecs = @($archive.Entries | Where-Object FullName -Like '*.nuspec') + if ($nuspecs.Count -ne 1) { + throw "Package '$Path' must contain exactly one nuspec." + } + $reader = [System.IO.StreamReader]::new($nuspecs[0].Open()) + try { [xml] $nuspec = $reader.ReadToEnd() } + finally { $reader.Dispose() } + return [pscustomobject]@{ + Id = [string] $nuspec.package.metadata.id + Version = [string] $nuspec.package.metadata.version + Path = $Path + } + } + finally { $archive.Dispose() } +} + +$root = (Resolve-Path -LiteralPath $SearchRoot).Path +$packages = @(Get-ChildItem -LiteralPath $root -Recurse -File | + Where-Object { + ($_.Name -like '*.nupkg' -or $_.Name -like '*.snupkg') -and + ([string]::IsNullOrEmpty($Configuration) -or + $_.FullName -match "[\\/]bin[\\/]$([regex]::Escape($Configuration))[\\/]") + }) +$primary = @($packages | Where-Object { $_.Name -notlike '*.snupkg' -and $_.Name -notlike '*.symbols.nupkg' }) +$symbols = @($packages | Where-Object { $_.Name -like '*.snupkg' -or $_.Name -like '*.symbols.nupkg' }) + +if ($primary.Count -ne $ExpectedPackageIds.Count) { + throw "Expected $($ExpectedPackageIds.Count) package files, but found $($primary.Count)." +} + +$identities = @($primary | ForEach-Object { Read-PackageIdentity $_.FullName }) +$actualIds = @($identities.Id | Sort-Object -CaseSensitive) +$expectedIds = @($ExpectedPackageIds | Sort-Object -CaseSensitive) +if (($actualIds -join "`n") -cne ($expectedIds -join "`n")) { + throw "Unexpected package IDs. Expected '$($expectedIds -join ', ')'; found '$($actualIds -join ', ')'." +} + +$versions = @($identities.Version | Sort-Object -Unique) +if ($versions.Count -ne 1 -or [string]::IsNullOrWhiteSpace($versions[0])) { + throw "All packages must have the same non-empty version. Found: $($versions -join ', ')" +} + +if ($symbols.Count -ne $ExpectedPackageIds.Count) { + throw "Expected one symbol package per package ID, but found $($symbols.Count)." +} +$symbolIdentities = @($symbols | ForEach-Object { Read-PackageIdentity $_.FullName }) +if ((@($symbolIdentities.Id | Sort-Object -CaseSensitive) -join "`n") -cne ($expectedIds -join "`n") -or + @($symbolIdentities.Version | Sort-Object -Unique).Count -ne 1 -or + $symbolIdentities[0].Version -cne $versions[0]) { + throw 'Symbol package IDs and versions must match the primary packages.' +} + +$stage = [System.IO.Path]::GetFullPath($StagingDirectory) +if (Test-Path -LiteralPath $stage) { + if (@(Get-ChildItem -LiteralPath $stage -Force).Count -gt 0) { + throw "Staging directory must be empty: $stage" + } +} +else { + [System.IO.Directory]::CreateDirectory($stage) | Out-Null +} + +foreach ($package in $packages) { + Copy-Item -LiteralPath $package.FullName -Destination $stage +} + +$version = $versions[0] +$prerelease = $version.Contains('-').ToString().ToLowerInvariant() +if ($GitHubOutput) { + Add-Content -LiteralPath $GitHubOutput -Value "version=$version" + Add-Content -LiteralPath $GitHubOutput -Value "prerelease=$prerelease" + Add-Content -LiteralPath $GitHubOutput -Value "directory=$stage" +} + +Write-Host "Validated $($primary.Count) packages and $($symbols.Count) symbol packages for version $version." diff --git a/build/Promote-PublicApi.ps1 b/build/Promote-PublicApi.ps1 new file mode 100644 index 0000000..173e514 --- /dev/null +++ b/build/Promote-PublicApi.ps1 @@ -0,0 +1,108 @@ +[CmdletBinding()] +param( + [string] $RepositoryRoot = (Split-Path -Parent $PSScriptRoot), + [switch] $Verify +) + +$ErrorActionPreference = 'Stop' +$nullableHeader = '#nullable enable' +$removedPrefix = '*REMOVED*' +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function Get-ApiLines([string] $Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Missing public API baseline: $Path" + } + + $lines = [System.IO.File]::ReadAllLines($Path) + if ($lines.Count -eq 0 -or $lines[0] -cne $nullableHeader) { + throw "The first line of '$Path' must be '$nullableHeader'." + } + + $entries = @($lines | Select-Object -Skip 1 | Where-Object { $_.Length -gt 0 }) + $duplicates = @($entries | Group-Object -CaseSensitive | Where-Object Count -gt 1) + if ($duplicates.Count -gt 0) { + throw "Duplicate public API entry in '$Path': $($duplicates[0].Name)" + } + + foreach ($entry in $entries) { + if ($entry.Trim() -cne $entry -or $entry.StartsWith('#')) { + throw "Malformed public API entry in '$Path': $entry" + } + } + + return $entries +} + +function Write-ApiLines([string] $Path, [string[]] $Entries) { + $ordered = [string[]] @($Entries) + [System.Array]::Sort($ordered, [System.StringComparer]::Ordinal) + $content = @($nullableHeader) + $ordered + [System.IO.File]::WriteAllText( + $Path, + (($content -join [Environment]::NewLine) + [Environment]::NewLine), + $utf8NoBom) +} + +$root = (Resolve-Path -LiteralPath $RepositoryRoot).Path +$projects = @(Get-ChildItem -LiteralPath $root -Recurse -Filter '*.csproj' -File | + Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' }) +$packableProjects = [System.Collections.Generic.List[System.IO.FileInfo]]::new() + +foreach ($project in $projects) { + $isPackable = (& dotnet msbuild $project.FullName -nologo -getProperty:IsPackable 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + throw "Could not evaluate IsPackable for '$($project.FullName)': $isPackable" + } + + if ($isPackable -ceq 'true') { + $packableProjects.Add($project) + } +} + +if ($packableProjects.Count -eq 0) { + throw "No packable projects were found under '$root'." +} + +$changed = [System.Collections.Generic.List[string]]::new() +foreach ($project in $packableProjects) { + $directory = $project.DirectoryName + $shippedPath = Join-Path $directory 'PublicAPI.Shipped.txt' + $unshippedPath = Join-Path $directory 'PublicAPI.Unshipped.txt' + $shipped = @(Get-ApiLines $shippedPath) + $unshipped = @(Get-ApiLines $unshippedPath) + $shippedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($entry in $shipped) { + $null = $shippedSet.Add($entry) + } + + foreach ($entry in $unshipped) { + if ($entry.StartsWith($removedPrefix, [System.StringComparison]::Ordinal)) { + $removed = $entry.Substring($removedPrefix.Length) + if ([string]::IsNullOrWhiteSpace($removed)) { + throw "Empty removal marker in '$unshippedPath'." + } + if (-not $shippedSet.Remove($removed)) { + throw "Removal marker in '$unshippedPath' does not match a shipped API: $removed" + } + } + elseif (-not $shippedSet.Add($entry)) { + throw "API is declared in both shipped and unshipped baselines for '$($project.Name)': $entry" + } + } + + if ($unshipped.Count -gt 0) { + $changed.Add($project.Name) + if (-not $Verify) { + Write-ApiLines $shippedPath @($shippedSet) + Write-ApiLines $unshippedPath @() + } + } +} + +if ($Verify) { + Write-Host "Validated $($packableProjects.Count) public API baseline pairs; $($changed.Count) would change." +} +else { + Write-Host "Promoted public APIs for $($changed.Count) of $($packableProjects.Count) packable projects." +} diff --git a/build/tests/Test-ReleaseAutomation.ps1 b/build/tests/Test-ReleaseAutomation.ps1 new file mode 100644 index 0000000..cb5167a --- /dev/null +++ b/build/tests/Test-ReleaseAutomation.ps1 @@ -0,0 +1,176 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$promoteScript = Join-Path $root 'build/Promote-PublicApi.ps1' +$artifactsScript = Join-Path $root 'build/Get-ReleaseArtifacts.ps1' +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function Write-TestFile([string] $Path, [string[]] $Lines) { + $directory = Split-Path -Parent $Path + [System.IO.Directory]::CreateDirectory($directory) | Out-Null + [System.IO.File]::WriteAllText($Path, (($Lines -join "`n") + "`n"), $utf8NoBom) +} + +function New-TestRepository([string] $Name, [string[]] $Shipped, [string[]] $Unshipped) { + $path = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-$Name-$([guid]::NewGuid())" + [System.IO.Directory]::CreateDirectory($path) | Out-Null + Write-TestFile (Join-Path $path 'Package/Package.csproj') @( + '', + ' net8.0', + '') + Write-TestFile (Join-Path $path 'Package/PublicAPI.Shipped.txt') (@('#nullable enable') + $Shipped) + Write-TestFile (Join-Path $path 'Package/PublicAPI.Unshipped.txt') (@('#nullable enable') + $Unshipped) + return $path +} + +function Assert-Equal([string] $Expected, [string] $Actual, [string] $Message) { + $normalizedExpected = $Expected.Replace("`r`n", "`n") + $normalizedActual = $Actual.Replace("`r`n", "`n") + if ($normalizedExpected -cne $normalizedActual) { throw "$Message`nExpected: $Expected`nActual: $Actual" } +} + +function Assert-Throws([scriptblock] $Action, [string] $Message) { + try { & $Action; throw "Expected failure: $Message" } + catch { + if ($_.Exception.Message -eq "Expected failure: $Message") { throw } + } +} + +function Read-OutputMap([string] $Path) { + $map = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($line in [System.IO.File]::ReadAllLines($Path)) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + $index = $line.IndexOf('=') + if ($index -lt 1) { + throw "Malformed GitHub output line: $line" + } + + $name = $line.Substring(0, $index) + $value = $line.Substring($index + 1) + if (-not $map.TryAdd($name, $value)) { + throw "Duplicate GitHub output key: $name" + } + } + + return $map +} + +function New-TestPackage([string] $Directory, [string] $Id, [string] $Version, [switch] $Symbols) { + $content = Join-Path $Directory ([guid]::NewGuid().ToString()) + [System.IO.Directory]::CreateDirectory($content) | Out-Null + Write-TestFile (Join-Path $content "$Id.nuspec") @( + '', + '', + "$Id$Version", + 'teststests', + '') + $suffix = if ($Symbols) { '.snupkg' } else { '.nupkg' } + $path = Join-Path $Directory "$Id.$Version$suffix" + [System.IO.Compression.ZipFile]::CreateFromDirectory($content, $path) + [System.IO.Directory]::Delete($content, $true) +} + +$temporaryRoots = [System.Collections.Generic.List[string]]::new() +try { + $basic = New-TestRepository 'promotion' @('Z.Api', 'Removed.Api') @('A.Api', '*REMOVED*Removed.Api') + $temporaryRoots.Add($basic) + & $promoteScript -RepositoryRoot $basic -Verify + Assert-Equal "#nullable enable`nZ.Api`nRemoved.Api`n" ([IO.File]::ReadAllText((Join-Path $basic 'Package/PublicAPI.Shipped.txt'))) 'Verify must not modify shipped APIs.' + & $promoteScript -RepositoryRoot $basic + Assert-Equal "#nullable enable`nA.Api`nZ.Api`n" ([IO.File]::ReadAllText((Join-Path $basic 'Package/PublicAPI.Shipped.txt'))) 'Promotion must sort additions and consume removals.' + Assert-Equal "#nullable enable`n" ([IO.File]::ReadAllText((Join-Path $basic 'Package/PublicAPI.Unshipped.txt'))) 'Promotion must empty the unshipped baseline.' + & $promoteScript -RepositoryRoot $basic + + $duplicate = New-TestRepository 'duplicate' @('A.Api', 'A.Api') @() + $temporaryRoots.Add($duplicate) + Assert-Throws { & $promoteScript -RepositoryRoot $duplicate -Verify } 'duplicate API' + + $malformed = New-TestRepository 'malformed' @() @(' A.Api') + $temporaryRoots.Add($malformed) + Assert-Throws { & $promoteScript -RepositoryRoot $malformed -Verify } 'malformed API' + + $missingHeader = New-TestRepository 'header' @() @() + $temporaryRoots.Add($missingHeader) + Write-TestFile (Join-Path $missingHeader 'Package/PublicAPI.Shipped.txt') @('A.Api') + Assert-Throws { & $promoteScript -RepositoryRoot $missingHeader -Verify } 'missing nullable header' + + $missingPair = New-TestRepository 'pair' @() @() + $temporaryRoots.Add($missingPair) + [System.IO.File]::Delete((Join-Path $missingPair 'Package/PublicAPI.Unshipped.txt')) + Assert-Throws { & $promoteScript -RepositoryRoot $missingPair -Verify } 'missing baseline pair' + + $missingRemoval = New-TestRepository 'removal' @() @('*REMOVED*Missing.Api') + $temporaryRoots.Add($missingRemoval) + Assert-Throws { & $promoteScript -RepositoryRoot $missingRemoval -Verify } 'unmatched removal' + + $packages = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-packages-$([guid]::NewGuid())" + $stage = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-stage-$([guid]::NewGuid())" + $githubOutput = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-output-$([guid]::NewGuid()).txt" + $temporaryRoots.Add($packages) + $temporaryRoots.Add($stage) + $temporaryRoots.Add($githubOutput) + [System.IO.Directory]::CreateDirectory($packages) | Out-Null + $ids = @('TaskFlow', 'TaskFlow.Microsoft.Extensions.DependencyInjection', 'TaskFlow.Microsoft.Extensions.Logging', 'TaskFlow.Extensions.Time') + foreach ($id in $ids) { + New-TestPackage $packages $id '1.2.3-rc1' + New-TestPackage $packages $id '1.2.3-rc1' -Symbols + } + & $artifactsScript -SearchRoot $packages -StagingDirectory $stage -GitHubOutput $githubOutput + Assert-Equal '8' ([string] @(Get-ChildItem $stage -File).Count) 'All validated artifacts must be staged.' + $outputs = Read-OutputMap $githubOutput + Assert-Equal '1.2.3-rc1' $outputs['version'] 'Version output must match validated package version.' + Assert-Equal 'true' $outputs['prerelease'] 'RC versions must be marked as prerelease.' + + $wrongIdStage = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-wrong-id-stage-$([guid]::NewGuid())" + $temporaryRoots.Add($wrongIdStage) + Assert-Throws { + & $artifactsScript -SearchRoot $packages -StagingDirectory $wrongIdStage -ExpectedPackageIds @('TaskFlow', 'Wrong.Package', 'TaskFlow.Microsoft.Extensions.Logging', 'TaskFlow.Extensions.Time') + } 'unexpected package ID' + + $badPackages = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-bad-packages-$([guid]::NewGuid())" + $badStage = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-bad-stage-$([guid]::NewGuid())" + $temporaryRoots.Add($badPackages) + $temporaryRoots.Add($badStage) + [System.IO.Directory]::CreateDirectory($badPackages) | Out-Null + foreach ($id in $ids) { + $version = if ($id -eq 'TaskFlow.Extensions.Time') { '2.0.0' } else { '1.2.3' } + New-TestPackage $badPackages $id $version + New-TestPackage $badPackages $id $version -Symbols + } + Assert-Throws { & $artifactsScript -SearchRoot $badPackages -StagingDirectory $badStage } 'mismatched versions' + + $stablePackages = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-stable-packages-$([guid]::NewGuid())" + $stableStage = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-stable-stage-$([guid]::NewGuid())" + $stableOutput = Join-Path ([System.IO.Path]::GetTempPath()) "taskflow-stable-output-$([guid]::NewGuid()).txt" + $temporaryRoots.Add($stablePackages) + $temporaryRoots.Add($stableStage) + $temporaryRoots.Add($stableOutput) + [System.IO.Directory]::CreateDirectory($stablePackages) | Out-Null + foreach ($id in $ids) { + New-TestPackage $stablePackages $id '3.4.5' + New-TestPackage $stablePackages $id '3.4.5' -Symbols + } + & $artifactsScript -SearchRoot $stablePackages -StagingDirectory $stableStage -GitHubOutput $stableOutput + $stableOutputs = Read-OutputMap $stableOutput + Assert-Equal '3.4.5' $stableOutputs['version'] 'Stable versions must be returned unchanged.' + Assert-Equal 'false' $stableOutputs['prerelease'] 'Stable versions must not be marked as prerelease.' + + Write-Host 'Release automation tests passed.' +} +finally { + foreach ($path in $temporaryRoots) { + if (-not (Test-Path -LiteralPath $path)) { + continue + } + + $item = Get-Item -LiteralPath $path + if ($item -is [System.IO.DirectoryInfo]) { + [System.IO.Directory]::Delete($path, $true) + } + else { + [System.IO.File]::Delete($path) + } + } +}