diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e9307..75ae2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +## [0.10.1] - 2026-07-24 + +Maintenance release: the deferred "thorough-review" hardening tier plus the +`Wolfgang.Etl.Abstractions` 0.17.0 bump. **No public API or behaviour change** to +either shipped package โ€” the test doubles and contract-test base classes are +unchanged. + +### Changed + +- Built against `Wolfgang.Etl.Abstractions` 0.17.0 (was 0.15.0). + +### Security + +- Release now publishes via **OIDC / NuGet Trusted Publishing** (`NuGet/login`), removing + the long-lived `NUGET_API_KEY` from the release path. +- Added supply-chain / security CI: transitive-dependency **license audit**, **CycloneDX SBOM**, + **OSSF Scorecard**, **Semgrep** SAST, GitHub **Actions audit** (actionlint + zizmor, all + actions SHA-pinned), and **build-reproducibility** verification with a per-release + reproducible-build manifest attached to each GitHub Release. +- Documented the release path and compromise scope in `SECURITY.md`, and added a + consumer-side reproducible-build verification guide (`docs/REPRODUCIBLE-BUILD.md`). + ## [0.10.0] - 2026-06-29 Adds an opt-in contract-test base for the `ISupportDryRun` interface introduced in diff --git a/README.md b/README.md index 99a8e0a..7db0898 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ An Extractor, Transformer and Loader designed to be used in testing libraries bu [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![.NET](https://img.shields.io/badge/.NET-Multi--Targeted-purple.svg)](https://dotnet.microsoft.com/) [![GitHub](https://img.shields.io/badge/GitHub-Repository-181717?logo=github)](https://github.com/Chris-Wolfgang/ETL-Test-Kit) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Chris-Wolfgang/ETL-Test-Kit/badge)](https://scorecard.dev/viewer/?uri=github.com/Chris-Wolfgang/ETL-Test-Kit) --- @@ -239,17 +240,15 @@ public sealed class MyLoaderContractTests --- -## ๐ŸŽฏ Target Frameworks +## ๐ŸŽฏ Supported Frameworks -Both packages multi-target the following frameworks: +This library targets: -| Framework | Versions | -|-----------|----------| -| .NET Framework | .NET 4.6.2, .NET 4.8.1 | -| .NET Standard | .NET Standard 2.0 | -| .NET | .NET 8.0, .NET 10.0 | +- **.NET Framework:** 4.6.2, 4.8.1 +- **.NET Standard:** 2.0 +- **.NET:** 8.0, 10.0 ---- +See the [NuGet package page](https://www.nuget.org/packages/Wolfgang.Etl.TestKit/) for the authoritative per-TFM compatibility matrix. ## ๐Ÿ” Code Quality & Static Analysis @@ -357,6 +356,16 @@ docfx build --serve --- +## ๐Ÿ” Verify the build + +Every release is built deterministically, and each GitHub Release attaches a +`reproducible-build-manifest.json` with the SHA-256 of every shipped assembly. +You can independently rebuild from the tag and confirm the hashes match โ€” see +[docs/REPRODUCIBLE-BUILD.md](docs/REPRODUCIBLE-BUILD.md) for the step-by-step +procedure and how to publish a third-party attestation. + +--- + ## ๐Ÿค Contributing Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for: diff --git a/REPRODUCIBLE-BUILD.md b/REPRODUCIBLE-BUILD.md new file mode 100644 index 0000000..e2143ad --- /dev/null +++ b/REPRODUCIBLE-BUILD.md @@ -0,0 +1,50 @@ +# Reproducible builds + +Both shipped assemblies โ€” `Wolfgang.Etl.TestKit` and `Wolfgang.Etl.TestKit.Xunit` +โ€” are built to be **byte-for-byte reproducible**: the same source commit produces +the same compiled output regardless of *where* it is built. + +## What makes the build reproducible + +`Directory.Build.props` sets the compiler inputs that a reproducible build +requires: + +- `true` โ€” the compiler emits deterministic + output (no embedded timestamps, ordered metadata). +- `true` (in CI) โ€” + normalises embedded source paths to a deterministic `/_/` root via `PathMap`, + so the checkout directory does not leak into the assembly. +- SourceLink โ€” embeds the commit SHA rather than machine-local paths. + +## How it is verified + +[`.github/workflows/reproducible-build.yaml`](.github/workflows/reproducible-build.yaml) +checks the same commit out to two independent directories, builds each with +`-p:ContinuousIntegrationBuild=true`, and fails if the produced `.dll`s do not +hash identically (`sha256sum`). This proves **path-independent** reproducibility +on a single runner โ€” the property that lets a third party rebuild and match. + +## How to verify it yourself + +```bash +git clone https://github.com/Chris-Wolfgang/ETL-Test-Kit a +git clone https://github.com/Chris-Wolfgang/ETL-Test-Kit b +for d in a b; do + dotnet build "$d/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj" \ + -c Release -f net10.0 -p:ContinuousIntegrationBuild=true +done +sha256sum \ + a/src/Wolfgang.Etl.TestKit/bin/Release/net10.0/Wolfgang.Etl.TestKit.dll \ + b/src/Wolfgang.Etl.TestKit/bin/Release/net10.0/Wolfgang.Etl.TestKit.dll +# The two hashes must be identical. +``` + +## Scope / follow-up + +The verification above covers path-independent reproducibility on a single OS โ€” +the fleet-proven guarantee. **Cross-OS** byte-identity (building on Ubuntu vs +Windows and matching) is a stronger claim that is not yet asserted here: `.pdb` +and some embedded metadata can differ across SDK patch levels and operating +systems even with deterministic inputs. Extending the matrix to cross-OS +comparison (with any required `.pdb`/metadata normalisation) is tracked as a +follow-up to #135. diff --git a/SECURITY.md b/SECURITY.md index e2bfe41..fd3c73f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,3 +21,15 @@ We will acknowledge your report within 48 hours and provide an estimated timelin Your help is greatly appreciated! Responsible disclosure of security vulnerabilities helps protect our entire community. + +## Release path & compromise scope + +Facts a maintainer would need at 2am if the release identity is compromised. Generic incident-response steps (rotating credentials, revoking OAuth apps, publishing advisories, unlisting NuGet packages) are not duplicated here โ€” GitHub's and NuGet's own docs update faster than a checked-in runbook. + +- **Release path**: OIDC / NuGet Trusted Publishing via `NuGet/login@v1` in `.github/workflows/release.yaml`. The workflow mints an ephemeral push token per run via OIDC โ€” the release path does not depend on a long-lived API key stored in GitHub secrets or on the NuGet account. During an incident, check the NuGet account for any long-lived API keys anyway (they can be created outside of CI) and delete anything you don't recognize. +- **Fallback**: none. If Trusted Publishing is compromised, the incident is at the GitHub-account level (the OIDC identity is `Chris-Wolfgang/ETL-Test-Kit`). +- **Owner**: @Chris-Wolfgang. +- **Downstream consumers**: known Wolfgang.* dependents (test projects) include ETL-Xml, ETL-FixedWidth, Etl-DbClient, ETL-Json, and ETL-Transformers; unknown external consumers may also exist on nuget.org. +- **Package coordinates for unlisting**: this repo ships two packages โ€” + - `Wolfgang.Etl.TestKit` โ€” https://www.nuget.org/packages/Wolfgang.Etl.TestKit/ + - `Wolfgang.Etl.TestKit.Xunit` โ€” https://www.nuget.org/packages/Wolfgang.Etl.TestKit.Xunit/ diff --git a/docfx_project/docfx.json b/docfx_project/docfx.json index 1c85224..86dae76 100644 --- a/docfx_project/docfx.json +++ b/docfx_project/docfx.json @@ -1,65 +1,65 @@ -{ - "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", - "metadata": [ - { - "src": [ - { - "files": [ - "src/**/*.csproj" - ], - "src": "../" - } - ], - "dest": "api", - "properties": { - "TargetFramework": "net8.0" - }, - "disableGitFeatures": false, - "disableDefaultFilter": false - } - ], - "build": { - "content": [ - { - "files": [ - "**/*.{md,yml}" - ], - "exclude": [ - "_site/**" - ] - } - ], - "resource": [ - { - "files": [ - "logo.svg", - "apple-touch-icon.png", - "favicon.svg", - "favicon.ico", - "images/**", - "public/**", - "versions.json" - ] - } - ], - "output": "_site", - "template": [ - "default", - "modern" - ], - "globalMetadata": { - "_appName": "Wolfgang.Etl.TestKit", - "_appTitle": "Wolfgang.Etl.TestKit Documentation", - "_appLogoPath": "logo.svg", - "_appFaviconPath": "favicon.svg", - "_enableSearch": true, - "_appFooter": "Made with DocFX ", - "_disableSidebar": false, - "_disableTocFilter": false, - "_enableDarkMode": true, - "colorMode": "dark", - "_baseUrl": "https://Chris-Wolfgang.github.io/ETL-Test-Kit/", - "pdf": true - } - } -} +{ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "metadata": [ + { + "src": [ + { + "files": [ + "src/**/*.csproj" + ], + "src": "../" + } + ], + "dest": "api", + "properties": { + "TargetFramework": "net8.0" + }, + "disableGitFeatures": false, + "disableDefaultFilter": false + } + ], + "build": { + "content": [ + { + "files": [ + "**/*.{md,yml}" + ], + "exclude": [ + "_site/**" + ] + } + ], + "resource": [ + { + "files": [ + "logo.svg", + "apple-touch-icon.png", + "favicon.svg", + "favicon.ico", + "images/**", + "public/**", + "versions.json" + ] + } + ], + "output": "_site", + "template": [ + "default", + "modern" + ], + "globalMetadata": { + "_appName": "Wolfgang.Etl.TestKit", + "_appTitle": "Wolfgang.Etl.TestKit Documentation", + "_appLogoPath": "logo.svg", + "_appFaviconPath": "favicon.svg", + "_enableSearch": true, + "_appFooter": "Made with DocFX ", + "_disableSidebar": false, + "_disableTocFilter": false, + "_enableDarkMode": true, + "colorMode": "dark", + "_baseUrl": "https://Chris-Wolfgang.github.io/ETL-Test-Kit/", + "pdf": true + } + } +} diff --git a/docs/REPRODUCIBLE-BUILD.md b/docs/REPRODUCIBLE-BUILD.md new file mode 100644 index 0000000..34fa3e3 --- /dev/null +++ b/docs/REPRODUCIBLE-BUILD.md @@ -0,0 +1,91 @@ +# Verifying the build is reproducible + +Every release of `Wolfgang.Etl.TestKit` and `Wolfgang.Etl.TestKit.Xunit` is built +deterministically: the same source at the same tag produces byte-identical +assemblies, independent of who builds it or where. This page lets **you** confirm +that independently, so "our builds are reproducible" is a checkable claim rather +than a promise. + +CI already proves *same-environment* reproducibility on every push +([`reproducible-build.yaml`](../.github/workflows/reproducible-build.yaml), #135): +it builds the library twice and asserts the assembly hashes match. This document +is the *consumer-side* flip โ€” how a third party reproduces and attests to it. + +## What is published + +Each GitHub Release attaches a **`reproducible-build-manifest.json`** listing the +SHA-256 of every shipped assembly, plus the reference environment (OS and .NET SDK +version) and the exact build command used to produce them. Example: + +```json +{ + "schema": "wolfgang.reproducible-build-manifest/v1", + "version": "v0.11.0", + "targetFramework": "net10.0", + "buildCommand": "dotnet build -c Release -f net10.0 -p:ContinuousIntegrationBuild=true", + "referenceEnvironment": { "os": "Linux", "dotnetSdk": "10.0.110" }, + "assemblies": [ + { "assembly": "Wolfgang.Etl.TestKit.dll", "sha256": "โ€ฆ" }, + { "assembly": "Wolfgang.Etl.TestKit.Xunit.dll", "sha256": "โ€ฆ" } + ] +} +``` + +## Reproduce it yourself + +1. **Match the reference environment.** Use the same OS family and .NET SDK + version named in the release's manifest (`referenceEnvironment`). + `ContinuousIntegrationBuild=true` normalises source paths, so the *checkout + location* does not matter โ€” but the compiler version does, so match the SDK. + +2. **Clone at the exact tag:** + + ```bash + git clone --branch --depth 1 https://github.com/Chris-Wolfgang/ETL-Test-Kit + cd ETL-Test-Kit + ``` + +3. **Build each library with the documented command** (the manifest's + `buildCommand`): + + ```bash + dotnet build src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj \ + -c Release -f net10.0 -p:ContinuousIntegrationBuild=true + dotnet build src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj \ + -c Release -f net10.0 -p:ContinuousIntegrationBuild=true + ``` + +4. **Hash your output and compare** against the manifest: + + ```bash + sha256sum \ + src/Wolfgang.Etl.TestKit/bin/Release/net10.0/Wolfgang.Etl.TestKit.dll \ + src/Wolfgang.Etl.TestKit.Xunit/bin/Release/net10.0/Wolfgang.Etl.TestKit.Xunit.dll + ``` + + Each hash must equal the corresponding `sha256` in + `reproducible-build-manifest.json`. The repo's own generator + ([`scripts/reproducible-manifest.sh`](../scripts/reproducible-manifest.sh)) + runs exactly these steps, so you can also regenerate the whole manifest and + `diff` it against the published one. + +## If a hash does not match + +A mismatch means either the environments differ (most commonly a different SDK +patch version) or the artifact was tampered with. Please +[open an issue](https://github.com/Chris-Wolfgang/ETL-Test-Kit/issues/new) titled +"Reproducible-build mismatch for ``" including: + +- the release tag, +- your OS and `dotnet --version`, +- your computed hashes vs the manifest's, +- the exact commands you ran. + +## Publishing a third-party attestation + +Independent verification is most useful when it is *public*. If you reproduced a +release successfully, you can publish an attestation following the +[Reproducible Builds project](https://reproducible-builds.org/) conventions (or a +service such as [vouchsafe.io](https://vouchsafe.io/)): sign a statement naming the +tag, the manifest hash, and your environment, and link it back on the mismatch/ +verification issue so others can find corroborating rebuilds. diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md new file mode 100644 index 0000000..a32da3a --- /dev/null +++ b/docs/adr/0001-record-architecture-decisions.md @@ -0,0 +1,27 @@ +# 1. Record architecture decisions + +## Status + +Accepted + +## Context + +Wolfgang.Etl.TestKit is a small but long-lived pair of NuGet packages (the test +doubles and the xUnit contract-test base classes) maintained across many release +cycles, often by different contributors and automated agents. Several non-obvious +design choices โ€” the pinned `AssemblyVersion`, the split into two packages, the +injectable progress timer โ€” are easy to accidentally undo in a later change +because the *reasoning* lives only in commit messages or a reviewer's memory. + +## Decision + +We will keep Architecture Decision Records in `docs/adr/`, one Markdown file per +decision, in the Nygard format (Context / Decision / Consequences). Records are +immutable once accepted; a changed decision is captured as a new, superseding ADR. + +## Consequences + +- The rationale behind load-bearing choices is discoverable next to the code. +- Reviewers can point at an ADR instead of re-litigating a settled decision. +- There is a small ongoing cost: a genuinely architectural change should come + with an ADR, not just code. diff --git a/docs/adr/0002-pin-assemblyversion-for-binding-stability.md b/docs/adr/0002-pin-assemblyversion-for-binding-stability.md new file mode 100644 index 0000000..24783c2 --- /dev/null +++ b/docs/adr/0002-pin-assemblyversion-for-binding-stability.md @@ -0,0 +1,33 @@ +# 2. Pin AssemblyVersion at 1.0.0.0 for binding stability + +## Status + +Accepted + +## Context + +Both packages ship to NuGet and target .NET Framework TFMs (net462, net481) in +addition to modern .NET. On .NET Framework, the CLR binds by the assembly's +**strong `AssemblyVersion`**: if `AssemblyVersion` tracks the package version, +every minor/patch bump changes the bind identity, so a consumer that references +`1.2.0` but resolves `1.3.0` at runtime needs an assembly binding redirect or +fails to load. For a *test-support* library pulled transitively into many test +projects, that friction is disproportionate to the value. + +## Decision + +We will pin `` at `1.0.0.0` and let `` and +`` (derived from ``) carry the real release +version. `AssemblyVersion` is bumped **only** on a deliberate breaking API change +(a new major), never on a minor/patch release. + +## Consequences + +- Consumers do not need binding redirects when a minor/patch bump flows in + transitively โ€” the bind identity is stable across the whole `1.x` line. +- The actual shipped version is still discoverable via file/informational + version and the NuGet package version. +- The pin is load-bearing: a reviewer must not "fix" `AssemblyVersion` to match + the package version. A major bump is the only time it moves. +- Binary compatibility within a bind identity is additionally guarded by + PackageValidation (see the ABI-gate decision and `EnablePackageValidation`). diff --git a/docs/adr/0003-split-doubles-and-contract-tests-into-two-packages.md b/docs/adr/0003-split-doubles-and-contract-tests-into-two-packages.md new file mode 100644 index 0000000..54edb35 --- /dev/null +++ b/docs/adr/0003-split-doubles-and-contract-tests-into-two-packages.md @@ -0,0 +1,39 @@ +# 3. Ship the test doubles and the contract-test base classes as two packages + +## Status + +Accepted + +## Context + +The kit provides two distinct kinds of test support: + +1. **Test doubles** (`TestExtractor`, `TestLoader`, `TestTransformer`, + and their `Faulty*` variants) โ€” concrete, framework-agnostic implementations + of the Abstractions base classes, usable from any test runner (or none). +2. **Contract-test base classes** (`ExtractorBaseContractTests<โ€ฆ>`, etc.) โ€” + abstract xUnit `[Fact]`/`[Theory]` suites a downstream library subclasses to + verify its own extractor/loader/transformer honours the Abstractions contract. + +The second kind hard-depends on xUnit; the first does not. Bundling them would +force every consumer of the doubles to take an xUnit dependency, including +consumers on MSTest/NUnit or using the doubles outside a test project (e.g. in +benchmarks or samples). + +## Decision + +We will ship two packages: **`Wolfgang.Etl.TestKit`** (the doubles, no test- +framework dependency) and **`Wolfgang.Etl.TestKit.Xunit`** (the contract-test +base classes, depends on `Wolfgang.Etl.TestKit` + xUnit). The Xunit package +references the core package by ProjectReference in-repo and by NuGet version +downstream. + +## Consequences + +- Consumers who only need the doubles do not pay for xUnit. +- The xUnit-specific contract suites live behind an explicit, separately-versioned + package boundary; a future MSTest/NUnit contract package can be added without + touching the core. +- Both packages must be versioned and released together in lock-step for the + Xunit package's dependency on the core to resolve; the release pipeline packs + both. diff --git a/docs/adr/0004-deterministic-progress-timer-via-injection.md b/docs/adr/0004-deterministic-progress-timer-via-injection.md new file mode 100644 index 0000000..af3bb9b --- /dev/null +++ b/docs/adr/0004-deterministic-progress-timer-via-injection.md @@ -0,0 +1,39 @@ +# 4. Make progress reporting deterministic via an injectable timer + +## Status + +Accepted + +## Context + +The Abstractions base classes report progress on a wall-clock interval +(`ReportingInterval`) driven by an internal timer. A test that wants to assert +"a progress report was raised" cannot depend on a real timer firing: the test +would either sleep (slow, flaky) or race the timer against a fast synchronous +source that completes โ€” and unsubscribes the timer's `Elapsed` handler in the +worker's `finally` โ€” before the timer ever fires. + +## Decision + +We will expose progress-timer injection as a first-class part of the doubles and +the contract-test base classes: + +- Every double offers a constructor overload taking an `IProgressTimer`, and the + kit ships **`ManualProgressTimer`** whose `Start`/`Stop` are no-ops and which + only raises `Elapsed` when `Fire()` is called explicitly. +- The contract-test base classes drive progress assertions by pulling the first + item (so the pipeline is mid-flight), calling `Fire()` deterministically, then + draining the rest โ€” never by waiting on wall-clock time. +- Implementations wiring an injected timer guard against duplicate `Elapsed` + subscriptions when `CreateProgressTimer` is overridden. + +## Consequences + +- Progress-callback tests are fast and deterministic โ€” no sleeps, no timing races. +- The injection seam is public API surface (the `IProgressTimer` constructor + overloads and `ManualProgressTimer`) and is therefore guarded by the + PublicAPI baseline and PackageValidation; it cannot be removed without a + breaking-change bump. +- Test authors must fire the timer while the pipeline is mid-flight; firing it + after a synchronous source has drained observes no callback (documented in the + `ManualProgressTimer` example). diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..bbbab89 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,24 @@ +# Architecture Decision Records + +This directory records the significant architecture / design decisions for +**Wolfgang.Etl.TestKit** and **Wolfgang.Etl.TestKit.Xunit**, using lightweight +[Architecture Decision Records (ADRs)](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions). + +An ADR captures a single decision: the context that forced it, the decision +itself, and the consequences (good and bad). ADRs are immutable once accepted โ€” +when a decision changes, add a **new** ADR that supersedes the old one rather +than editing history. + +- **[index.md](index.md)** โ€” the list of all ADRs and their status. +- **[TEMPLATE.md](TEMPLATE.md)** โ€” the skeleton to copy when adding one. + +## Adding an ADR + +1. Copy [`TEMPLATE.md`](TEMPLATE.md) to `NNNN-short-title.md`, numbering it with + the next free 4-digit sequence. +2. Fill in Context / Decision / Consequences (Nygard style). +3. Set the status to `Proposed`, then `Accepted` once agreed (or + `Superseded by ADR-NNNN`). +4. Add a row to [`index.md`](index.md). +5. Land the ADR alongside the PR that introduces the decision, so it is part of + the review. diff --git a/docs/adr/TEMPLATE.md b/docs/adr/TEMPLATE.md new file mode 100644 index 0000000..b2adfc4 --- /dev/null +++ b/docs/adr/TEMPLATE.md @@ -0,0 +1,22 @@ +# N. Short title of the decision + +## Status + +Proposed | Accepted | Superseded by [ADR-NNNN](NNNN-....md) + +## Context + +What is the issue or force that motivates this decision? Describe the facts and +constraints โ€” technical, product, or process โ€” that are driving the choice. +State the problem, not the solution. + +## Decision + +The change we are making, in active voice: "We will ...". Be specific enough +that a future reader can tell whether later code still honours it. + +## Consequences + +What becomes easier or harder as a result โ€” the good, the bad, and the neutral. +Include any follow-up work, risks, or constraints the decision imposes (e.g. a +test that guards it, or a rule new code must follow). diff --git a/docs/adr/index.md b/docs/adr/index.md new file mode 100644 index 0000000..20966da --- /dev/null +++ b/docs/adr/index.md @@ -0,0 +1,8 @@ +# ADR Index + +| ADR | Title | Status | +| --- | --- | --- | +| [0001](0001-record-architecture-decisions.md) | Record architecture decisions | Accepted | +| [0002](0002-pin-assemblyversion-for-binding-stability.md) | Pin AssemblyVersion at 1.0.0.0 for binding stability | Accepted | +| [0003](0003-split-doubles-and-contract-tests-into-two-packages.md) | Ship the test doubles and the contract-test base classes as two packages | Accepted | +| [0004](0004-deterministic-progress-timer-via-injection.md) | Make progress reporting deterministic via an injectable timer | Accepted | diff --git a/docs/migrations/README.md b/docs/migrations/README.md new file mode 100644 index 0000000..af62752 --- /dev/null +++ b/docs/migrations/README.md @@ -0,0 +1,12 @@ +# Migration guides + +Per-major-version upgrade guides live here, named `vX-to-vY.md`, created during +release prep for any major bump (0.x โ†’ 1.0 with breaking changes, or 1.0 โ†’ 2.0) +and linked from the corresponding GitHub Release notes. A guide covers both +shipped packages (`Wolfgang.Etl.TestKit` and `Wolfgang.Etl.TestKit.Xunit`), which +are released in lock-step. + +No major version has shipped yet โ€” ETL-Test-Kit is in its `0.x` line โ€” so only +the [template](TEMPLATE-major-version-migration.md) exists. The convention is +established now so the first major release can use it without inventing structure +under time pressure. diff --git a/docs/migrations/TEMPLATE-major-version-migration.md b/docs/migrations/TEMPLATE-major-version-migration.md new file mode 100644 index 0000000..0c8db96 --- /dev/null +++ b/docs/migrations/TEMPLATE-major-version-migration.md @@ -0,0 +1,47 @@ +# Migrating from vX to vY + +> Copy this template to `vX-to-vY.md` during release prep for a major version +> bump, fill in each section, and link it from the GitHub Release notes. Delete +> any section that does not apply (but prefer "None" over deletion so readers know +> it was considered). + +## Summary + +One paragraph: who is affected, roughly how much work the upgrade is, and whether +a compatibility shim exists. + +## Breaking-change inventory + +| API | Change | Replacement | +| --- | --- | --- | +| `OldType.OldMember` | removed / renamed / behaviour change | `NewType.NewMember` | + +## Before / after + +```csharp +// Before (vX) +``` + +```csharp +// After (vY) +``` + +Repeat per breaking change that needs a code edit. + +## Behavioural changes (no signature change) + +Changes that compile unchanged but behave differently at runtime (default-value +changes, nullability flips, parsing-tolerance changes). These are the dangerous +ones โ€” call each out explicitly. + +## Deprecation timeline + +- **vX**: member marked `[Obsolete]` (warning). +- **vY**: member removed. + +State when deprecated members were first warned about and when they were removed. + +## Verifying the upgrade + +How a consumer confirms the migration succeeded (build clean, tests green, ABI +check via the release `api-compat` gate). diff --git a/src/Wolfgang.Etl.TestKit.Xunit/ManualProgressTimer.cs b/src/Wolfgang.Etl.TestKit.Xunit/ManualProgressTimer.cs index 1fa9c1c..cd84696 100644 --- a/src/Wolfgang.Etl.TestKit.Xunit/ManualProgressTimer.cs +++ b/src/Wolfgang.Etl.TestKit.Xunit/ManualProgressTimer.cs @@ -21,9 +21,8 @@ namespace Wolfgang.Etl.TestKit.Xunit; /// /// /// -/// // In your concrete contract test class: -/// protected override MyExtractor CreateSutWithTimer(IProgressTimer timer) => -/// new MyExtractor(sourceData, timer); +/// // In your concrete contract test class, override CreateSutWithTimer to return +/// // your SUT wired to the supplied timer (see ExtractorBaseContractTests). /// /// // The base class uses it like this in the progress callback test: /// var timer = new ManualProgressTimer(); diff --git a/src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj b/src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj index 28b7142..11d6733 100644 --- a/src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj +++ b/src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj @@ -4,7 +4,7 @@ net462;net481;netstandard2.0;net8.0;net10.0 latest disable - 0.10.0 + 0.10.1 + true + 0.10.0 Wolfgang.Etl.TestKit.Xunit Abstract xUnit contract test base classes for verifying custom ETL extractors, transformers, and loaders built on Wolfgang.Etl.Abstractions. https://github.com/Chris-Wolfgang/ETL-Test-Kit diff --git a/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj b/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj index a35157c..e6d516e 100644 --- a/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj +++ b/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj @@ -4,7 +4,7 @@ net462;net481;netstandard2.0;net8.0;net10.0 latest disable - 0.10.0 + 0.10.1 + true + 0.10.0 Wolfgang.Etl.TestKit An implementation of Extractor, Transformer and Loader for use in ETL examples and benchmarks. Built on Wolfgang.Etl.Abstractions. https://github.com/Chris-Wolfgang/ETL-Test-Kit diff --git a/tests/Wolfgang.Etl.TestKit.Tests.Unit/AllocationRegressionTests.cs b/tests/Wolfgang.Etl.TestKit.Tests.Unit/AllocationRegressionTests.cs new file mode 100644 index 0000000..310ce5f --- /dev/null +++ b/tests/Wolfgang.Etl.TestKit.Tests.Unit/AllocationRegressionTests.cs @@ -0,0 +1,143 @@ +#if NET6_0_OR_GREATER +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Wolfgang.Etl.TestKit; +using Xunit; + +namespace Wolfgang.Etl.TestKit.Tests.Unit; + +/// +/// Guards the doubles' per-item hot path against allocation regressions (#136). +/// +/// +/// The doubles exist to feed benchmarks of the real ETL code, so any per-item +/// allocation they introduce shows up as noise in those measurements. The +/// enumeration hot paths below are intended to be zero-allocation per item +/// (a constant, one-time setup cost independent of the item count): +/// +/// via ExtractAsync() +/// via LoadAsync(...) with collectItems: false +/// via TransformAsync(...) +/// +/// Each test measures allocation over N and 10ยทN items and asserts the marginal +/// per-item allocation stays below a small threshold โ€” a real regression (a +/// per-item List.Add, boxing an int at ~24 B/item, an LINQ closure) +/// pushes the marginal cost far above it, while one-time setup and background +/// noise cancel out of the delta. Measured baseline: ~0 B/item on all three. +/// Guarded to net6.0+ where exists. +/// +public sealed class AllocationRegressionTests +{ + // Real per-item regressions allocate >= 24 bytes/item (boxing) or an amortized + // slab (List growth). 4 bytes/item is comfortably above measurement noise yet + // an order of magnitude below any genuine per-item allocation. + // Real per-item regressions allocate >= 24 bytes/item (boxing) or an amortized + // slab (List growth). 8 bytes/item sits an order of magnitude below that while + // tolerating the background-allocation noise the process-wide counter picks up + // on a shared CI runner (this budget was 4.0 and proved flaky on linux-x64). + private const double MaxBytesPerItem = 8.0; + + // A large denominator amortizes any fixed background-allocation spike that + // lands inside a measurement window: 450k marginal items means even a 1 MB + // stray allocation only reads as ~2.3 B/item. + private const int BaseCount = 50_000; + + private const int Attempts = 5; + + [Fact] + public async Task ExtractAsync_does_not_allocate_per_item() + { + await AssertZeroAllocPerItem(async count => + { + var extractor = new TestExtractor(new int[count]); + var seen = 0; + await foreach (var _ in extractor.ExtractAsync()) + { + seen++; + } + + return seen; + }); + } + + [Fact] + public async Task LoadAsync_does_not_allocate_per_item() + { + await AssertZeroAllocPerItem(async count => + { + var loader = new TestLoader(collectItems: false); + await loader.LoadAsync(Range(count)); + return count; + }); + } + + [Fact] + public async Task TransformAsync_does_not_allocate_per_item() + { + await AssertZeroAllocPerItem(async count => + { + var transformer = new TestTransformer(); + var seen = 0; + await foreach (var _ in transformer.TransformAsync(Range(count))) + { + seen++; + } + + return seen; + }); + } + + // Measures the marginal per-item allocation as (alloc(10N) - alloc(N)) / (9N), + // which cancels the one-time setup cost, and takes the minimum across a few + // runs to shed transient background-allocation noise. Fails if the marginal + // per-item allocation exceeds the threshold. + private static async Task AssertZeroAllocPerItem(Func> run) + { + // Warm up so JIT / first-run allocations do not land in the measurement. + await run(BaseCount); + await run(BaseCount * 10); + + var best = double.MaxValue; + + for (var attempt = 0; attempt < Attempts; attempt++) + { + var small = await Measure(run, BaseCount); + var large = await Measure(run, BaseCount * 10); + + var perItem = (double)(large - small) / (BaseCount * 10 - BaseCount); + best = Math.Min(best, perItem); + } + + Assert.True + ( + best < MaxBytesPerItem, + $"Per-item allocation {best:F3} B exceeds the {MaxBytesPerItem} B/item budget โ€” the hot path regressed to allocating per item." + ); + } + + private static async Task Measure(Func> run, int count) + { + // Settle pending finalizers/collections first: the counter is process-wide, + // so anything the runtime is still cleaning up would otherwise land inside + // the measurement window and inflate the reading. + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var before = GC.GetTotalAllocatedBytes(precise: true); + await run(count); + return GC.GetTotalAllocatedBytes(precise: true) - before; + } + + private static async IAsyncEnumerable Range(int count) + { + for (var i = 0; i < count; i++) + { + yield return i; + } + + await Task.CompletedTask; + } +} +#endif diff --git a/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureInvarianceTests.cs b/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureInvarianceTests.cs new file mode 100644 index 0000000..02b7d04 --- /dev/null +++ b/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureInvarianceTests.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Wolfgang.Etl.TestKit; +using Xunit; + +namespace Wolfgang.Etl.TestKit.Tests.Unit.Globalization; + +/// +/// Verifies the doubles are culture-invariant (#134): the same input must produce +/// the same output regardless of the ambient +/// / . The suite runs the extract โ†’ +/// transform โ†’ load pipeline under a matrix of hostile cultures โ€” en-US, +/// tr-TR (dotted-I), de-DE (decimal comma), zh-CN, ar-SA +/// (Hindi-Arabic digits, RTL), and ja-JP (full-width digits) โ€” exercising the +/// number-separator, digit-shape, and case-folding bug classes. +/// +/// +/// Culture-sensitivity allowlist. The doubles expose no public +/// method that is intentionally culture-sensitive: they move the caller's items +/// through the pipeline by reference/value without formatting or parsing them, and +/// the windowing / counting logic is pure integer arithmetic. The allowlist is +/// therefore empty and these tests assert the whole surface is invariant by +/// contract โ€” if a future edit introduces culture-sensitive formatting (e.g. a +/// double that yields value.ToString() under the ambient culture), the +/// non-en-US rows fail. +/// +public class CultureInvarianceTests +{ + /// + /// Hostile cultures the pipeline runs under. en-US is the baseline; + /// the rest each stress a distinct globalization bug class. + /// + public static readonly IEnumerable HostileCultures = new[] + { + new object[] { "en-US" }, + new object[] { "tr-TR" }, + new object[] { "de-DE" }, + new object[] { "zh-CN" }, + new object[] { "ar-SA" }, + new object[] { "ja-JP" }, + }; + + [Theory] + [MemberData(nameof(HostileCultures))] + public async Task Extractor_windowing_is_culture_invariant(string culture) + { + using var _ = new CultureSwapper(culture); + + var items = Enumerable.Range(0, 20).ToList(); + using var extractor = new TestExtractor(items) + { + SkipItemCount = 5, + MaximumItemCount = 7, + }; + + var results = new List(); + await foreach (var item in extractor.ExtractAsync()) + { + results.Add(item); + } + + // The window is fixed integer arithmetic โ€” identical under every culture. + Assert.Equal(Enumerable.Range(5, 7), results); + } + + [Theory] + [MemberData(nameof(HostileCultures))] + public async Task Full_pipeline_round_trips_decimals_identically_under_every_culture(string culture) + { + // decimal is the type most likely to expose a latent culture bug (comma vs + // dot decimal separators); assert the doubles pass it through untouched. + var items = new[] { 1.5m, 1000.25m, -3.14m, 0m, 999999.999m }; + + List collected; + using (var _ = new CultureSwapper(culture)) + { + using var transformer = new TestTransformer(); + using var loader = new TestLoader(collectItems: true); + await loader.LoadAsync(transformer.TransformAsync(ToAsync(items))); + collected = loader.GetCollectedItems()!.ToList(); + } + + Assert.Equal(items, collected); + } + + private static async IAsyncEnumerable ToAsync(IEnumerable items) + { + foreach (var item in items) + { + yield return item; + } + + await Task.CompletedTask; + } +} diff --git a/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureSwapper.cs b/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureSwapper.cs new file mode 100644 index 0000000..518b345 --- /dev/null +++ b/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureSwapper.cs @@ -0,0 +1,44 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Wolfgang.Etl.TestKit.Tests.Unit.Globalization; + +/// +/// Swaps both and +/// to a hostile culture for the +/// duration of a test and restores the originals on . +/// +/// +/// Since .NET Framework 4.6, flows +/// across await continuations via the execution context, so a value set +/// before an asynchronous call remains in effect inside it. Wrap each test body +/// in a using so the ambient culture is always restored, even on failure. +/// +[ExcludeFromCodeCoverage] +internal sealed class CultureSwapper : IDisposable +{ + private readonly CultureInfo _originalCulture; + + private readonly CultureInfo _originalUiCulture; + + + + public CultureSwapper(string cultureName) + { + _originalCulture = CultureInfo.CurrentCulture; + _originalUiCulture = CultureInfo.CurrentUICulture; + + var culture = CultureInfo.GetCultureInfo(cultureName); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + } + + + + public void Dispose() + { + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUiCulture; + } +}