Skip to content

fix(extensions): tolerate ClientFactory<T> constructor change in AWS SDK 4.0.4+ (#52) - #53

Merged
Blind-Striker merged 15 commits into
masterfrom
feature/issue-52-clientfactory-compat
Jul 29, 2026
Merged

fix(extensions): tolerate ClientFactory<T> constructor change in AWS SDK 4.0.4+ (#52)#53
Blind-Striker merged 15 commits into
masterfrom
feature/issue-52-clientfactory-compat

Conversation

@Blind-Striker

@Blind-Striker Blind-Striker commented Jul 29, 2026

Copy link
Copy Markdown
Member

📝 Description

What does this PR do?

Fixes the LocalStackClientConfigurationException thrown when UseLocalStack is false, refreshes the dependency graph, adds the safety net that would have caught this class of problem, and prepares the LocalStack.Client.Extensions 2.0.1 release.

Root cause. AWSSDK.Extensions.NETCore.Setup 4.0.4 (published 2026-05-20) changed the internal ClientFactory<T> constructor:

// <= 4.0.3.40  (2026-05-15)
internal ClientFactory(AWSOptions awsOptions)

// >= 4.0.4     (2026-05-20)   <-- break
internal ClientFactory(AWSOptions awsOptions, Action<ClientConfig, IServiceProvider> configAction = null)

The added parameter is optional, so from AWS's side this is source-compatible and shipped as a patch-level bump with nothing in the release notes. It is binary-breaking for our exact-signature reflection lookup in AwsClientFactoryWrapper, which returned null and threw.

Three things kept it quiet for ~70 days:

  1. Only the UseLocalStack: false path goes through this wrapper — LocalStack-only users never hit it.
  2. Our nuspec declared [4.0.2, ), so consumers float to the newest version and break.
  3. CI pinned 4.0.2 exactly, so the pipeline only ever saw the one version that still worked.

The existing test CreateServiceClient_Should_Create_Client_When_UseLocalStack_False was always correct — it goes red the moment the pin moves past 4.0.4. The suite was never the gap; the pin was.

The fix. AwsClientFactoryWrapper now matches on the AWSOptions parameter rather than the full signature, prefers the fewest-parameter overload so the choice stays deterministic if AWS appends more optional parameters, and defaults any trailing arguments. Passing null for configAction is not a workaround — it is exactly what the SDK itself passes (= null, null-guarded before use). Requiring AWSOptions in first position also naturally excludes the private parameterless ctor present in every version.

Failure messages now include the constructor signatures actually discovered, so the next drift is diagnosable from a bug report instead of requiring a version bisect.

Related Issue(s):

🔄 Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🧹 Code cleanup/refactoring
  • ⚡ Performance improvement
  • 🧪 Test improvements

🎯 Target Version Track

  • v1.x (AWS SDK v3) - LTS Branch (sdkv3-lts)
  • v2.x (AWS SDK v4) - Current Branch (master)
  • Both versions (requires separate PRs)

v1.x is unaffected — it reflects over the non-generic ClientFactory with (Type, AWSOptions) in the SDK v3 Setup package, a different major line.

📦 Release scope — Extensions 2.0.1 only

PackageExtensionVersion2.0.1. PackageMainVersion deliberately stays at 2.0.0: LocalStack.Client has no source change, and the advisory on its AWSSDK.Core >= 4.0.0.15 floor is unreachable in practice because AWSSDK.Extensions.NETCore.Setup 4.0.100.5 itself declares AWSSDK.Core >= 4.0.100.6, so NuGet lifts it anyway.

The generated Extensions nuspec:

<version>2.0.1</version>
<dependency id="LocalStack.Client" version="2.0.0" />
<dependency id="AWSSDK.Extensions.NETCore.Setup" version="4.0.100.5" />   <!-- was 4.0.2 -->

The minimum AWSSDK.Extensions.NETCore.Setup is raised to 4.0.100.5 on purpose. Keeping consumers on a maintained AWS SDK is the intent; supporting the pre-4.0.4 shape while forbidding it in the nuspec would have been a contradiction. That decision is why this PR removes the backwards-compatibility test leg it originally introduced — see below.

PackageValidationBaselineVersion moved from the stale v1 baselines (1.4.1 / 1.2.2) to 2.0.0 for both projects. That made all five entries in src/LocalStack.Client/CompatibilitySuppressions.xml provably dead — they compare lib/net461, lib/net6.0 and lib/net7.0, target frameworks that only existed in the v1.4.1 package — so the file is deleted. Package validation reports them as unnecessary and fails the build if kept.

🧪 Testing

How has this been tested?

  • Unit tests added/updated
  • Integration tests added/updated
  • Functional tests added/updated
  • Manual testing performed
  • Tested with LocalStack container
  • Tested across multiple .NET versions

Functional/container tests were not run locally (Docker); they run on the Linux CI leg, which is why this is opened as a draft — letting the pipeline exercise everything.

Reproduced the original failure first using the reporter's own repro, then verified the fix on both tracks:

Track AWSSDK.Core / …NETCore.Setup resolved Result
current (default, pinned) 4.0.100.6 / 4.0.100.5 ✅ 1092/1092
latest (floating canary) 4.0.100.9 / 4.0.100.7 ✅ 1092/1092

4.0.100.7 is the reporter's exact version. Full matrix = Client.Tests 91 × net472/net8.0/net9.0, Integration.Tests 239 × net472/net8.0/net9.0, Extensions.Tests 51 × net8.0/net9.0. Clean --no-incremental rebuild: 0 warnings, 0 errors. Both packages pack with package validation enabled.

New tests model the current constructor shape — plus a shape AWS has not shipped, including a required value-type parameter — with local stand-ins, because a test bound to the real SDK can only ever see whichever version is pinned. The real-SDK check is deliberately shape-agnostic so the canary goes red only when resolution genuinely fails, not merely because AWS appended another parameter.

Test Environment:

  • LocalStack Version: n/a locally (container tests deferred to CI); functional suite targets 3.7.1 and 4.6.0
  • .NET Versions Tested: net472, net8.0, net9.0 (SDK 9.0.316)
  • Operating Systems: Windows 11 locally; Windows/Linux/macOS in CI

📚 Documentation

  • Code is self-documenting with clear naming
  • XML documentation comments added/updated
  • README.md updated (if needed)
  • CHANGELOG.md entry added
  • Breaking changes documented

✅ Code Quality Checklist

  • Code follows project coding standards
  • No new analyzer warnings introduced
  • All tests pass locally
  • No merge conflicts
  • Branch is up to date with target branch
  • Commit messages follow Conventional Commits

🔍 Additional Notes

Breaking Changes:

None to the public API; the new resolution helpers are internal, exposed to tests via InternalsVisibleTo. The raised AWSSDK.Extensions.NETCore.Setup floor is the one consumer-visible dependency change.

Performance Impact:

Negligible. Constructor selection is a one-time reflection scan per service registration, replacing a single GetConstructor call with a short loop over the same candidate set.

Dependencies:

AWSSDK.Core4.0.100.6 and AWSSDK.Extensions.NETCore.Setup4.0.100.5 (near-latest, well-adopted, both published 2026-07-17 — a coherent pair), plus 120 AWSSDK service packages and the analyzer/test toolchain to latest.

This also resolves NU1901 (GHSA-9cvc-h2w8-phrp) on AWSSDK.Core 4.0.0.15, which was failing restore outright because TreatWarningsAsErrors promotes the advisory to an error. The previous pin was 403 days old.

Microsoft.Extensions.* and System.Text.Json track the .NET 9 servicing line (9.0.18), matching the highest TFM in the repo — there is no net10.0 target anywhere. Microsoft.SourceLink.GitHub stays on 10.0.301 because it has no 9.x release at all (it jumps 8.0.0 → 10.x). These central versions are consumed only by tests, sandboxes and the build project: every Microsoft.Extensions.* reference in src/ carries an explicit VersionOverride (3.1.32 / 8.0.0), so no shipped dependency floor moves.

global.json → SDK 9.0.316 for the same reason; the previous 10.0.302 pin had no target to justify it. LICENSE copyright range → 2026.

Two upstream behaviour changes required source adaptations — none in src/:

  • Testcontainers 4.13.0 obsoleted the parameterless LocalStackBuilder() ctor (CS0618 → error).
  • AWSSDK.Core moved constructor-supplied credentials from Config.DefaultAWSCredentials to the new protected internal ExplicitAWSCredentials, leaving the old property null. Caught immediately by the existing unit tests.

Preventing recurrence — the AwsSetupTrack switch:

Raising the floor does not stop this recurring: a NuGet floor is a minimum, so consumers still float upward and will meet the next AWS change before our pinned build does. The forgiving constructor resolution is the airbag; the canary is the alarm.

One MSBuild property moves both structurally-depended-on packages:

current (default) - pinned, reproducible
latest            - AWSSDK.Core and …NETCore.Setup both floating, scheduled canary only

AWSSDK.Core floats too — its ExplicitAWSCredentials change bit us in this very PR, and the canary's summary step was printing a Core version that could never move.

A legacy track and a sdk-compat CI leg existed in earlier commits of this PR and are removed: once the nuspec floor is 4.0.100.5, proving the pre-4.0.4 shape works tests a configuration no consumer can install. The [Trait("Category", "SdkCompat")] attributes that only existed to filter that leg are gone with it.

Two constraints worth knowing for review:

  • A per-project VersionOverride does not work for this — src and tests must move together or NuGet reports NU1605 (package downgrade), an error under TreatWarningsAsErrors. Hence a central switch.
  • Floating versions are opt-in under CPM (NU1011), so CentralPackageFloatingVersionsEnabled is enabled only on the canary track. The property lives in Directory.Build.props; Directory.Packages.props keeps only the conditional PackageVersion items.

A real bug found in the canary itself:

--force-restore only means "do restore" — it does not bypass NuGet's HTTP cache, and floating versions resolve against the cached feed index. Measured locally: the canary resolved …NETCore.Setup 4.0.100.5 instead of 4.0.100.7, i.e. it would have silently reported "AWS has not moved" while AWS had. Fixed by clearing the HTTP cache before the run; it now resolves 4.0.100.9 / 4.0.100.7.

Build reliability:

  • Package version generation now validates through NuGet.Versioning instead of a hand-rolled SemVer rule. NuGetVersion decides whether a pre-release identifier is legal (it rejects …20260729.075853 and accepts …g075853), the branch name is sanitised to the legal [0-9A-Za-z-] alphabet, and the composed version is parsed as a backstop so an invalid version fails the build with the offending value rather than at dotnet pack time.
  • build.sh now carries the executable bit in the index (100755), so the five chmod +x ./build.sh steps across three workflows are gone.

CI/CD:

Every action bumped: checkout v4→v7, cache v4→v6, setup-dotnet v4→v6, upload-artifact v4→v7, dependency-review-action v4→v5, dorny/test-reporter v1→v3.

All four workflows now declare an explicit permissions block, resolving the CodeQL actions/missing-workflow-permissions alerts. build-and-test gets checks: write because dorny/test-reporter publishes a check run; the badge step authenticates with its own PAT and needs nothing from GITHUB_TOKEN.

setup-dotnet reads global-json-file, while the explicit 8.0.x/9.0.x entries are kept on purpose — running net8.0 test assemblies needs the .NET 8 runtime, which the pinned SDK alone does not provide.

🎯 Reviewer Focus Areas

Please pay special attention to:

  • Security implications
  • Performance impact
  • Breaking changes
  • Test coverage
  • Documentation completeness
  • Backward compatibility

Specifically worth a second pair of eyes:

  1. The raised nuspec floor (4.0.24.0.100.5). Deliberate, and the reason the legacy track was dropped — but it is the one change consumers feel.
  2. SelectFactoryConstructor tie-breaking — fewest-parameter-wins. Reasonable today, but it is the rule that decides behaviour if AWS ever ships two AWSOptions-first overloads.
  3. Deleting CompatibilitySuppressions.xml. Provably dead against the 2.0.0 baseline, but it removes the record of those v1-era differences.
  4. The S8969 fixes. SonarAnalyzer flagged 8 null-forgiving operators as redundant; 7 were not — they were masking CS8620 nullability variance. Following the analyzer literally broke the build. The correct fix was Dictionary<string, string?>.

Known follow-ups (deliberately not in this PR):

  • Two High-severity transitive vulnerabilities reachable only from LocalStack.Client.Functional.Tests (AutoFixture 4.18.1FareNETStandard.Library 1.6.1System.Net.Http 4.3.0 / System.Text.RegularExpressions 4.3.0). Test-only, never shipped, and AutoFixture 4.18.1 is already latest so it cannot be fixed by upgrading. Invisible to dotnet build because NuGetAuditMode defaults to direct.
  • The longer-term reflection-free path: AWSOptions.CreateServiceClient<T>() is public and has existed in every release since 3.2.8-rc (2016-09-08). It would remove this reflection entirely — and with it the [RequiresDynamicCode]/[RequiresUnreferencedCode] annotations and ILLink.Descriptors.xml — but it has no IServiceProvider overload, so the AWSOptions fallback chain and logger wiring would need reimplementing. Tracked for 2.1 alongside the AOT work.

📸 Screenshots/Examples

The failure this fixes, from the original report:

Unhandled exception. LocalStack.Client.Extensions.Exceptions.LocalStackClientConfigurationException:
    ClientFactory<T> missing constructor with AWSOptions parameter.
   at LocalStack.Client.Extensions.AwsClientFactoryWrapper.CreateServiceClient[TClient](IServiceProvider provider, AWSOptions awsOptions)
   at LocalStack.Client.Extensions.ServiceCollectionExtensions.<>c__DisplayClass12_0`1.<GetServiceFactoryDescriptor>b__0(IServiceProvider provider)

Unchanged user code — this now works on 4.0.4+ as well as the versions before it:

services.AddLocalStack(Configuration);
services.AddDefaultAWSOptions(Configuration.GetAWSOptions());
services.AddAwsService<IAmazonS3>();   // UseLocalStack: false no longer throws

Running the suite the way the scheduled canary does:

dotnet nuget locals http-cache --clear
./build.sh --target tests --skipFunctionalTest true --aws-setup-track latest --force-restore true --exclusive

By submitting this pull request, I confirm that:

  • I have read and agree to the project's Code of Conduct
  • I understand that this contribution may be subject to the .NET Foundation CLA
  • My contribution is licensed under the same terms as the project (MIT License)

Updates AWSSDK.Core to 4.0.100.6 and AWSSDK.Extensions.NETCore.Setup to 4.0.100.5
(near-latest, well-adopted, both published 2026-07-17), 120 AWSSDK service packages,
Microsoft.Extensions.* to 10.0.10, and the analyzer/test toolchain to latest.

Resolves NU1901 (GHSA-9cvc-h2w8-phrp) on AWSSDK.Core 4.0.0.15, which was failing
restore outright because TreatWarningsAsErrors promotes the advisory to an error.
The previous pin was 403 days old.

Adds the $(AwsSetupTrack) switch to Directory.Packages.props so the whole package
graph can move between AWSSDK.Extensions.NETCore.Setup shapes at once:

  current (default) - post-4.0.4 ClientFactory<T>(AWSOptions, Action<...>)
  legacy            - pre-4.0.4  ClientFactory<T>(AWSOptions)
  latest            - floating, scheduled canary only

A per-project VersionOverride does not work here: src and tests must move together
or NuGet reports NU1605. Floating versions are opt-in under CPM (NU1011), so they
are enabled only on the canary track. Exposed through Cake as --aws-setup-track.

Two upstream behaviour changes required source adaptations, none in src/:

- Testcontainers 4.13.0 obsoleted the parameterless LocalStackBuilder() ctor.
- AWSSDK.Core moved constructor-supplied credentials from Config.DefaultAWSCredentials
  to the new protected internal ExplicitAWSCredentials, leaving the old property null.
  Caught immediately by the existing unit tests.

New analyzer diagnostics fixed rather than suppressed, except CA1873 which joins the
CA1848/CA2254 logging-performance opt-out already present in those test projects.
Note S8969 flagged eight null-forgiving operators of which seven were NOT redundant -
they masked CS8620 nullability variance; the correct fix was Dictionary<string, string?>.

Also updates global.json to SDK 10.0.302 and the LICENSE copyright range to 2026.
….4+ (#52)

AWSSDK.Extensions.NETCore.Setup 4.0.4 (2026-05-20) changed the internal
ClientFactory<T> constructor:

  <= 4.0.3.40  ClientFactory(AWSOptions awsOptions)
  >= 4.0.4     ClientFactory(AWSOptions awsOptions,
                             Action<ClientConfig, IServiceProvider> configAction = null)

The new parameter is optional, so this was source-compatible for AWS and shipped as
a patch-level bump with nothing in the release notes. It is binary-breaking for our
exact-signature reflection lookup, which returned null and threw
LocalStackClientConfigurationException. Only the UseLocalStack:false path was
affected, which is why it went unreported for ~70 days while consumers floated past
4.0.4 (our nuspec declares [4.0.2, )) and CI stayed pinned at 4.0.2.

AwsClientFactoryWrapper now matches on the AWSOptions parameter rather than the full
signature, prefers the fewest-parameter overload so the choice stays deterministic if
AWS appends more optional parameters, and defaults any trailing arguments. Passing
null for configAction is what the SDK itself does - the parameter is declared = null
and null-guarded before use. Requiring AWSOptions in first position also excludes the
private parameterless ctor present in every version.

Failure messages now include the constructor signatures actually discovered, so the
next drift is diagnosable from a bug report instead of needing a version bisect.

Tests model both known shapes - plus a shape AWS has not shipped - with local
stand-ins, because a test bound to the real SDK can only ever see the pinned version.
The real-SDK check is deliberately shape-agnostic so it passes on either track.
Verified green against 4.0.3.40, 4.0.100.5 and 4.0.100.7 (the reporter's version).

Public API unchanged; the resolution helpers are internal via InternalsVisibleTo.
Updates every action to latest: checkout v4->v7, cache v4->v6, setup-dotnet v4->v6,
upload-artifact v4->v7, dependency-review-action v4->v5, dorny/test-reporter v1->v3.

setup-dotnet now also reads global-json-file so the SDK matches the repo pin, while
the explicit 8.0.x/9.0.x entries are kept because the multi-TFM test run needs those
runtimes present - the .NET 10 SDK alone cannot execute net8.0/net9.0 test assemblies.

No setup-node pinning: nothing in the workflows uses setup-node, and update-test-badge
is a composite action running bash steps. The JS action runtimes come from the actions
themselves, so bumping them is what moves Node forward.

Adds a Linux leg running the pre-4.0.4 AWS SDK track, so backwards compatibility is
proven on every push rather than assumed.

Adds a scheduled canary that floats AWSSDK.Extensions.NETCore.Setup to the newest 4.x
and runs the SDK-sensitive tests against it, deliberately without NuGet caching. The
pinned build is reproducible by design, which is exactly why the 4.0.4 break went
unnoticed for 70 days - CI only ever saw the pinned version while consumers floated.
A failure there means "AWS moved", not "we broke something".
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 3 package(s) with unknown licenses.
See the Details below.

License Issues

.github/workflows/dependency-review.yml

PackageVersionLicenseIssue Type
actions/checkout7.*.*NullUnknown License
actions/dependency-review-action5.*.*NullUnknown License

build/LocalStack.Build/LocalStack.Build.csproj

PackageVersionLicenseIssue Type
NuGet.Versioning>= 0NullUnknown License

OpenSSF Scorecard

PackageVersionScoreDetails
actions/actions/checkout 7.*.* 🟢 6.9
Details
CheckScoreReason
Binary-Artifacts🟢 10no binaries found in the repo
Code-Review🟢 10all changesets reviewed
Maintained🟢 1025 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
Pinned-Dependencies🟢 3dependency not pinned by hash detected -- score normalized to 3
License🟢 10license file detected
Packaging⚠️ -1packaging workflow not detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
Branch-Protection🟢 5branch protection is not maximal on development and all release branches
SAST🟢 10SAST tool is run on all commits
actions/actions/dependency-review-action 5.*.* 🟢 7.7
Details
CheckScoreReason
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Security-Policy🟢 9security policy file detected
Code-Review🟢 10all changesets reviewed
Packaging⚠️ -1packaging workflow not detected
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions🟢 9detected GitHub workflow tokens with excessive permissions
Maintained🟢 1021 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Pinned-Dependencies⚠️ 1dependency not pinned by hash detected -- score normalized to 1
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Branch-Protection🟢 6branch protection is not maximal on development and all release branches
SAST🟢 9SAST tool detected but not run on all commits
nuget/NuGet.Versioning >= 0 UnknownUnknown

Scanned Files

  • .github/workflows/dependency-review.yml
  • build/LocalStack.Build/LocalStack.Build.csproj

Comment thread .github/workflows/aws-sdk-canary.yml Fixed
The macos-latest image no longer ships Mono, so VSTest aborts the net472 run with
"Could not find 'mono' host". Linux is unaffected - ubuntu-22.04 still has it.

Guarded by a presence check so the step becomes a no-op if a future image brings
Mono back.
…ainers

Scenario classes in a collection share one LocalStack container, so anything a test
leaves behind is visible to every test that runs afterwards - including in other
classes. That made assertions about container state order dependent:
Multi_Region_Tests_Async("eu-central-1") asserts Assert.Single over ListTopics and
failed once another class left a topic alive. The same test passed or failed
depending on which subset was run, which is the definition of a non-isolated test.

The assertion is deliberately left untouched. Asserting more than strictly necessary
is what surfaced the leak in the first place; weakening it would hide the defect
rather than fix it.

Cleanup previously sat at the end of the test body, so it never ran when an assertion
threw - one red test would poison every test after it. BaseScenario now implements
IAsyncLifetime: xUnit builds a fresh instance per test method and awaits DisposeAsync
regardless of outcome, which is exactly per-test teardown.

  TrackForCleanup(resourceId, cleanup)  - registers a resource for removal
  UntrackCleanup(resourceId)            - drops it when deletion is the behaviour under test

Teardown runs in reverse registration order, so a subscription goes before the topic
it depends on, and reports failures via AggregateException instead of swallowing them -
a leaked resource is a defect and should be loud.

Applied across every scenario that creates resources:

- BaseRealLife created a topic, a queue and a subscription and deleted none of them.
- BaseCloudFormationScenario never deleted its stack, so the template's ChatTopic
  survived. Deletion now waits for DELETE_COMPLETE, because DeleteStack returns before
  the resources it owns are actually gone.
- SNS/SQS/S3/DynamoDB create helpers register cleanup; delete helpers de-register.
- S3 buckets are emptied via AmazonS3Util.DeleteS3BucketWithObjectsAsync, since S3
  refuses to drop a non-empty bucket and these scenarios upload into it.

Verified: full functional suite 50/50 on net8.0 and net9.0, and the previously
order-dependent subsets (~SNS, ~Scenarios.SNS, ~CloudFormation) now agree.
Two defects in the dynamic version generated for nightly/feature packages.

1. Invalid SemVer between 00:00 and 09:59 UTC.

   The commit-SHA fallback emits DateTime.UtcNow.ToString("HHmmss"), so a build at
   07:58 produced "2.0.0-<branch>.20260729.075853". SemVer 2.0.0 forbids leading
   zeroes in numeric pre-release identifiers and NuGet enforces it, so dotnet pack
   failed with "is not a valid version string" - purely as a function of the time of
   day. Master's last successful deploy ran at 10:38 UTC, which is why this never
   surfaced. Numeric identifiers with a leading zero are now prefixed, keeping them
   alphanumeric, which SemVer permits. A git short SHA that happens to be all digits
   hits the same trap and is covered by the same guard.

2. The commit SHA was never actually captured.

   Cake's StartProcess returned exit code 0 with empty stdout, so every build silently
   fell through to the timestamp branch and no published package has ever identified
   its commit. Reading the process directly makes the capture explicit and independent
   of Cake's redirection behaviour, and the fallback now logs a warning instead of
   passing silently.

Verified locally: dotnet pack now produces 2.0.0-test.20260729.c3176a1, matching
git rev-parse --short HEAD.
upload-artifact rejects '/' in artifact names, and the deploy job only ever runs on
master pushes or feature/* pull requests - so every feature branch deploy failed at
the upload step with "The artifact name is not valid ... Contains the following
character: Forward slash /", after the packages had already been published.

The branch name is now sanitised into SAFE_REF first, mirroring the Replace('/', '-')
that BuildContext already applies when composing the package version.
The pre-4.0.4 compatibility run shared the build-and-test job, which broke reporting.
TestTask names its TRX by target framework alone (net8-0_results.trx), so the second
pass overwrote the first and dorny/test-reporter published the legacy results instead
of the real ones - without the functional tests, which that leg skips. The badge took
the same wrong numbers.

Moved to its own sdk-compat job: separate workspace, no TRX collision, and no test
report at all, since these results are a compatibility gate rather than the suite's
outcome. Deployment now waits on it as well as build-and-test.

.mcp.json is per-developer configuration - it pins a machine-local Rider SSE port and
personal tooling unrelated to building this library - so it follows the convention the
repo already applies to .idea/, *.user and Properties/launchSettings.json. It stays on
disk, only untracked. No history rewrite: the file carries no secret, the GitHub token
is an ${ENV_VAR} reference.
@Blind-Striker Blind-Striker self-assigned this Jul 29, 2026
@Blind-Striker Blind-Striker added the bug Something isn't working label Jul 29, 2026
@Blind-Striker
Blind-Striker requested a review from Copilot July 29, 2026 09:44
The nuspec floor is deliberately raised to AWSSDK.Extensions.NETCore.Setup
4.0.100.5, so proving the pre-4.0.4 constructor shape still works tests a
configuration no consumer can install. Drop the `legacy` track, the
`sdk-compat` CI job and the `SdkCompat` traits that only existed to filter
it, and trim the resolution tests to forward-drift coverage.

A NuGet floor is a minimum, so consumers still float upward and will meet
the next AWS change before the pinned build does. The canary is what
catches that, so it now floats AWSSDK.Core as well - Core's move of
constructor credentials to ExplicitAWSCredentials already bit us once, and
the canary summary was printing a version that could never change.

The canary had a second, quieter problem: --force-restore only means "do
restore", it does not bypass NuGet's HTTP cache, and floating versions
resolve against the cached feed index. Measured locally it resolved Setup
4.0.100.5 instead of 4.0.100.7 - reporting "AWS has not moved" while AWS
had. Clear the HTTP cache before the run.

Also:

- Move $(AwsSetupTrack) and CentralPackageFloatingVersionsEnabled to
  Directory.Build.props; Directory.Packages.props keeps only conditional
  PackageVersion items.
- Validate generated package versions through NuGet.Versioning instead of a
  hand-rolled SemVer rule. NuGetVersion decides whether a pre-release
  identifier is legal, the branch name is sanitised to [0-9A-Za-z-], and the
  composed version is parsed as a backstop so an invalid version fails the
  build with the offending value rather than at pack time.
- Carry build.sh's executable bit in the index, removing five `chmod +x`
  steps across three workflows.
- Give every workflow an explicit permissions block, resolving the CodeQL
  actions/missing-workflow-permissions alerts. build-and-test needs
  checks: write because dorny/test-reporter publishes a check run; the badge
  step uses its own PAT.
- Pin global.json to SDK 9.0.316 and Microsoft.Extensions.*/System.Text.Json
  to 9.0.18. The highest TFM in the repo is net9.0 and there is no net10.0
  target anywhere. Microsoft.SourceLink.GitHub stays on 10.0.301 - it has no
  9.x release. Every Microsoft.Extensions.* reference in src/ carries an
  explicit VersionOverride, so no shipped dependency floor moves.
PackageExtensionVersion 2.0.0 -> 2.0.1. PackageMainVersion stays at 2.0.0:
LocalStack.Client has no source change, and the advisory on its
AWSSDK.Core >= 4.0.0.15 floor is unreachable in practice because
AWSSDK.Extensions.NETCore.Setup 4.0.100.5 declares AWSSDK.Core >= 4.0.100.6,
so NuGet lifts it anyway.

PackageValidationBaselineVersion moves off the stale v1 baselines
(1.4.1 / 1.2.2) to 2.0.0 for both projects. That makes every entry in
LocalStack.Client's CompatibilitySuppressions.xml dead - they compare
lib/net461, lib/net6.0 and lib/net7.0, target frameworks that only existed
in the v1.4.1 package - and package validation fails the build while they
remain, so the file goes.

The CHANGELOG entry is rewritten as terse bullets matching the v2.0.0
section. The previous draft claimed a Microsoft.Extensions.* bump that
consumers never see: every such reference in src/ carries an explicit
VersionOverride, so the only consumer-visible dependency change is the
AWSSDK.Extensions.NETCore.Setup floor.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens LocalStack.Client.Extensions against AWS SDK v4 internal ClientFactory<T> constructor drift (4.0.4+), refreshes dependencies, and adds CI/build switches to continuously validate compatibility across multiple AWS SDK “shapes” (legacy/current/latest canary). It also improves functional-test isolation by introducing centralized per-test resource cleanup and updating sandboxes/tests for upstream dependency API changes.

Changes:

  • Make AwsClientFactoryWrapper select ClientFactory<T> constructors by AWSOptions-first matching (vs exact signature), defaulting trailing args and improving diagnostics; add SDK-shape resolution tests.
  • Introduce AwsSetupTrack (current/legacy/latest) in CPM and propagate it through Cake + CI (including a scheduled canary workflow).
  • Refresh dependency graph and update tests/sandboxes (Testcontainers API changes, AWS SDK Core credential behavior, functional scenario teardown).

Reviewed changes

Copilot reviewed 41 out of 42 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/sandboxes/LocalStack.Container/Program.cs Update Testcontainers LocalStack builder usage.
tests/sandboxes/LocalStack.Client.Sandbox/Program.cs Adjust S3 bucket location access for newer SDK types.
tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/SampleS3Service.cs Adjust S3 bucket location access for newer SDK types.
tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj Add analyzer suppression for logging perf rule.
tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/Program.cs Adjust S3 bucket location access for newer SDK types.
tests/LocalStack.Client.Functional.Tests/TestContainers.cs Update LocalStack builder construction for Testcontainers.
tests/LocalStack.Client.Functional.Tests/Scenarios/SQS/BaseSqsScenario.cs Register created queues for centralized cleanup; untrack on explicit delete.
tests/LocalStack.Client.Functional.Tests/Scenarios/SNS/BaseSnsScenario.cs Register created topics for centralized cleanup; untrack on explicit delete.
tests/LocalStack.Client.Functional.Tests/Scenarios/S3/BaseS3Scenario.cs Register buckets for cleanup (including non-empty buckets); untrack on explicit delete.
tests/LocalStack.Client.Functional.Tests/Scenarios/RealLife/BaseRealLife.cs Register SNS/SQS/subscription resources for cleanup ordering.
tests/LocalStack.Client.Functional.Tests/Scenarios/DynamoDb/BaseDynamoDbScenario.cs Register DynamoDB tables for centralized cleanup; untrack on explicit delete.
tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs Track stacks for cleanup and wait for deletion completion.
tests/LocalStack.Client.Functional.Tests/Scenarios/BaseScenario.cs Introduce per-test async teardown with tracked cleanup registrations.
tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj Add analyzer suppression for logging perf rule.
tests/LocalStack.Client.Functional.Tests/GlobalUsings.cs Add S3 util global using for bucket-with-objects deletion helper.
tests/LocalStack.Client.Functional.Tests/Fixtures/TestFixture.cs Fix nullability variance for in-memory configuration values.
tests/LocalStack.Client.Functional.Tests/CloudFormation/CloudFormationProvisioner.cs Remove null-forgiving and adapt to updated nullability expectations.
tests/LocalStack.Client.Extensions.Tests/ServiceCollectionExtensionsTests.cs Fix nullability variance for in-memory configuration values.
tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs Add trait tagging for SDK-compat coverage.
tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs New tests modeling multiple ClientFactory<T> constructor shapes.
tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs Update credential exposure to match AWSSDK.Core >= 4.0.100 behavior.
src/LocalStack.Client/README.md Add Discussions/community section.
src/LocalStack.Client/LICENSE.txt Update copyright year.
src/LocalStack.Client.Extensions/README.md Add Discussions/community section.
src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj Expose internals to tests for constructor-resolution verification.
src/LocalStack.Client.Extensions/LICENSE.txt Update copyright year.
src/LocalStack.Client.Extensions/GlobalUsings.cs Add System.Text for constructor signature formatting.
src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs Implement constructor selection + argument building + improved diagnostics.
LICENSE Update copyright year.
global.json Bump pinned .NET SDK version.
Directory.Packages.props Add AwsSetupTrack switch and refresh package versions across the graph.
CHANGELOG.md Add unreleased entry documenting the fix + dependency/CI updates.
build/LocalStack.Build/GlobalUsings.cs Add suppressions/attributes support for build tooling changes.
build/LocalStack.Build/CakeTasks/TestTask.cs Ensure dotnet test uses the same AwsSetupTrack as build.
build/LocalStack.Build/CakeTasks/Nuget/Services/PackageOperations.cs Add regex timeout to avoid potential pathological matches.
build/LocalStack.Build/CakeTasks/BuildTask.cs Propagate AwsSetupTrack into MSBuild and log selected track.
build/LocalStack.Build/BuildContext.cs Add aws-setup-track argument and harden SemVer pre-release identifier generation.
.gitignore Ignore per-developer .mcp.json.
.github/workflows/publish-nuget.yml Bump action versions and align setup-dotnet with global.json.
.github/workflows/dependency-review.yml Bump action versions.
.github/workflows/ci-cd.yml Bump action versions, install Mono on macOS, add legacy SDK compat job, sanitize artifact names.
.github/workflows/aws-sdk-canary.yml New scheduled canary workflow floating AWS SDK versions.

Comment thread src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs Outdated
The slnx was added in 7669a28 but BuildContext still pointed Cake at
LocalStack.sln, so every build, test and pack ran through the old file while the
new one was only used by hand. The two listed exactly the same twelve projects,
but that was maintained by hand - nothing enforced it, and a project added to one
and not the other would have silently dropped out of CI.

Point $(SlnFilePath) at LocalStack.slnx and delete the .sln.

LocalStack.sln.DotSettings is renamed to LocalStack.slnx.DotSettings. Rider
resolves shared settings by solution file name, so with the .slnx open the 24 KB
of shared naming, formatting and inspection-severity settings were not being
applied at all. The file has no internal reference to the solution name, so the
rename is sufficient.
Anyone installing LocalStack.Client.Extensions 2.0.0 today still hits issue #52:
its floor is [4.0.2, ), so NuGet floats them straight onto 4.0.4+ where the
internal ClientFactory<T> constructor changed. The exception text is what people
will search for, so the README is where the answer belongs - with the upgrade to
2.0.1 as the fix and pinning AWSSDK.Extensions.NETCore.Setup to 4.0.3.40 as the
workaround for anyone who cannot upgrade yet.

Also record why ClientFactoryGenericTypeName and CreateServiceClientMethodName
are 'static readonly' and not 'const'. CA1802 and Sonar S3962 both flag them, and
following that advice compiles but breaks two tests: AwsClientFactoryWrapperTests
overwrites those fields by reflection to simulate AWS renaming its internals, and
a const is inlined at every use site, so the overwrite would be a no-op and the
failure-path tests would silently stop testing anything. The suppression is
load-bearing; the comment says so.

The two README copies under src/ are refreshed by the PreBuild target and ship
inside the packages, so they move with the root file.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 46 out of 49 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs:99

  • DeleteStackAndWaitAsync treats any AmazonCloudFormationException during DescribeStacks as a success signal. This can incorrectly report successful deletion on transient errors (throttling/network/auth), potentially leaking stacks and making later tests order-dependent again. It’s safer to only treat the specific “stack does not exist” case as success and let other exceptions fail the test (they’ll be aggregated by BaseScenario.DisposeAsync).
    build/LocalStack.Build/BuildContext.cs:91
  • The AwsSetupTrack XML doc comment still mentions a legacy track (“pre-4.0.4”), but the PR description says the legacy/sdk-compat track was removed and Directory.Build.props only documents current and latest. This comment is now misleading for contributors.
    /// <summary>
    /// Which AWSSDK.Extensions.NETCore.Setup constructor shape to build and test against
    /// (current = post-4.0.4, legacy = pre-4.0.4, latest = floating canary).
    /// </summary>
    public string AwsSetupTrack { get; }

tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs:139

  • The real-SDK resolution test uses the null-forgiving operator on Assembly.GetType(...). If AWS renames/moves ClientFactory<T>, this will fail with an unhelpful NullReferenceException instead of a clear assertion failure explaining what type couldn’t be found.
        // Deliberately shape-agnostic: this also runs on the floating canary track, where it must go red
        // only when we genuinely cannot resolve - not merely because AWS appended another parameter.
        Type factoryType = typeof(ConfigurationException).Assembly.GetType("Amazon.Extensions.NETCore.Setup.ClientFactory`1")!
                                                         .MakeGenericType(typeof(IAmazonS3));

        ConstructorInfo[] candidates = factoryType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance);
        ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(candidates);

DeleteStackAndWaitAsync caught every AmazonCloudFormationException from
DescribeStacks and returned as though deletion had completed. Throttling,
credential and service errors would all have been read as success, leaving the
stack - and the SNS topic and SQS queue its template owns - behind in the shared
container for whichever test ran next. That is the exact cross-test pollution
this teardown was written to eliminate, so the swallow defeated its own purpose.

Narrow the catch to a ValidationError, which is what CloudFormation answers with
once the stack is gone, and let anything else fail the cleanup loudly.

Verified against real LocalStack containers: CloudFormation scenarios 2/2 and
the full functional suite 50/50 on net8.0, confirming LocalStack does report
ValidationError for a deleted stack rather than some other error code.

Raised by Copilot review on PR #53.
DescribeConstructors rendered parameter types with Type.Name, which collapses
Action<ClientConfig, IServiceProvider> to "Action`2" - dropping precisely the
part that identifies the parameter AWS added in 4.0.4. That text is what a bug
report quotes, and diagnosing the next signature change without a version bisect
is the whole reason the discovered signatures are included in the exception.

Spell the arguments out, so the message reads
.ctor(AWSOptions, Action<ClientConfig, IServiceProvider>).

Uses Split rather than IndexOf because the StringComparison overload CA1307 asks
for does not exist on netstandard2.0, which this project still targets.

Covered by a test asserting the arguments survive and no backtick remains.

Raised by Copilot review on PR #53.
The ValidationError filter added in cba7309 was justified with a claim the
evidence did not actually support: the functional suite passing is also
consistent with the catch never being entered, because the poll loop can exit
through the DELETE_COMPLETE branch instead. A filter that always returned false
would have passed the same tests.

Measured it directly against both LocalStack versions these scenarios run on.
Creating a stack, deleting it, then describing it by name gives, identically on
3.7.1 and 4.6.0:

  AmazonCloudFormationException
    ErrorCode  = ValidationError
    StatusCode = 400 BadRequest
    Message    = Stack with id <name> does not exist

The throw also arrives on the very first poll - LocalStack drops the stack
outright rather than parking it in DELETE_COMPLETE - so this catch is the loop's
real exit path rather than a fallback, which is the part the passing suite could
not have told us.

Comment only; the filter itself is unchanged and now carries its evidence.
@Blind-Striker
Blind-Striker marked this pull request as ready for review July 29, 2026 10:57
@Blind-Striker
Blind-Striker merged commit 6513676 into master Jul 29, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Still having issues with AWS SDK v4

3 participants