diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..b6a3f69
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,14 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "gitversion.tool": {
+ "version": "6.8.2",
+ "commands": ["dotnet-gitversion"]
+ },
+ "sourcelink": {
+ "version": "3.1.1",
+ "commands": ["sourcelink"]
+ }
+ }
+}
diff --git a/.gitattributes b/.gitattributes
index 1ff0c42..3c1ac14 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -61,3 +61,9 @@
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
+
+*.yml text eol=lf
+*.json text eol=lf
+*.props text eol=lf
+*.ps1 text eol=lf
+*.md text eol=lf
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..9a869d7
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,18 @@
+version: 2
+updates:
+ - package-ecosystem: nuget
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ test-tooling:
+ patterns: [Microsoft.NET.Test.Sdk, MSTest.*, coverlet.collector, NSubstitute]
+ ignore:
+ - dependency-name: Microsoft.EntityFrameworkCore*
+ update-types: [version-update:semver-major]
+ - dependency-name: Microsoft.Extensions.*
+ update-types: [version-update:semver-major]
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/build-tests.yml b/.github/workflows/build-tests.yml
index 26cb16e..34e96be 100644
--- a/.github/workflows/build-tests.yml
+++ b/.github/workflows/build-tests.yml
@@ -2,35 +2,112 @@ name: Build and Test
on:
pull_request:
- branches:
- - main
+ branches: [main]
+ push:
+ branches-ignore: [main]
+ workflow_call:
+ outputs:
+ version:
+ value: ${{ jobs.build-test.outputs.version }}
+ sha:
+ value: ${{ jobs.build-test.outputs.sha }}
+ artifact-name:
+ value: ${{ jobs.build-test.outputs.artifact-name }}
+
+permissions:
+ contents: read
jobs:
+ workflow-lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7.0.1
+ - name: Validate workflow syntax and shell scripts
+ shell: bash
+ run: |
+ set -euo pipefail
+ tool_dir="$RUNNER_TEMP/actionlint"
+ mkdir -p "$tool_dir"
+ cd "$tool_dir"
+ base=https://github.com/rhysd/actionlint/releases/download/v1.7.12
+ curl -fsSLO "$base/actionlint_1.7.12_linux_amd64.tar.gz"
+ curl -fsSLO "$base/actionlint_1.7.12_checksums.txt"
+ sha256sum --check --ignore-missing actionlint_1.7.12_checksums.txt
+ tar -xzf actionlint_1.7.12_linux_amd64.tar.gz
+ cd "$GITHUB_WORKSPACE"
+ "$tool_dir/actionlint"
+ git diff --check
build-test:
runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ sha: ${{ steps.version.outputs.sha }}
+ artifact-name: ${{ steps.version.outputs.artifact-name }}
+ env:
+ VersionPropsFile: ${{ github.workspace }}/artifacts/Version.props
+ DOTNET_NOLOGO: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
steps:
- - uses: actions/checkout@v2
-
- - name: Setup .NET Core 8
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: 8.x
-
- - name: Setup .NET Core 9
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: 9.x
-
- - name: Install dependencies
- run: dotnet restore
-
- - name: Build
- run: dotnet build --no-restore
-
- - name: Test
- run: dotnet test --no-restore --verbosity normal --logger trx --collect:"XPlat Code Coverage" --no-build
-
- - name: Test Pack nuget
- run: |
- dotnet pack --no-build --no-restore --include-symbols --verbosity normal --configuration Debug
-
+ - uses: actions/checkout@v7.0.1
+ with:
+ fetch-depth: 0
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - uses: actions/setup-dotnet@v6.0.0
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.401
+ - name: Restore validation tools
+ run: dotnet tool restore
+ - name: Validate version scenarios
+ shell: pwsh
+ run: ./scripts/Test-Versioning.ps1
+ - name: Calculate and stamp versions
+ id: version
+ shell: pwsh
+ run: ./scripts/Set-BuildVersion.ps1 -RequireStable:($env:GITHUB_REF -eq 'refs/heads/main')
+ - name: Restore (warnings are errors)
+ run: dotnet restore -warnaserror
+ - name: Build every framework (warnings are errors)
+ run: dotnet build --configuration Release --no-restore -warnaserror
+ - name: Test every framework and collect coverage
+ run: dotnet test --configuration Release --no-build --no-restore --logger trx --collect:"XPlat Code Coverage" --results-directory artifacts/test-results
+ - name: Upload test results and coverage
+ if: always()
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: test-results-${{ github.run_id }}-${{ github.run_attempt }}
+ path: artifacts/test-results
+ if-no-files-found: error
+ - name: Pack (warnings are errors)
+ run: dotnet pack src/myNOC.EntityFramework.Query --configuration Release --no-build --no-restore -warnaserror --output artifacts/packages
+ - name: Validate packaged Source Link, symbols and versions
+ shell: pwsh
+ env:
+ PACKAGE_VERSION: ${{ steps.version.outputs.version }}
+ BUILD_COMMIT: ${{ steps.version.outputs.sha }}
+ run: ./scripts/Test-Packages.ps1 -Version $env:PACKAGE_VERSION -Commit $env:BUILD_COMMIT
+ - name: Audit dependencies
+ run: |
+ dotnet list package --vulnerable --include-transitive --format json > artifacts/vulnerabilities.json
+ dotnet list package --outdated --include-transitive --format json > artifacts/outdated.json
+ - name: Reject known vulnerabilities
+ shell: pwsh
+ run: |
+ $report = Get-Content artifacts/vulnerabilities.json -Raw | ConvertFrom-Json
+ $packages = @($report.projects.frameworks.topLevelPackages) + @($report.projects.frameworks.transitivePackages)
+ if (@($packages | Where-Object { $_.vulnerabilities.Count -gt 0 }).Count -gt 0) { throw 'Known vulnerable dependencies found.' }
+ - name: Upload dependency reports
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: dependency-reports-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ artifacts/vulnerabilities.json
+ artifacts/outdated.json
+ if-no-files-found: error
+ - name: Upload validated packages
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: packages-${{ github.run_id }}-${{ github.run_attempt }}
+ path: artifacts/packages/*
+ if-no-files-found: error
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e2e3f00..5972320 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,59 +2,81 @@ name: Release
on:
push:
- branches:
- - main
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-main
+ cancel-in-progress: false
jobs:
- release:
+ validate:
+ if: github.ref == 'refs/heads/main'
+ uses: ./.github/workflows/build-tests.yml
+ publish:
+ needs: validate
runs-on: ubuntu-latest
+ environment: nuget
+ permissions:
+ contents: write
+ id-token: write
+ env:
+ PACKAGE_VERSION: ${{ needs.validate.outputs.version }}
+ BUILD_COMMIT: ${{ needs.validate.outputs.sha }}
steps:
- - uses: actions/checkout@v2
- with:
- fetch-depth: 0
-
- - name: Install GitVersion
- uses: gittools/actions/gitversion/setup@v0.9.7
- with:
- versionSpec: '5.x'
-
- - name: Determine Version
- id: gitversion
- uses: gittools/actions/gitversion/execute@v0.9.7
- with:
- useConfigFile: true
-
- - name: Setup .NET Core 8
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: 8.x
-
- - name: Setup .NET Core 9
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: 9.x
-
- - name: Install dependencies
- run: dotnet restore
-
- - name: Build
- run: |
- dotnet build ./src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj --configuration Release --no-restore /p:Version="${{ steps.gitversion.outputs.assemblySemVer }}" /p:AssemblyVersion="${{ steps.gitversion.outputs.assemblySemVer }}" /p:FileVersion="${{ steps.gitversion.outputs.assemblySemFileVer }}" /p:InformationalVersion="${{ steps.gitversion.outputs.informationalVersion }}" /p:PackageVersion="${{ steps.gitversion.outputs.nuGetVersion }}" /p:ContinuousIntegrationBuild=true
-
- - name: Pack nuget
- run: |
- dotnet pack ./src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj --configuration Release --no-build --no-restore --include-symbols --output ./.pack --verbosity normal -p:PackageVersion="${{ steps.gitversion.outputs.nuGetVersion }}"
-
- - name: Nuget Push
- run: |
- dotnet nuget push "**/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_PUBLISH }} --skip-duplicate
-
- - name: Create Release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- tag_name: v${{ steps.gitversion.outputs.nuGetVersion }}
- release_name: v${{ steps.gitversion.outputs.nuGetVersion }}
- draft: false
- prerelease: true
+ - uses: actions/checkout@v7.0.1
+ with:
+ fetch-depth: 0
+ ref: ${{ needs.validate.outputs.sha }}
+ - uses: actions/setup-dotnet@v6.0.0
+ with:
+ dotnet-version: 10.0.401
+ - uses: actions/download-artifact@v8.0.1
+ with:
+ name: ${{ needs.validate.outputs.artifact-name }}
+ path: artifacts/packages
+ - name: Guard stable version and immutable tag
+ shell: bash
+ run: |
+ set -euo pipefail
+ [[ "$GITHUB_REF" == refs/heads/main ]]
+ [[ "$PACKAGE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
+ tag="v$PACKAGE_VERSION"
+ if git show-ref --verify --quiet "refs/tags/$tag"; then
+ [[ "$(git rev-list -n 1 "$tag")" == "$BUILD_COMMIT" ]]
+ fi
+ - name: Verify existing package provenance before duplicate skipping
+ shell: pwsh
+ run: ./scripts/Test-PublishedPackage.ps1 -Version $env:PACKAGE_VERSION -Commit $env:BUILD_COMMIT -AllowMissing
+ - name: Authenticate to NuGet using GitHub OIDC
+ uses: NuGet/login@v1.2.0
+ id: login
+ with:
+ user: ${{ secrets.NUGET_USER }}
+ - name: Publish package and symbols (safe to rerun)
+ env:
+ NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ dotnet nuget push "artifacts/packages/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --no-symbols --skip-duplicate
+ dotnet nuget push "artifacts/packages/*.snupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate
+ - name: Verify published package matches this commit
+ shell: pwsh
+ run: ./scripts/Test-PublishedPackage.ps1 -Version $env:PACKAGE_VERSION -Commit $env:BUILD_COMMIT -Attempts 60
+ - name: Create matching stable GitHub release and tag
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ tag="v$PACKAGE_VERSION"
+ if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+ gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease=false
+ else
+ gh release create "$tag" --repo "$GITHUB_REPOSITORY" --target "$BUILD_COMMIT" --title "$tag" --generate-notes --draft=false --prerelease=false
+ fi
+ gh release upload "$tag" artifacts/packages/* --repo "$GITHUB_REPOSITORY" --clobber
diff --git a/.gitignore b/.gitignore
index dfcfd56..3bc5066 100644
--- a/.gitignore
+++ b/.gitignore
@@ -348,3 +348,11 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
+
+# Local validation tools, reports, and package output
+.tools/
+.validation/
+.pack/
+artifacts/
+*.nupkg
+*.snupkg
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..15da82b
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,21 @@
+
+
+ net8.0;net10.0
+ 14.0
+ enable
+ enable
+ true
+ true
+ all
+ false
+ portable
+ true
+ true
+ true
+ 8.0.31
+ 10.0.12
+ 8.0.1
+ 10.0.12
+
+
+
diff --git a/GitVersion.yml b/GitVersion.yml
index 1c3b841..fe4a506 100644
--- a/GitVersion.yml
+++ b/GitVersion.yml
@@ -1,23 +1,33 @@
+mode: ContinuousDeployment
+tag-prefix: '[vV]?'
+assembly-versioning-scheme: MajorMinorPatch
+assembly-file-versioning-scheme: MajorMinorPatch
+commit-message-incrementing: Enabled
+major-version-bump-message: '\+semver:\s?(breaking|major)'
+minor-version-bump-message: '\+semver:\s?(feature|minor)'
+patch-version-bump-message: '\+semver:\s?(fix|patch)'
+no-bump-message: '\+semver:\s?(none|skip)'
branches:
main:
regex: ^main$
- mode: ContinuousDelivery
- tag: ''
+ label: ''
increment: Patch
- prevent-increment-of-merged-branch-version: true
- track-merge-target: false
- source-branches: [ 'work' ]
- tracks-release-branches: true
- is-mainline: true
- work:
- regex: ^work?[/-]
- mode: ContinuousDeployment
- tag: alpha
- increment: Minor
- prevent-increment-of-merged-branch-version: false
- track-merge-target: true
- source-branches: []
- tracks-release-branches: true
- is-release-branch: false
- is-mainline: false
- pre-release-weight: 0
+ is-main-branch: true
+ prevent-increment:
+ of-merged-branch: true
+ when-current-commit-tagged: true
+ feature:
+ regex: ^(feature|work|build|fix|chore)[/-](?.+)
+ mode: ContinuousDelivery
+ label: alpha.{BranchName}
+ increment: Inherit
+ source-branches: [main]
+ pull-request:
+ mode: ContinuousDelivery
+ label: pr{Number}
+ increment: Inherit
+ unknown:
+ mode: ContinuousDelivery
+ label: preview.{BranchName}
+ increment: Inherit
+ source-branches: [main]
diff --git a/README.md b/README.md
index b4890db..a7def8d 100644
--- a/README.md
+++ b/README.md
@@ -18,11 +18,149 @@ So, where you needed a simple quick list of users is now doing way more than it
This pattern also gives you the ability to easily test your queries. As Steve points out in the podcast what might work fine in compile time,
or even within a unit test using mocked lists as your data, could give you a runtime error when running against EntityFramework.
-## nuget.org
+## Installation and supported frameworks
-For installation instructions please to to [myNOC.EntityFramework.Query](https://www.nuget.org/packages/myNOC.EntityFramework.Query/) at [nuget.org](https://nuget.org).
+```shell
+dotnet add package myNOC.EntityFramework.Query
+```
+
+The package, tests, and sample target **.NET 8.0 and .NET 10.0** (LTS).
+The .NET 8 asset uses the latest EF Core 8 servicing release; the .NET 10
+asset uses EF Core 10. EF Core 10 cannot run on .NET 8. Dependency reports
+may therefore show newer major versions for .NET 8; this is intentional.
+Test tooling uses the current stable MSTest, VSTest, Coverlet and NSubstitute.
+
+## Local build and validation
+
+Install the .NET SDK selected by [global.json](./global.json), plus the
+.NET 8 runtime to run the .NET 8 tests. No .NET 9 SDK/runtime is needed.
+Run these commands from the repository root:
+
+```shell
+dotnet tool restore
+dotnet restore -warnaserror
+dotnet build --configuration Release --no-restore -warnaserror
+dotnet test --configuration Release --no-build --no-restore --logger trx --collect:"XPlat Code Coverage" --results-directory artifacts/test-results
+dotnet pack src/myNOC.EntityFramework.Query --configuration Release --no-build --no-restore -warnaserror --output artifacts/packages
+dotnet list package --outdated --include-transitive
+dotnet list package --vulnerable --include-transitive
+```
+
+Warnings are errors in every project. Tests run for both target frameworks.
+PowerShell 7 is required for the versioning and package-validation scripts.
+To reproduce CI's version stamping locally:
+
+```powershell
+./scripts/Test-Versioning.ps1
+./scripts/Set-BuildVersion.ps1
+$env:VersionPropsFile = (Resolve-Path artifacts/Version.props).Path
+dotnet restore -warnaserror
+dotnet build --configuration Release --no-restore -warnaserror
+dotnet pack src/myNOC.EntityFramework.Query --configuration Release --no-build --no-restore -warnaserror --output artifacts/packages
+$version = dotnet gitversion /output json /nofetch | ConvertFrom-Json
+./scripts/Test-Packages.ps1 -Version $version.SemVer -Commit $version.Sha
+Remove-Item Env:VersionPropsFile
+```
+
+Source downloads require network access and a commit pushed to GitHub.
+Uncommitted source changes cannot match the published commit's checksums.
+Without the version props file, local builds retain the SDK's default version.
+
+## Automatic versioning
+
+[GitVersion.yml](./GitVersion.yml) uses GitVersion 6, pinned in the local
+tool manifest. CI fetches complete history and tags, then generates a single
+props file used by restore, build and pack. It stamps `Version`, `PackageVersion`,
+`AssemblyVersion`, `FileVersion` and `InformationalVersion`. The informational
+version and NuGet repository metadata contain the exact build commit.
+
+- `main` produces only stable `major.minor.patch` packages; two guards reject
+ prereleases and publishing from any other branch.
+- A commit after `v1.2.3` produces `1.2.4`. Publishing creates `v1.2.4` on
+ that exact commit; the next release becomes `1.2.5`.
+- Rerunning a tagged commit retains its version rather than incrementing it.
+- Add `+semver: minor` to the commit or merge/squash message for `1.3.0`.
+ Add `+semver: major` for `2.0.0`. Preserve these directives when squashing.
+- Work/feature branches produce `alpha..` previews; PRs
+ produce preview versions. CI validates but never publishes them.
+- Avoid `+semver: none` for publishable changes: NuGet versions are immutable.
+
+[Test-Versioning.ps1](./scripts/Test-Versioning.ps1) exercises patch releases,
+stable tags, tagged reruns, previews, explicit minor/major increments and
+merge-message increments in an isolated temporary Git repository.
+
+## CI and Trusted Publishing
+
+[Build and Test](./.github/workflows/build-tests.yml) restores, builds and tests
+the entire solution for both frameworks, collects coverage/TRX files, audits
+dependencies, validates workflow syntax, and creates packages with warnings
+treated as errors. Its packaged Source Link check downloads source and verifies
+checksums for **each PDB extracted from the `.snupkg`**, not just build output.
+Test results, coverage, dependency reports and validated packages are artifacts.
+
+[Release](./.github/workflows/release.yml) calls that same validation workflow,
+then publishes only from `main` using the `nuget` GitHub environment.
+Repository workflow token permissions default to read-only; the publishing job
+alone receives `contents: write` and `id-token: write`.
+Branch protection requires successful `build-test` and `workflow-lint` checks,
+an up-to-date branch and resolved conversations. Merged branches are deleted
+automatically; force-pushes and deletion of `main` are disallowed.
+
+Trusted Publishing configuration for this repository:
+
+| Setting | Value |
+| --- | --- |
+| GitHub secret `NUGET_USER` | `erenken` (NuGet username, not an API key) |
+| GitHub environment | `nuget`, deployment branch restricted to `main` |
+| NuGet policy name | `queryPattern-release` |
+| Package owner | `erenken` |
+| Provider | GitHub Actions |
+| Repository owner / repository | `erenken` / `queryPattern` |
+| Workflow filename | `release.yml` (not a full path) |
+| Environment in policy | `nuget` (must match exactly) |
+| Package scope | `myNOC.EntityFramework.Query` (no wildcard) |
+| Permissions | Push only new package versions; no unlist/relist |
+
+Manage the policy at [NuGet Trusted Publishing](https://www.nuget.org/account/trustedpublishing).
+`NuGet/login` exchanges a GitHub OIDC token for a short-lived publishing key.
+There is no permanent API-key fallback. After this PR is merged, delete the
+legacy `NUGET_PUBLISH` repository secret and revoke its old key on NuGet.
+It is retained during the PR so the existing `main` workflow is not broken.
+
+Releases are serialized. The workflow checks that an existing version tag
+points to the exact build commit, explicitly publishes packages and symbols
+with duplicate skipping, then creates a stable GitHub release and `v`
+tag. A previously published NuGet version must have matching repository commit
+metadata; a collision from a different commit fails rather than silently
+skipping and creating a misleading tag. After pushing, the workflow waits for
+package availability and verifies that provenance again before creating the
+release. Existing releases are reused and assets refreshed on reruns. A failed-job
+rerun downloads the original successful validation job's package artifact.
+NuGet or symbol indexing may finish after the push completes; unrelated
+authentication/network errors fail the job rather than being ignored.
+
+## Source Link debugging
+
+Every framework has a portable PDB in the separate `.snupkg`. The SDK's built-in
+Source Link provider maps source to the exact GitHub commit; no extra Source
+Link package is required. The primary `.nupkg` contains the framework DLLs.
+
+In Visual Studio, add `https://symbols.nuget.org/download/symbols` under
+**Tools > Options > Debugging > Symbols**, enable **Source Link support**,
+and disable **Just My Code** when stepping into the library. Load the symbols
+for `myNOC.EntityFramework.Query.dll`, then step into a method. Wait for NuGet's
+symbol validation/indexing if symbols are not available immediately.
+
+## Temporary artifacts
+
+`.tools/` (downloaded CLI/lint tools), `.validation/` (local inspection output),
+`.pack/`, `artifacts/` (packages, version props, TRX/coverage/audit reports),
+and project `bin/`/`obj/` directories are ignored and can be deleted after
+validation. `.nupkg` and `.snupkg` files are ignored anywhere. Removing the
+tools means downloading them again if needed. Keep the tracked tool manifest,
+build props, GitVersion configuration and validation scripts.
## Documentation & Usage
The documentation for the library can be found [here](./src/myNOC.EntityFramework.Query/README.md)
-A very simple sample project [here](./sample/QuerySample/),
\ No newline at end of file
+A very simple sample project [here](./sample/QuerySample/).
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..e1c5e99
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.401",
+ "rollForward": "latestPatch",
+ "allowPrerelease": false
+ }
+}
diff --git a/sample/QuerySample/QuerySample.csproj b/sample/QuerySample/QuerySample.csproj
index 70b1076..3a1286f 100644
--- a/sample/QuerySample/QuerySample.csproj
+++ b/sample/QuerySample/QuerySample.csproj
@@ -1,16 +1,12 @@
- net8.0;net9.0
- 13.0
Exe
- enable
- enable
-
-
+
+
diff --git a/scripts/Assert-StableRelease.ps1 b/scripts/Assert-StableRelease.ps1
new file mode 100644
index 0000000..8e994ae
--- /dev/null
+++ b/scripts/Assert-StableRelease.ps1
@@ -0,0 +1,8 @@
+param(
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$Branch
+)
+
+if ($Branch -cne 'main' -or $Version -cnotmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') {
+ throw "Only stable major.minor.patch versions from main may publish: $Branch / $Version"
+}
diff --git a/scripts/Set-BuildVersion.ps1 b/scripts/Set-BuildVersion.ps1
new file mode 100644
index 0000000..fb131e2
--- /dev/null
+++ b/scripts/Set-BuildVersion.ps1
@@ -0,0 +1,39 @@
+param(
+ [string]$OutputPath = 'artifacts/Version.props',
+ [switch]$RequireStable
+)
+
+$ErrorActionPreference = 'Stop'
+$versionJson = & dotnet gitversion /output json /nofetch
+if ($LASTEXITCODE -ne 0) { throw 'GitVersion failed.' }
+$version = ($versionJson -join "`n") | ConvertFrom-Json
+if ($RequireStable) {
+ & "$PSScriptRoot/Assert-StableRelease.ps1" -Version $version.SemVer -Branch $version.BranchName
+}
+$properties = [ordered]@{
+ Version = $version.SemVer
+ PackageVersion = $version.SemVer
+ AssemblyVersion = $version.AssemblySemVer
+ FileVersion = $version.AssemblySemFileVer
+ InformationalVersion = $version.InformationalVersion
+ IncludeSourceRevisionInInformationalVersion = 'false'
+ SourceRevisionId = $version.Sha
+ RepositoryCommit = $version.Sha
+ ContinuousIntegrationBuild = 'true'
+}
+$document = [System.Xml.XmlDocument]::new()
+$project = $document.AppendChild($document.CreateElement('Project'))
+$group = $project.AppendChild($document.CreateElement('PropertyGroup'))
+foreach ($property in $properties.GetEnumerator()) {
+ $element = $group.AppendChild($document.CreateElement($property.Key))
+ $element.InnerText = $property.Value
+}
+$fullPath = [System.IO.Path]::GetFullPath($OutputPath)
+[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($fullPath)) | Out-Null
+$document.Save($fullPath)
+if ($env:GITHUB_OUTPUT) {
+ "version=$($version.SemVer)" >> $env:GITHUB_OUTPUT
+ "sha=$($version.Sha)" >> $env:GITHUB_OUTPUT
+ "artifact-name=packages-$($env:GITHUB_RUN_ID)-$($env:GITHUB_RUN_ATTEMPT)" >> $env:GITHUB_OUTPUT
+}
+Write-Host "Version: $($version.SemVer); commit: $($version.Sha)"
diff --git a/scripts/Test-Packages.ps1 b/scripts/Test-Packages.ps1
new file mode 100644
index 0000000..19bf10c
--- /dev/null
+++ b/scripts/Test-Packages.ps1
@@ -0,0 +1,72 @@
+param(
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$Commit,
+ [string]$PackageDirectory = 'artifacts/packages'
+)
+
+$ErrorActionPreference = 'Stop'
+$packageId = 'myNOC.EntityFramework.Query'
+$package = Join-Path $PackageDirectory "$packageId.$Version.nupkg"
+$symbols = Join-Path $PackageDirectory "$packageId.$Version.snupkg"
+$scratch = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
+[System.IO.Directory]::CreateDirectory($scratch) | Out-Null
+try {
+ [System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $package), "$scratch/package")
+ [System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $symbols), "$scratch/symbols")
+ [xml]$manifest = Get-Content "$scratch/package/$packageId.nuspec" -Raw
+ $metadata = $manifest.package.metadata
+ if ($metadata.version -cne $Version -or $metadata.repository.commit -cne $Commit) {
+ throw 'Package version or repository commit does not match the build.'
+ }
+ if (Get-ChildItem "$scratch/package" -Recurse -Filter '*.pdb') {
+ throw 'Portable PDBs must be in the symbol package, not the primary package.'
+ }
+ $frameworks = @('net8.0', 'net10.0')
+ $pdbs = @(Get-ChildItem "$scratch/symbols" -Recurse -Filter '*.pdb')
+ if ($pdbs.Count -ne $frameworks.Count) { throw 'Expected one packaged PDB per target framework.' }
+ foreach ($framework in $frameworks) {
+ $assembly = "$scratch/package/lib/$framework/$packageId.dll"
+ $pdb = "$scratch/symbols/lib/$framework/$packageId.pdb"
+ if (!(Test-Path $assembly) -or !(Test-Path $pdb)) { throw "Missing DLL/PDB for $framework." }
+ $assemblyStream = [System.IO.File]::OpenRead($assembly)
+ $pdbStream = [System.IO.File]::OpenRead($pdb)
+ $peReader = [System.Reflection.PortableExecutable.PEReader]::new($assemblyStream)
+ $pdbProvider = [System.Reflection.Metadata.MetadataReaderProvider]::FromPortablePdbStream($pdbStream)
+ try {
+ $codeViewEntries = @($peReader.ReadDebugDirectory() | Where-Object { $_.Type -eq 'CodeView' })
+ if ($codeViewEntries.Count -ne 1) { throw "Expected one CodeView entry for $framework." }
+ $codeView = $peReader.ReadCodeViewDebugDirectoryData($codeViewEntries[0])
+ $pdbId = $pdbProvider.GetMetadataReader().DebugMetadataHeader.Id
+ $pdbGuid = [guid]::new([byte[]]$pdbId[0..15])
+ if ($codeView.Guid -ne $pdbGuid -or $codeView.Age -ne 1) { throw "DLL/PDB identity mismatch for $framework." }
+ } finally {
+ $pdbProvider.Dispose()
+ $peReader.Dispose()
+ $pdbStream.Dispose()
+ $assemblyStream.Dispose()
+ }
+ $assemblyVersion = [System.Reflection.AssemblyName]::GetAssemblyName($assembly).Version.ToString()
+ $stableVersion = ($Version -split '-')[0]
+ if ($assemblyVersion -ne "$stableVersion.0") { throw "Incorrect assembly version: $assemblyVersion" }
+ $fileInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($assembly)
+ if ($fileInfo.FileVersion -ne "$stableVersion.0" -or !$fileInfo.ProductVersion.Contains($Commit)) {
+ throw "File/informational version mismatch for $framework."
+ }
+ $json = & dotnet sourcelink print-json $pdb
+ if ($LASTEXITCODE -ne 0) { throw "Cannot read portable PDB for $framework." }
+ $mapping = ($json -join "`n") | ConvertFrom-Json
+ $urls = @($mapping.documents.PSObject.Properties.Value)
+ $prefix = "https://raw.githubusercontent.com/erenken/queryPattern/$Commit/"
+ if ($urls.Count -eq 0 -or @($urls | Where-Object { !$_.StartsWith($prefix, [System.StringComparison]::Ordinal) }).Count -gt 0) {
+ throw "Source Link does not point exclusively to the exact build commit for $framework."
+ }
+ & dotnet sourcelink test $pdb
+ if ($LASTEXITCODE -ne 0) { throw "Source downloads/checksums failed for $framework." }
+ Write-Host "Validated packaged DLL, portable PDB, versions and source checksums: $framework"
+ }
+} finally {
+ if ([System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($scratch)) -ne [System.IO.Path]::GetTempPath().TrimEnd('\', '/')) {
+ throw 'Refusing to delete outside the temporary directory.'
+ }
+ [System.IO.Directory]::Delete($scratch, $true)
+}
diff --git a/scripts/Test-PublishedPackage.ps1 b/scripts/Test-PublishedPackage.ps1
new file mode 100644
index 0000000..54f4fa0
--- /dev/null
+++ b/scripts/Test-PublishedPackage.ps1
@@ -0,0 +1,41 @@
+param(
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$Commit,
+ [switch]$AllowMissing,
+ [int]$Attempts = 1,
+ [int]$RetrySeconds = 10
+)
+
+$ErrorActionPreference = 'Stop'
+$packageId = 'mynoc.entityframework.query'
+$url = "https://api.nuget.org/v3-flatcontainer/$packageId/$Version/$packageId.$Version.nupkg"
+$file = Join-Path ([System.IO.Path]::GetTempPath()) "$([guid]::NewGuid()).nupkg"
+try {
+ for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
+ try {
+ Invoke-WebRequest $url -OutFile $file
+ } catch {
+ if ([int]$_.Exception.Response.StatusCode -ne 404) { throw }
+ if ($AllowMissing) { Write-Host 'Version not published yet.'; return }
+ if ($attempt -eq $Attempts) { throw 'Published package did not become available in time.' }
+ Start-Sleep -Seconds $RetrySeconds
+ continue
+ }
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($file)
+ try {
+ $entry = @($archive.Entries | Where-Object { $_.FullName.EndsWith('.nuspec') })
+ if ($entry.Count -ne 1) { throw 'Expected one published package manifest.' }
+ $reader = [System.IO.StreamReader]::new($entry[0].Open())
+ try { [xml]$manifest = $reader.ReadToEnd() } finally { $reader.Dispose() }
+ $metadata = $manifest.package.metadata
+ if ($metadata.id -ine $packageId -or $metadata.version -cne $Version -or $metadata.repository.commit -cne $Commit) {
+ throw "NuGet version $Version belongs to another commit or has no verifiable provenance; refusing to create a mismatched release."
+ }
+ } finally { $archive.Dispose() }
+ Write-Host "Published package $Version matches commit $Commit."
+ return
+ }
+ throw 'No package verification attempt was made.'
+} finally {
+ if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Force }
+}
diff --git a/scripts/Test-Versioning.ps1 b/scripts/Test-Versioning.ps1
new file mode 100644
index 0000000..9101260
--- /dev/null
+++ b/scripts/Test-Versioning.ps1
@@ -0,0 +1,74 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path $PSScriptRoot -Parent
+$scratch = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
+$githubActions = $env:GITHUB_ACTIONS
+& "$PSScriptRoot/Assert-StableRelease.ps1" -Version '1.2.3' -Branch main
+foreach ($invalidVersion in @('1.2.3-alpha.1', '1.2.3+build', '1.2.3.4', '01.2.3')) {
+ $rejected = $false
+ try { & "$PSScriptRoot/Assert-StableRelease.ps1" -Version $invalidVersion -Branch main } catch { $rejected = $true }
+ if (!$rejected) { throw "Release guard accepted invalid version: $invalidVersion" }
+}
+$rejected = $false
+try { & "$PSScriptRoot/Assert-StableRelease.ps1" -Version '1.2.3' -Branch work/preview } catch { $rejected = $true }
+if (!$rejected) { throw 'Release guard accepted a non-main branch.' }
+Write-Host 'Verified stable-only release guard rejects prereleases, metadata, invalid versions and non-main branches.'
+function Invoke-Git {
+ & git -C $scratch @args
+ if ($LASTEXITCODE -ne 0) { throw "git failed: $args" }
+}
+function Add-Commit([string]$Message) {
+ Invoke-Git -c user.name=VersionTests -c user.email=version-tests@example.invalid -c commit.gpgsign=false commit --allow-empty -m $Message
+}
+function Assert-Version([string]$Expected) {
+ $json = & dotnet gitversion $scratch /config "$scratch/GitVersion.yml" /output json /nofetch /nonormalize /nocache
+ if ($LASTEXITCODE -ne 0) { throw "GitVersion failed: $json" }
+ $version = ($json -join "`n") | ConvertFrom-Json
+ if ($version.SemVer -cne $Expected) { throw "Expected $Expected, got $($version.SemVer)" }
+ Write-Host "Verified $Expected"
+}
+try {
+ $env:GITHUB_ACTIONS = 'false'
+ [System.IO.Directory]::CreateDirectory($scratch) | Out-Null
+ Invoke-Git init --initial-branch=main
+ Copy-Item "$root/GitVersion.yml" "$scratch/GitVersion.yml"
+ Invoke-Git add GitVersion.yml
+ Add-Commit 'Initial release'
+ Invoke-Git tag v1.2.3
+ Assert-Version '1.2.3'
+ Add-Commit 'Patch change'
+ Assert-Version '1.2.4'
+ Invoke-Git tag v1.2.4
+ Assert-Version '1.2.4'
+ Assert-Version '1.2.4'
+ Add-Commit 'Next patch change'
+ Assert-Version '1.2.5'
+ Invoke-Git tag v1.2.5
+ Add-Commit 'New feature +semver: minor'
+ Assert-Version '1.3.0'
+ Invoke-Git tag v1.3.0
+ Add-Commit 'Breaking change +semver: major'
+ Assert-Version '2.0.0'
+ Invoke-Git tag v2.0.0
+ Invoke-Git checkout -b work/version-preview
+ Add-Commit 'Preview change'
+ $json = & dotnet gitversion $scratch /config "$scratch/GitVersion.yml" /output json /nofetch /nonormalize /nocache
+ if ($LASTEXITCODE -ne 0) { throw 'GitVersion preview calculation failed.' }
+ $preview = ($json -join "`n") | ConvertFrom-Json
+ if ($preview.SemVer -notmatch '^2\.0\.1-alpha\.version-preview\.[0-9]+$') {
+ throw "Expected a branch preview, got $($preview.SemVer)"
+ }
+ Write-Host "Verified preview: $($preview.SemVer)"
+ Invoke-Git checkout main
+ Invoke-Git -c user.name=VersionTests -c user.email=version-tests@example.invalid -c commit.gpgsign=false merge --no-ff work/version-preview -m 'Merge preview +semver: minor'
+ Assert-Version '2.1.0'
+ Invoke-Git tag v2.1.0
+ Invoke-Git checkout -b pull/123
+ Add-Commit 'Pull request preview'
+ Assert-Version '2.1.1-pr123.1'
+} finally {
+ $env:GITHUB_ACTIONS = $githubActions
+ if ([System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($scratch)) -ne [System.IO.Path]::GetTempPath().TrimEnd('\', '/')) {
+ throw 'Refusing to delete outside the temporary directory.'
+ }
+ Remove-Item -LiteralPath $scratch -Recurse -Force
+}
diff --git a/src/myNOC.EntityFramework.Query/README.md b/src/myNOC.EntityFramework.Query/README.md
index addf911..ff78bfd 100644
--- a/src/myNOC.EntityFramework.Query/README.md
+++ b/src/myNOC.EntityFramework.Query/README.md
@@ -1,5 +1,37 @@
# myNOC.EntityFramework.Query
+## Installation and support
+
+```shell
+dotnet add package myNOC.EntityFramework.Query
+```
+
+Supports **.NET 8.0 and .NET 10.0** (LTS), using EF Core 8 and EF Core 10
+respectively. Match your application's Entity Framework packages to its target.
+See the [repository build instructions](https://github.com/erenken/queryPattern#local-build-and-validation)
+to build the library/sample and run tests against both frameworks.
+
+## Versions and publishing
+
+Stable packages are released from `main` only after all targets build without
+warnings/errors, tests pass, and packaged Source Link checks succeed. GitVersion
+automatically stamps package, assembly, file and informational versions. Work
+branches use preview versions for validation, not public publishing. Maintainers
+request minor/major releases using `+semver: minor` / `+semver: major` in commit
+or merge messages. Each release has a matching GitHub `v` tag.
+Publishing uses GitHub OIDC through `NuGet/login` and a package-scoped NuGet
+Trusted Publishing policy, not a permanent API key. See the
+[release setup](https://github.com/erenken/queryPattern#ci-and-trusted-publishing).
+
+## Source Link debugging
+
+The `.snupkg` includes portable PDBs for .NET 8 and .NET 10. Source Link maps
+to the exact build commit and CI verifies matching source downloads/checksums.
+In Visual Studio, add `https://symbols.nuget.org/download/symbols` under
+**Tools > Options > Debugging > Symbols**, enable **Source Link support** and
+disable **Just My Code** to step into library methods. Symbols become available
+after NuGet finishes indexing the symbol package.
+
## Overview
A library used to create EntityFramework queries.
diff --git a/src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj b/src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj
index c92b641..e80a7e8 100644
--- a/src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj
+++ b/src/myNOC.EntityFramework.Query/myNOC.EntityFramework.Query.csproj
@@ -1,16 +1,11 @@
- net8.0;net9.0
- 13.0
- enable
- enable
+ true
snupkg
true
true
true
- embedded
- true
False
myNOC.EntityFramework.Query
myNOC LLC
@@ -18,13 +13,10 @@
README.md
LICENSE.txt
Copyright © myNOC LLC 2025
- $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb
-
-
-
- true
- pdbonly
- true
+ https://github.com/erenken/queryPattern
+ git
+ https://github.com/erenken/queryPattern
+ true
@@ -33,7 +25,7 @@
-
+
diff --git a/tests/myNOC.Tests.EntityFramework.Query/Extensions/IServiceCollectionExtensionsTests.cs b/tests/myNOC.Tests.EntityFramework.Query/Extensions/IServiceCollectionExtensionsTests.cs
index 970a99d..ce9baad 100644
--- a/tests/myNOC.Tests.EntityFramework.Query/Extensions/IServiceCollectionExtensionsTests.cs
+++ b/tests/myNOC.Tests.EntityFramework.Query/Extensions/IServiceCollectionExtensionsTests.cs
@@ -18,7 +18,7 @@ public void AddQueryPattern_ScanAppDomainForClassesThatInheritFromIQueryContextO
// Assert
Assert.IsNotNull(results);
- Assert.IsInstanceOfType(results, typeof(IServiceCollection));
+ Assert.IsInstanceOfType(results);
Assert.IsNotNull(results.FirstOrDefault(x => x.ImplementationType == typeof(TestQueryContext)
&& x.ServiceType == typeof(IQueryContext)
diff --git a/tests/myNOC.Tests.EntityFramework.Query/myNOC.Tests.EntityFramework.Query.csproj b/tests/myNOC.Tests.EntityFramework.Query/myNOC.Tests.EntityFramework.Query.csproj
index f1a654a..0730a06 100644
--- a/tests/myNOC.Tests.EntityFramework.Query/myNOC.Tests.EntityFramework.Query.csproj
+++ b/tests/myNOC.Tests.EntityFramework.Query/myNOC.Tests.EntityFramework.Query.csproj
@@ -1,25 +1,20 @@
- net8.0;net9.0
- 13.0
- enable
- enable
-
false
true
-
-
-
-
-
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+