From fcc16f2c14c9df78f47bb0640e3b1980258dcb82 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 05:21:39 -0300 Subject: [PATCH 01/13] Bump to .net6/8 for the CLI Fixes #146 --- src/Config.Tool/Config.Tool.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.Tool/Config.Tool.csproj b/src/Config.Tool/Config.Tool.csproj index 2f6bff4..9f232ee 100644 --- a/src/Config.Tool/Config.Tool.csproj +++ b/src/Config.Tool/Config.Tool.csproj @@ -34,7 +34,7 @@ Other Exe - netcoreapp3.1;net6.0 + net6.0;net8.0 win-x64;linux-x64 dotnet-config From c662931c0e5b102fed5c8095a8ea9c1a7c5ff4d3 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 05:30:41 -0300 Subject: [PATCH 02/13] Bump dependencies --- src/CommandLine/CommandLine.csproj | 1 - src/Config.Tests/Config.Tests.csproj | 14 +++++++------- src/Config.Tool/Config.Tool.csproj | 3 +-- src/Config/Config.csproj | 3 +-- src/Configuration/Configuration.csproj | 3 +-- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/CommandLine/CommandLine.csproj b/src/CommandLine/CommandLine.csproj index 552b08b..230d4f4 100644 --- a/src/CommandLine/CommandLine.csproj +++ b/src/CommandLine/CommandLine.csproj @@ -35,7 +35,6 @@ The following heuristics are applied when providing default values: - diff --git a/src/Config.Tests/Config.Tests.csproj b/src/Config.Tests/Config.Tests.csproj index d4559ed..1df97ce 100644 --- a/src/Config.Tests/Config.Tests.csproj +++ b/src/Config.Tests/Config.Tests.csproj @@ -1,4 +1,4 @@ - + net6.0 @@ -23,15 +23,15 @@ - + - - - - - + + + + + diff --git a/src/Config.Tool/Config.Tool.csproj b/src/Config.Tool/Config.Tool.csproj index 9f232ee..de63a7d 100644 --- a/src/Config.Tool/Config.Tool.csproj +++ b/src/Config.Tool/Config.Tool.csproj @@ -52,9 +52,8 @@ Other - - + diff --git a/src/Config/Config.csproj b/src/Config/Config.csproj index df047b8..049f8ed 100644 --- a/src/Config/Config.csproj +++ b/src/Config/Config.csproj @@ -33,9 +33,8 @@ Usage: - - + diff --git a/src/Configuration/Configuration.csproj b/src/Configuration/Configuration.csproj index a877461..ace4925 100644 --- a/src/Configuration/Configuration.csproj +++ b/src/Configuration/Configuration.csproj @@ -20,8 +20,7 @@ Note: section is required and subsection is optional, just like in dotnet-config - - + From 071f704c27f7be0ac0bd2cfafe76e65936133fb9 Mon Sep 17 00:00:00 2001 From: kzu Date: Tue, 25 Jun 2024 08:39:22 +0000 Subject: [PATCH 03/13] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Bump=20files=20with?= =?UTF-8?q?=20dotnet-file=20sync=20#=20devlooped/oss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integrate more seamlessly with the existing workflows https://github.com/devlooped/oss/commit/e732f6a - Add our implementation of JWT manifest reading and reporting https://github.com/devlooped/oss/commit/a0ae727 - Fix path to jwk.ps1 alongside the SponsorLink.targets https://github.com/devlooped/oss/commit/c4830fc - Minor code simplification https://github.com/devlooped/oss/commit/cf154d5 - Rename sample assemblies for nicer display https://github.com/devlooped/oss/commit/93df7c7 - Minimal docs on consuming https://github.com/devlooped/oss/commit/827a1d1 - Remove dependency on ThisAssembly https://github.com/devlooped/oss/commit/c879f25 - Add nullable and generated code annotations https://github.com/devlooped/oss/commit/b2a11fa - Whitespace and formatting https://github.com/devlooped/oss/commit/d74f511 - Improve versioning of sample package https://github.com/devlooped/oss/commit/3b943f5 - Dynamically fetch devlooped JWK from github https://github.com/devlooped/oss/commit/55124bc - Fix scenario where multiple packages share product name https://github.com/devlooped/oss/commit/23f83bd - Add targets for inclusion from tests https://github.com/devlooped/oss/commit/81ba912 - Simplify and unify manifest reading implementation https://github.com/devlooped/oss/commit/4fca946 - SponsorLink-enabled analyzers need copylocal https://github.com/devlooped/oss/commit/7593657 - Update dependabot.yml with some default groupings https://github.com/devlooped/oss/commit/cba10bb - Add System.IdentityModel group https://github.com/devlooped/oss/commit/e7d18ae - Add MS.IdentityModel to identity group https://github.com/devlooped/oss/commit/14d1868 - Exclude System.IdentityModel from System group https://github.com/devlooped/oss/commit/35ca3f3 - Fix dependabot group for tests https://github.com/devlooped/oss/commit/49661db - Make sure build runs before pack https://github.com/devlooped/oss/commit/ede013a - Switch to PackOnBuild=true and remove pack step https://github.com/devlooped/oss/commit/6e7a3ab - Upload binlog artifact on debug runs https://github.com/devlooped/oss/commit/a67ae78 - Set env:gh_token if present as secret https://github.com/devlooped/oss/commit/97ebd18 - Update to checkout@v4 https://github.com/devlooped/oss/commit/5fb1723 - Cleanup build and publish to use VersionLabel https://github.com/devlooped/oss/commit/14deaea - Add .sass-cache to ignores https://github.com/devlooped/oss/commit/d65f9c7 - Move .sass-cache down alongside other jekyll folders https://github.com/devlooped/oss/commit/551d4e0 - Ignore azure functions local settings https://github.com/devlooped/oss/commit/4bd7025 - Update .gitignore with BenchmarkDotNet artifacts default path https://github.com/devlooped/oss/commit/e20e906 - Remove whitespace and add results to ignore https://github.com/devlooped/oss/commit/ef852e7 - Only ignore App folder directly under the root https://github.com/devlooped/oss/commit/02811fa - Honor the PackReadme=false property https://github.com/devlooped/oss/commit/1bf1eac - NoTargets/Traversal SDKs now support central package versions too https://github.com/devlooped/oss/commit/afca922 - Enable floating versions for central packages by default https://github.com/devlooped/oss/commit/b1d14c6 - Add static usings to allow unprefixed ThrowXxxx https://github.com/devlooped/oss/commit/6dfe21f - Add compatibility for non-SDK projects without InitializeSourceControlInformation target https://github.com/devlooped/oss/commit/6e96c59 - Set Version from VersionLabel if it's a refs/tags/ https://github.com/devlooped/oss/commit/57653a2 - Append missing trailing path to directory for icon/readme https://github.com/devlooped/oss/commit/5cec43d - Add common sponsors metadata to assemblies https://github.com/devlooped/oss/commit/0789bf0 - Update assembly metadata format for Funding.GitHub https://github.com/devlooped/oss/commit/5801de0 - SponsorLink metadata will be opt-in only by analyzer projects https://github.com/devlooped/oss/commit/c618ea8 - Update dotnet-file.yml with fix to create pull request action https://github.com/devlooped/oss/commit/11a331d - Don't add random wait on manual dotnet-file runs https://github.com/devlooped/oss/commit/7afe350 - Bump create-pr dependency to avoid error with existing PRs https://github.com/devlooped/oss/commit/11a8757 - Only commit markdown files when resolving includes https://github.com/devlooped/oss/commit/2c10a83 --- .github/dependabot.yml | 31 ++ .github/workflows/build.yml | 21 +- .github/workflows/changelog.yml | 2 +- .github/workflows/dotnet-file.yml | 5 +- .github/workflows/includes.yml | 5 +- .github/workflows/publish.yml | 18 +- .github/workflows/sponsor.yml | 2 +- .gitignore | 6 +- .netconfig | 235 +++++++++++-- SponsorLink.sln | 43 +++ src/Directory.Build.props | 23 +- src/Directory.Build.targets | 6 +- src/SponsorLink/Analyzer/Analyzer.csproj | 37 +++ .../Analyzer/Properties/launchSettings.json | 11 + .../Analyzer/StatusReportingAnalyzer.cs | 25 ++ .../buildTransitive/SponsorableLib.targets | 3 + src/SponsorLink/Directory.Build.props | 47 +++ src/SponsorLink/Directory.Build.targets | 8 + src/SponsorLink/Library/Library.csproj | 21 ++ src/SponsorLink/Library/MyClass.cs | 5 + src/SponsorLink/Library/Resources.resx | 123 +++++++ src/SponsorLink/Library/readme.md | 5 + src/SponsorLink/SponsorLink.Tests.targets | 38 +++ src/SponsorLink/SponsorLink.targets | 186 +++++++++++ .../SponsorLink/AppDomainDictionary.cs | 36 ++ .../SponsorLink/DiagnosticsManager.cs | 137 ++++++++ src/SponsorLink/SponsorLink/ManifestStatus.cs | 25 ++ src/SponsorLink/SponsorLink/Resources.es.resx | 163 +++++++++ src/SponsorLink/SponsorLink/Resources.resx | 164 ++++++++++ src/SponsorLink/SponsorLink/SponsorLink.cs | 169 ++++++++++ .../SponsorLink/SponsorLink.csproj | 79 +++++ .../SponsorLink/SponsorLinkAnalyzer.cs | 119 +++++++ src/SponsorLink/SponsorLink/SponsorStatus.cs | 25 ++ .../SponsorLink/SponsorableLib.targets | 60 ++++ src/SponsorLink/SponsorLink/Tracing.cs | 53 +++ .../Devlooped.Sponsors.targets | 102 ++++++ src/SponsorLink/SponsorLink/sponsorable.md | 5 + src/SponsorLink/SponsorLinkAnalyzer.sln | 43 +++ src/SponsorLink/Tests/.netconfig | 15 + src/SponsorLink/Tests/Attributes.cs | 59 ++++ src/SponsorLink/Tests/Extensions.cs | 43 +++ src/SponsorLink/Tests/JsonOptions.cs | 72 ++++ src/SponsorLink/Tests/Resources.Designer.cs | 63 ++++ src/SponsorLink/Tests/Resources.resx | 101 ++++++ src/SponsorLink/Tests/Sample.cs | 59 ++++ src/SponsorLink/Tests/SponsorLinkTests.cs | 126 +++++++ src/SponsorLink/Tests/SponsorableManifest.cs | 309 ++++++++++++++++++ src/SponsorLink/Tests/Tests.csproj | 57 ++++ src/SponsorLink/jwk.ps1 | 1 + src/SponsorLink/readme.md | 34 ++ 50 files changed, 2979 insertions(+), 46 deletions(-) create mode 100644 SponsorLink.sln create mode 100644 src/SponsorLink/Analyzer/Analyzer.csproj create mode 100644 src/SponsorLink/Analyzer/Properties/launchSettings.json create mode 100644 src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs create mode 100644 src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets create mode 100644 src/SponsorLink/Directory.Build.props create mode 100644 src/SponsorLink/Directory.Build.targets create mode 100644 src/SponsorLink/Library/Library.csproj create mode 100644 src/SponsorLink/Library/MyClass.cs create mode 100644 src/SponsorLink/Library/Resources.resx create mode 100644 src/SponsorLink/Library/readme.md create mode 100644 src/SponsorLink/SponsorLink.Tests.targets create mode 100644 src/SponsorLink/SponsorLink.targets create mode 100644 src/SponsorLink/SponsorLink/AppDomainDictionary.cs create mode 100644 src/SponsorLink/SponsorLink/DiagnosticsManager.cs create mode 100644 src/SponsorLink/SponsorLink/ManifestStatus.cs create mode 100644 src/SponsorLink/SponsorLink/Resources.es.resx create mode 100644 src/SponsorLink/SponsorLink/Resources.resx create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorLink.csproj create mode 100644 src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorStatus.cs create mode 100644 src/SponsorLink/SponsorLink/SponsorableLib.targets create mode 100644 src/SponsorLink/SponsorLink/Tracing.cs create mode 100644 src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets create mode 100644 src/SponsorLink/SponsorLink/sponsorable.md create mode 100644 src/SponsorLink/SponsorLinkAnalyzer.sln create mode 100644 src/SponsorLink/Tests/.netconfig create mode 100644 src/SponsorLink/Tests/Attributes.cs create mode 100644 src/SponsorLink/Tests/Extensions.cs create mode 100644 src/SponsorLink/Tests/JsonOptions.cs create mode 100644 src/SponsorLink/Tests/Resources.Designer.cs create mode 100644 src/SponsorLink/Tests/Resources.resx create mode 100644 src/SponsorLink/Tests/Sample.cs create mode 100644 src/SponsorLink/Tests/SponsorLinkTests.cs create mode 100644 src/SponsorLink/Tests/SponsorableManifest.cs create mode 100644 src/SponsorLink/Tests/Tests.csproj create mode 100644 src/SponsorLink/jwk.ps1 create mode 100644 src/SponsorLink/readme.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f551596..c95eb73 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,3 +7,34 @@ updates: directory: / schedule: interval: daily + groups: + Azure: + patterns: + - "Azure*" + - "Microsoft.Azure*" + Identity: + patterns: + - "System.IdentityModel*" + - "Microsoft.IdentityModel*" + System: + patterns: + - "System*" + exclude-patterns: + - "System.IdentityModel*" + Extensions: + patterns: + - "Microsoft.Extensions*" + Web: + patterns: + - "Microsoft.AspNetCore*" + Tests: + patterns: + - "Microsoft.NET.Test*" + - "xunit*" + - "coverlet*" + ThisAssembly: + patterns: + - "ThisAssembly*" + ProtoBuf: + patterns: + - "protobuf-*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0480456..c671ecc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,9 +17,12 @@ on: env: DOTNET_NOLOGO: true + PackOnBuild: true + GeneratePackageOnBuild: true VersionPrefix: 42.42.${{ github.run_number }} VersionLabel: ${{ github.ref }} - + GH_TOKEN: ${{ secrets.GH_TOKEN }} + defaults: run: shell: bash @@ -31,7 +34,7 @@ jobs: matrix: ${{ steps.lookup.outputs.matrix }} steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: 🔎 lookup id: lookup @@ -50,13 +53,13 @@ jobs: os: ${{ fromJSON(needs.os-matrix.outputs.matrix) }} steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: 🙏 build - run: dotnet build -m:1 + run: dotnet build -m:1 -bl:build.binlog - name: ⚙ GNU grep if: matrix.os == 'macOS-latest' @@ -67,8 +70,12 @@ jobs: - name: 🧪 test uses: ./.github/workflows/test - - name: 📦 pack - run: dotnet pack -m:1 + - name: 🐛 logs + uses: actions/upload-artifact@v3 + if: runner.debug && always() + with: + name: logs + path: '*.binlog' # Only push CI package to sleet feed if building on ubuntu (fastest) - name: 🚀 sleet @@ -83,7 +90,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index b120b73..ca50e5a 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -17,7 +17,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: main diff --git a/.github/workflows/dotnet-file.yml b/.github/workflows/dotnet-file.yml index 818aa2c..95f6228 100644 --- a/.github/workflows/dotnet-file.yml +++ b/.github/workflows/dotnet-file.yml @@ -24,7 +24,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: main @@ -32,6 +32,7 @@ jobs: - name: ⌛ rate shell: pwsh + if: github.event_name != 'workflow_dispatch' run: | # add random sleep since we run on fixed schedule sleep (get-random -max 60) @@ -70,7 +71,7 @@ jobs: validate: false - name: ✍ pull request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v6 with: base: main branch: dotnet-file-sync diff --git a/.github/workflows/includes.yml b/.github/workflows/includes.yml index bb1a90b..9cdae21 100644 --- a/.github/workflows/includes.yml +++ b/.github/workflows/includes.yml @@ -21,7 +21,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: token: ${{ env.GH_TOKEN }} @@ -29,8 +29,9 @@ jobs: uses: devlooped/actions-includes@v1 - name: ✍ pull request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v6 with: + add-paths: '**/*.md' base: main branch: markdown-includes delete-branch: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd83ada..a086072 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,25 +10,33 @@ on: env: DOTNET_NOLOGO: true Configuration: Release - + PackOnBuild: true + GeneratePackageOnBuild: true + VersionLabel: ${{ github.ref }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + jobs: publish: runs-on: ubuntu-latest steps: - name: 🤘 checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: 🙏 build - run: dotnet build -m:1 -p:version=${GITHUB_REF#refs/*/v} + run: dotnet build -m:1 -bl:build.binlog - name: 🧪 test uses: ./.github/workflows/test - - name: 📦 pack - run: dotnet pack -m:1 -p:version=${GITHUB_REF#refs/*/v} + - name: 🐛 logs + uses: actions/upload-artifact@v3 + if: runner.debug && always() + with: + name: logs + path: '*.binlog' - name: 🚀 nuget run: dotnet nuget push ./bin/**/*.nupkg -s https://api.nuget.org/v3/index.json -k ${{secrets.NUGET_API_KEY}} --skip-duplicate diff --git a/.github/workflows/sponsor.yml b/.github/workflows/sponsor.yml index 9e47191..1d484d3 100644 --- a/.github/workflows/sponsor.yml +++ b/.github/workflows/sponsor.yml @@ -15,7 +15,7 @@ jobs: steps: - name: 🤘 checkout if: env.token != '' - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: 💜 sponsor if: env.token != '' diff --git a/.gitignore b/.gitignore index 0c18de7..6639458 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,15 @@ bin -app obj artifacts pack TestResults +results +BenchmarkDotNet.Artifacts +/app .vs .vscode .idea +local.settings.json *.suo *.sdf @@ -31,5 +34,6 @@ node_modules _site .jekyll-metadata .jekyll-cache +.sass-cache Gemfile.lock package-lock.json diff --git a/.netconfig b/.netconfig index 9e30a9e..ebe97f5 100644 --- a/.netconfig +++ b/.netconfig @@ -22,18 +22,18 @@ weak [file ".github/dependabot.yml"] url = https://github.com/devlooped/oss/blob/main/.github/dependabot.yml - sha = 4f070a477b4162a280f02722ae666376ae4fcc71 - etag = 35f2134fff3b0235ff8dac8618a76198c8ef533ad2f29628bbb435cd1134d638 + sha = 49661dbf0720cde93eb5569be7523b5912351560 + etag = c147ea2f3431ca0338c315c4a45b56ee233c4d30f8d6ab698d0e1980a257fd6a weak [file ".github/workflows/build.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/build.yml - sha = 13d67e2cf3f786c8189364fd29332aaa7dc575dc - etag = c616df0877fba60002ccfc0397e9f731ddb22acbbb195a0598fedd4cac5f3135 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = d9fa5d91dc601f10d19099abb55c86df065cd1c23b1f6fab98ad883cb443bf5c weak [file ".gitignore"] url = https://github.com/devlooped/oss/blob/main/.gitignore - sha = b87a8a795a4c2b6830602225c066c11108552a99 - etag = 96e0860052044780f1fc9e3bdfbee09d82d5dddb8b1217d67460fc7330a64dd8 + sha = 02811fa23b0a102b9b33048335d41e515bf75737 + etag = a9c37ae312afac14b78436a7d018af4483d88736b5f780576f2c5a0b3f14998c weak [file "Directory.Build.rsp"] url = https://github.com/devlooped/oss/blob/main/Directory.Build.rsp @@ -52,23 +52,23 @@ weak [file "src/Directory.Build.props"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.props - sha = 6ae80a175a8f926ac5d9ffb0f6afd55d85cc9320 - etag = 69d4b16c14d5047b3ed812dbf556b0b8d77deb86f73af04b9bd3640220056fa8 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = f177eb767aaa6a347da43ff7ff419c9a0736c562cb171e17ded8007a1945a8b0 weak [file "src/Directory.Build.targets"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.targets - sha = 1514d15399a7d545ad92a0e9d57dc8295fdd6af8 - etag = 428f80b0786ff17b836c7a5b0640948724855d17933e958642b22849ac00dadb + sha = c618ea86d94402a12c7d7d10fe2b5cb8a21c3eea + etag = 7cb1421f00d9f6f4c00f0ca98e485dcadb927cfa6b3f0b5d4fb212525d2ce9c0 weak [file ".github/workflows/changelog.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/changelog.yml - sha = a4b66eb5f4dfb9704502f19f59ba33cb4855188c - etag = 54c0b571648b1055beb3ddac180b34e93a9869b9f0277de306901b2c1dbe0b2c + sha = 5fb172362c767bef7c36478f1a6bdc264723f8f9 + etag = ad1efa56d6024ee1add2bcda81a7e4e38d0e9069473c6ff70374d5ce06af1f5a weak [file ".github/workflows/publish.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/publish.yml - sha = d3022567c9ef2bc9461511e53b8abe065afdf03b - etag = 58601b5a71c805647ab26e84053acdfb8d174eaa93330487af8a5503753c5707 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = 4e9a9885a28ce867fd6139e1ae23735ad0073775145af96ff7d96d047d750973 weak [file "assets/css/style.scss"] url = https://github.com/devlooped/oss/blob/main/assets/css/style.scss @@ -85,8 +85,8 @@ weak [file ".github/workflows/dotnet-file.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/dotnet-file.yml - sha = f08c3f28e46e28eb31e70846d65e57aa9553ce56 - etag = 567444486383d032c1c5fbc538f07e860f92b1d08c66ac6ffb1db64ca539251c + sha = 7afe350f7e80a230e922db026d4e1198ba15cae1 + etag = 65e9794df6caff779eb989c8f71ddf4d4109b24a75af79e4f8d0fe6ba7bd9702 weak [file ".github/workflows/test/action.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/test/action.yml @@ -98,13 +98,13 @@ skip [file ".github/workflows/includes.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/includes.yml - sha = ac753b791d03997eb655efb26ae141b51addd1c0 - etag = fcd94a08ac9ebc0e8351deac4e7f085cf8ef67816cc50006e068f44166096eb8 + sha = 5fb172362c767bef7c36478f1a6bdc264723f8f9 + etag = e5ee22e115c925fb85ec931cda3ac811fcc453c03904554fa3f573935b221d34 weak [file ".github/workflows/sponsor.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/sponsor.yml - sha = 8990ebb36199046e0b8098bad9e46dcef739c56e - etag = e1dc114d2e8b57d50649989d32dbf0c9080ec77da3738a4cc79e9256d6ca5d3e + sha = 5fb172362c767bef7c36478f1a6bdc264723f8f9 + etag = 0849ee61af6daee29615f9632173b4e82da5bfa9d78ff28907e9408bd5acde4d weak [file ".github/release.yml"] url = https://github.com/devlooped/oss/blob/main/.github/release.yml @@ -121,3 +121,198 @@ sha = c1610886eba42cb250e3894aed40c0a258cd383d etag = 598ee294649a44d4c5d5934416c01183597d08aa7db7938453fd2bbf52a4e64d weak +[file "SponsorLink.sln"] + url = https://github.com/devlooped/oss/blob/main/SponsorLink.sln + sha = e732f6a2c44a2f7940a1868a92cd66523f74ed24 + etag = 5f4cdd4e73a4ac03a41b6f11ec5c576f7a0e7ecef95fcdae04006abc39ea729b + weak +[file "src/SponsorLink/Analyzer/Analyzer.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/Analyzer.csproj + sha = 93df7c7ec34f83ae58efbf213624d5ea31fe3c41 + etag = f76e33fde812244a275b95c8815101f6f87d144a5305a2c1f0f631f770d91920 + weak +[file "src/SponsorLink/Analyzer/Properties/launchSettings.json"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/Properties/launchSettings.json + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 6c59ab4d008e3221e316c9e3b6e0da155b892680d48cdc400a39d53cb9a12aac + weak +[file "src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = cde10b763b87a3987e86cca2292c9afc7637d2113b9921e79492b6a31620bbb4 + weak +[file "src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 332060de0945590d7c41cd237c250b8186acd6fc2045cc85a890368c74fdf473 + weak +[file "src/SponsorLink/Directory.Build.props"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Directory.Build.props + sha = 3b943f5aa59f33141d1c0fffcb215446d594ad53 + etag = 0c7737411744012078642dbfc174af3f2ac7dc9f7b8ea4423981ae38753a5be4 + weak +[file "src/SponsorLink/Directory.Build.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Directory.Build.targets + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 9938f29c3573bf8bdb9686e1d9884dee177256b1d5dd7ee41472dd64bfbdd92d + weak +[file "src/SponsorLink/Library/Library.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Library/Library.csproj + sha = 93df7c7ec34f83ae58efbf213624d5ea31fe3c41 + etag = 56233a536fb38edd75f66f6a9a9e6044eb227a0b58fb791495ff88e43649feb7 + weak +[file "src/SponsorLink/Library/MyClass.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Library/MyClass.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = b5b3ccd6cd14bb90dd9702b9d7e52cc22c11e601c039617738d688f9fd45d49b + weak +[file "src/SponsorLink/Library/Resources.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Library/Resources.resx + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = aff6051733d22982e761f2b414173aafeab40e0a76a142e2b33025dced213eb2 + weak +[file "src/SponsorLink/Library/readme.md"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Library/readme.md + sha = 55124bc610b2dcad9efb343bdffc79c959170593 + etag = 5002ac8c5bbeee60c13937a32c1b6c1a5dbf0065617c8f2550e6eca6fded256d + weak +[file "src/SponsorLink/SponsorLink.Tests.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink.Tests.targets + sha = 81ba912310dd4b723c7a0103a76cb71b183983b1 + etag = cf6deba5b5cdadb5b2ea6b8533331da49afd3c841db2932a45618627ffc4ff9a + weak +[file "src/SponsorLink/SponsorLink.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink.targets + sha = 759365751e6529049a3df5701f85aecb51189289 + etag = 6e3955b7233c5c2000b9adf1bb281e74e7fb08813e17b3ef10fd8b5d50f9fb4d + weak +[file "src/SponsorLink/SponsorLink/AppDomainDictionary.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/AppDomainDictionary.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 4a70f86e73f951bca95618c221d821e38a31ef9092af4ac61447eab845671a28 + weak +[file "src/SponsorLink/SponsorLink/DiagnosticsManager.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/DiagnosticsManager.cs + sha = cf154d5d9c2ac3dad56e95da04effdad64409471 + etag = 7ac9738f71cafd15dbb347bc9d83468b0691d0b0888cc82e35c161fd1f2d48eb + weak +[file "src/SponsorLink/SponsorLink/ManifestStatus.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/ManifestStatus.cs + sha = b2a11faac6c1c300bce8c1d45f95b585c19f2953 + etag = e46848f83c0436ba33a1c09a4060ad627a74db41bab66bb37ca40fce8a6532a7 + weak +[file "src/SponsorLink/SponsorLink/Resources.es.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/Resources.es.resx + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = c0a05bb5efedf8e30a73ab96678579ad33832e4a4aec75d3b596b47f248c23f5 + weak +[file "src/SponsorLink/SponsorLink/Resources.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/Resources.resx + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = fcb46a86511cb7996e8dcd1f4e283cea9cd51610b094ac49a7396301730814b0 + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.cs + sha = 55124bc610b2dcad9efb343bdffc79c959170593 + etag = 28178198489bf9b72f8a400563950194a06f7ce55ff4a016535eb1be35fa70b8 + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.csproj + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = 997b08082f85a491be7a68805d7811e65e1474a6e7d49cbe927617f7035d21e1 + weak +[file "src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs + sha = 23f83bd6b1f0fe13ac02bf464377f576652fec97 + etag = 5f9823d1bf83f7d28e5809e0a08d942fb2c444f4653ca5b035d500ebef2ead15 + weak +[file "src/SponsorLink/SponsorLink/SponsorStatus.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorStatus.cs + sha = 4fca946c3201d90d30e2183f699c850dcc1bf8d5 + etag = 9a5f6f35c38c34b77796925d80addc998e204bc112fcd5fc124030060390e7c2 + weak +[file "src/SponsorLink/SponsorLink/SponsorableLib.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorableLib.targets + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 2f923a97081481a6a264d63c8ff70ce5ba65c3dbaf7ea078cbe1388fb0868e1c + weak +[file "src/SponsorLink/SponsorLink/Tracing.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/Tracing.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 22e32872cafd080bcd5ac9084355578ef70910c8e494602ead365139dcbf40c0 + weak +[file "src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets + sha = 55124bc610b2dcad9efb343bdffc79c959170593 + etag = 46842d44ece3d55285bc30a6b22ac21c1c35d3b0c451aa5285d4ca4564b8698c + weak +[file "src/SponsorLink/SponsorLink/sponsorable.md"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/sponsorable.md + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 9c275d50705a2e661f0f86f1ae5e555c0033a05e86e12f936283a5b5ef47ae77 + weak +[file "src/SponsorLink/SponsorLinkAnalyzer.sln"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLinkAnalyzer.sln + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = fc2928c9b303d81ff23891ee791a859b794d9f2d4b9f4e81b9ed15e5b74db487 + weak +[file "src/SponsorLink/Tests/.netconfig"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/.netconfig + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 089a26cdb722d57014c8b8104cc6f4e770868efdc49ae3119eebc873f00a316e + weak +[file "src/SponsorLink/Tests/Attributes.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Attributes.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 1d7c17a2c9424db73746112c338a39e0000134ac878b398e2aa88f7ea5c0c488 + weak +[file "src/SponsorLink/Tests/Extensions.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Extensions.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = f68e11894103f8748ce290c29927bf1e4f749e743ae33d5350e72ed22c15d245 + weak +[file "src/SponsorLink/Tests/JsonOptions.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/JsonOptions.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 6e9a1b12757a97491441b9534ced4e5dac6d9d6334008fa0cd20575650bbd935 + weak +[file "src/SponsorLink/Tests/Resources.Designer.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Resources.Designer.cs + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = 69404ac09238930893fdbc225ae7839b14957e129b4c05f1ef0e7afcc4c91d63 + weak +[file "src/SponsorLink/Tests/Resources.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Resources.resx + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = 13d1bb8b0de32a8c9b5dbdc806a036ed89d423cd7c0be187b8c56055c9bf7783 + weak +[file "src/SponsorLink/Tests/Sample.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Sample.cs + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = c4ed1e041d1ec816710757790aaa6688e3756870cfd98ba7e6c7b5103ce3a9ba + weak +[file "src/SponsorLink/Tests/SponsorLinkTests.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/SponsorLinkTests.cs + sha = d74f5111504a0fae6e5a1e68ca92bf7afddb3254 + etag = 1fa41250bd984e8aa840a966d34ce0e94f2111d1422d7f50b864c38364fcf4a4 + weak +[file "src/SponsorLink/Tests/SponsorableManifest.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/SponsorableManifest.cs + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = e0c95e7fc6c0499dbc8c5cd28aa9a6a5a49c9d0ad41fe028a5a085aca7e00eaf + weak +[file "src/SponsorLink/Tests/Tests.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Tests.csproj + sha = c879f25bf483086725c8a29f104555644e6ee542 + etag = 31d33feb5860cd6df71ee2d6f3ca6d8fdc9e6535bea8caa97300421c0502246e + weak +[file "src/SponsorLink/jwk.ps1"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/jwk.ps1 + sha = c4830fc3b1aa78ec98d1d2ea4fed86ef0b7b803c + etag = f399e05ecb56adaf41d2545171f299a319142b17dd09fc38e452ca8c5d13bd0d + weak +[file "src/SponsorLink/readme.md"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/readme.md + sha = 827a1d18bf0245978d81bcd3d52e9e6f1584d1ef + etag = 079b4aedba2aa9851e609b569f25c55db8d5922e3dbb1adc22611ce4d6cfe465 + weak diff --git a/SponsorLink.sln b/SponsorLink.sln new file mode 100644 index 0000000..d4eab56 --- /dev/null +++ b/SponsorLink.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.34909.67 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SponsorLink", "src\SponsorLink\SponsorLink\SponsorLink.csproj", "{1E1D01A2-D202-4FAB-B21B-AF21B1C37163}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzer", "src\SponsorLink\Analyzer\Analyzer.csproj", "{87B3A42C-FFA7-49CF-8F3A-656A6D213246}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library", "src\SponsorLink\Library\Library.csproj", "{23371E8B-2401-42A1-9A01-4720D8388105}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "src\SponsorLink\Tests\Tests.csproj", "{A86B253A-340E-4B82-8207-336BF65F36C8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Release|Any CPU.Build.0 = Release|Any CPU + {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Release|Any CPU.Build.0 = Release|Any CPU + {23371E8B-2401-42A1-9A01-4720D8388105}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23371E8B-2401-42A1-9A01-4720D8388105}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23371E8B-2401-42A1-9A01-4720D8388105}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23371E8B-2401-42A1-9A01-4720D8388105}.Release|Any CPU.Build.0 = Release|Any CPU + {A86B253A-340E-4B82-8207-336BF65F36C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A86B253A-340E-4B82-8207-336BF65F36C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A86B253A-340E-4B82-8207-336BF65F36C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A86B253A-340E-4B82-8207-336BF65F36C8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {36BC3C24-D4E3-4EB0-A910-4BE4BD8FE01F} + EndGlobalSection +EndGlobal diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 6b9a668..1648dcd 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -26,10 +26,10 @@ icon.png - readme.md + readme.md icon.png - readme.md + readme.md true true @@ -37,7 +37,8 @@ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\bin')) - true + true + true true @@ -117,6 +118,8 @@ <_VersionLabel>$(VersionLabel.Replace('refs/heads/', '')) + <_VersionLabel>$(_VersionLabel.Replace('refs/tags/v', '')) + <_VersionLabel Condition="$(_VersionLabel.Contains('refs/pull/'))">$(VersionLabel.TrimEnd('.0123456789')) @@ -127,7 +130,9 @@ <_VersionLabel>$(_VersionLabel.Replace('/', '-')) - $(_VersionLabel) + $(_VersionLabel) + + $(_VersionLabel) @@ -141,6 +146,16 @@ + + + + + + + + + diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 5bd4019..0cb1e4e 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -35,16 +35,16 @@ + Condition="'$(PackReadme)' != 'false' and '$(PackageReadmeFile)' != ''" /> + Condition="Exists('$(MSBuildThisFileDirectory)icon.png') and !Exists('$(MSBuildProjectDirectory)\icon.png')" /> + Condition="'$(PackReadme)' != 'false' and Exists('$(MSBuildThisFileDirectory)readme.md') and !Exists('$(MSBuildProjectDirectory)\readme.md')" /> + false + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)bin')) + + https://pkg.kzu.app/index.json;https://api.nuget.org/v3/index.json + $(PackageOutputPath);$(RestoreSources) + + + $([System.DateTime]::Parse("2024-03-15")) + $([System.DateTime]::UtcNow.Subtract($(Epoc)).TotalDays) + $([System.Math]::Truncate($(TotalDays))) + $([System.Math]::Floor($([MSBuild]::Divide($([System.DateTime]::UtcNow.TimeOfDay.TotalSeconds), 10)))) + 42.$(Days).$(Seconds) + + SponsorableLib + + + + + + + + + + + + diff --git a/src/SponsorLink/Directory.Build.targets b/src/SponsorLink/Directory.Build.targets new file mode 100644 index 0000000..4ce4c80 --- /dev/null +++ b/src/SponsorLink/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/Library/Library.csproj b/src/SponsorLink/Library/Library.csproj new file mode 100644 index 0000000..f363648 --- /dev/null +++ b/src/SponsorLink/Library/Library.csproj @@ -0,0 +1,21 @@ + + + + SponsorableLib + netstandard2.0 + true + SponsorableLib + Sample library incorporating SponsorLink checks + true + true + + + + + + + + + + + diff --git a/src/SponsorLink/Library/MyClass.cs b/src/SponsorLink/Library/MyClass.cs new file mode 100644 index 0000000..7b7f6f5 --- /dev/null +++ b/src/SponsorLink/Library/MyClass.cs @@ -0,0 +1,5 @@ +namespace SponsorableLib; + +public class MyClass +{ +} diff --git a/src/SponsorLink/Library/Resources.resx b/src/SponsorLink/Library/Resources.resx new file mode 100644 index 0000000..636fedc --- /dev/null +++ b/src/SponsorLink/Library/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Bar + + \ No newline at end of file diff --git a/src/SponsorLink/Library/readme.md b/src/SponsorLink/Library/readme.md new file mode 100644 index 0000000..ba4ce37 --- /dev/null +++ b/src/SponsorLink/Library/readme.md @@ -0,0 +1,5 @@ +# Sponsorable Library + +Example of a library that is available for sponsorship and leverages +[SponsorLink](https://github.com/devlooped/SponsorLink) to remind users +in an IDE (VS/Rider). diff --git a/src/SponsorLink/SponsorLink.Tests.targets b/src/SponsorLink/SponsorLink.Tests.targets new file mode 100644 index 0000000..ffc7586 --- /dev/null +++ b/src/SponsorLink/SponsorLink.Tests.targets @@ -0,0 +1,38 @@ + + + + + true + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink.targets b/src/SponsorLink/SponsorLink.targets new file mode 100644 index 0000000..6de86fb --- /dev/null +++ b/src/SponsorLink/SponsorLink.targets @@ -0,0 +1,186 @@ + + + + + + + true + + true + + true + + CoreResGen;$(CoreCompileDependsOn) + + + $(Product) + + $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", "")) + + 21 + + + + + + + + + + + + SponsorLink\%(RecursiveDir)%(Filename)%(Extension) + + + SponsorLink\%(RecursiveDir)%(Filename)%(Extension) + + + SponsorLink\%(RecursiveDir)%(Filename)%(Extension) + + + SponsorLink\%(PackagePath) + + + + + + false + + + false + + + false + + + false + + + + + + + + + + + + + + + + + + + + namespace Devlooped.Sponsors%3B + +partial class SponsorLink +{ + public partial class Funding + { + public const string Product = "$(FundingProduct)"%3B + public const string Prefix = "$(FundingPrefix)"%3B + public const int Grace = $(FundingGrace)%3B + } +} + + + + + + + + + + + + + + + + + + + + + + $([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)','$(AssemblyOriginatorKeyFile)')))) + /keyfile:"$(AbsoluteAssemblyOriginatorKeyFile)" /delaysign + $(ILRepackArgs) /internalize + $(ILRepackArgs) /union + + $(ILRepackArgs) @(LibDir -> '/lib:"%(Identity)."', ' ') + $(ILRepackArgs) /out:"@(IntermediateAssembly -> '%(FullPath)')" + $(ILRepackArgs) "@(IntermediateAssembly -> '%(FullPath)')" + $(ILRepackArgs) @(MergedAssemblies -> '"%(FullPath)"', ' ') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)devlooped.jwk')) + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/AppDomainDictionary.cs b/src/SponsorLink/SponsorLink/AppDomainDictionary.cs new file mode 100644 index 0000000..05cc949 --- /dev/null +++ b/src/SponsorLink/SponsorLink/AppDomainDictionary.cs @@ -0,0 +1,36 @@ +// +#nullable enable +using System; + +namespace Devlooped.Sponsors; + +/// +/// A helper class to store and retrieve values from the current +/// as typed named values. +/// +/// +/// This allows tools that run within the same app domain to share state, such as +/// MSBuild tasks or Roslyn analyzers. +/// +static class AppDomainDictionary +{ + /// + /// Gets the value associated with the specified name, or creates a new one if it doesn't exist. + /// + public static TValue Get(string name) where TValue : notnull, new() + { + var data = AppDomain.CurrentDomain.GetData(name); + if (data is TValue firstTry) + return firstTry; + + lock (AppDomain.CurrentDomain) + { + if (AppDomain.CurrentDomain.GetData(name) is TValue secondTry) + return secondTry; + + var newValue = new TValue(); + AppDomain.CurrentDomain.SetData(name, newValue); + return newValue; + } + } +} \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/DiagnosticsManager.cs b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs new file mode 100644 index 0000000..c22ecc8 --- /dev/null +++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs @@ -0,0 +1,137 @@ +// +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Globalization; +using Humanizer; +using Microsoft.CodeAnalysis; + +namespace Devlooped.Sponsors; + +/// +/// Manages diagnostics for the SponsorLink analyzer so that there are no duplicates +/// when multiple projects share the same product name (i.e. ThisAssembly). +/// +class DiagnosticsManager +{ + /// + /// Acceses the diagnostics dictionary for the current . + /// + ConcurrentDictionary Diagnostics + => AppDomainDictionary.Get>(nameof(Diagnostics)); + + /// + /// Creates a descriptor from well-known diagnostic kinds. + /// + /// The names of the sponsorable accounts that can be funded for the given product. + /// The product or project developed by the sponsorable(s). + /// Custom prefix to use for diagnostic IDs. + /// The kind of status diagnostic to create. + /// The given . + /// The is not one of the known ones. + public DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch + { + SponsorStatus.Unknown => CreateUnknown(sponsorable, product, prefix), + SponsorStatus.Sponsor => CreateSponsor(sponsorable, prefix), + SponsorStatus.Expiring => CreateExpiring(sponsorable, prefix), + SponsorStatus.Expired => CreateExpired(sponsorable, prefix), + _ => throw new NotImplementedException(), + }; + + /// + /// Pushes a diagnostic for the given product. If an existing one exists, it is replaced. + /// + /// The same diagnostic that was pushed, for chained invocations. + public Diagnostic Push(string product, Diagnostic diagnostic) + { + // Directly sets, since we only expect to get one warning per sponsorable+product + // combination. + Diagnostics[product] = diagnostic; + return diagnostic; + } + + /// + /// Attemps to remove a diagnostic for the given product. + /// + /// The product diagnostic that might have been pushed previously. + /// The removed diagnostic, or if none was previously pushed. + public Diagnostic? Pop(string product) + { + Diagnostics.TryRemove(product, out var diagnostic); + return diagnostic; + } + + /// + /// Gets the status of the given product based on a previously stored diagnostic. + /// + /// The product to check status for. + /// Optional that was reported, if any. + public SponsorStatus? GetStatus(string product) + { + // NOTE: the SponsorLinkAnalyzer.SetStatus uses diagnostic properties to store the + // kind of diagnostic as a simple string instead of the enum. We do this so that + // multiple analyzers or versions even across multiple products, which all would + // have their own enum, can still share the same diagnostic kind. + if (Diagnostics.TryGetValue(product, out var diagnostic) && + diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value)) + { + // Switch on value matching DiagnosticKind names + return value switch + { + nameof(SponsorStatus.Unknown) => SponsorStatus.Unknown, + nameof(SponsorStatus.Sponsor) => SponsorStatus.Sponsor, + nameof(SponsorStatus.Expiring) => SponsorStatus.Expiring, + nameof(SponsorStatus.Expired) => SponsorStatus.Expired, + _ => null, + }; + } + + return null; + } + + static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new( + $"{prefix}100", + Resources.Sponsor_Title, + Resources.Sponsor_Message, + "SponsorLink", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: Resources.Sponsor_Description, + helpLinkUri: "https://github.com/devlooped#sponsorlink", + "DoesNotSupportF1Help"); + + static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new( + $"{prefix}101", + Resources.Unknown_Title, + Resources.Unknown_Message, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: string.Format(CultureInfo.CurrentCulture, Resources.Unknown_Description, + sponsorable.Humanize(x => $"https://github.com/sponsors/{x}"), + string.Join(" ", sponsorable)), + helpLinkUri: "https://github.com/devlooped#sponsorlink", + WellKnownDiagnosticTags.NotConfigurable); + + static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new( + $"{prefix}103", + Resources.Expiring_Title, + Resources.Expiring_Message, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: string.Format(CultureInfo.CurrentCulture, Resources.Expiring_Description, string.Join(" ", sponsorable)), + helpLinkUri: "https://github.com/devlooped#autosync", + "DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable); + + static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new( + $"{prefix}104", + Resources.Expired_Title, + Resources.Expired_Message, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: string.Format(CultureInfo.CurrentCulture, Resources.Expired_Description, string.Join(" ", sponsorable)), + helpLinkUri: "https://github.com/devlooped#autosync", + "DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable); +} diff --git a/src/SponsorLink/SponsorLink/ManifestStatus.cs b/src/SponsorLink/SponsorLink/ManifestStatus.cs new file mode 100644 index 0000000..0960e5a --- /dev/null +++ b/src/SponsorLink/SponsorLink/ManifestStatus.cs @@ -0,0 +1,25 @@ +// +namespace Devlooped.Sponsors; + +/// +/// The resulting status from validation. +/// +public enum ManifestStatus +{ + /// + /// The manifest couldn't be read at all. + /// + Unknown, + /// + /// The manifest was read and is valid (not expired and properly signed). + /// + Valid, + /// + /// The manifest was read but has expired. + /// + Expired, + /// + /// The manifest was read, but its signature is invalid. + /// + Invalid, +} diff --git a/src/SponsorLink/SponsorLink/Resources.es.resx b/src/SponsorLink/SponsorLink/Resources.es.resx new file mode 100644 index 0000000..ec1b5c1 --- /dev/null +++ b/src/SponsorLink/SponsorLink/Resources.es.resx @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Patrocinar los proyectos en que dependes asegura que se mantengan activos, y que recibas el apoyo que necesitas. También es muy económico y está disponible en todo el mundo! +Por favor considera apoyar el proyecto patrocinando en {0} y ejecutando posteriormente 'sponsor sync {1}'. + + + Por favor considere apoyar {0} patrocinando @{1} 🙏 + + + Estado de patrocinio desconocido + + + Funcionalidades exclusivas para patrocinadores pueden no estar disponibles. Ejecuta 'sponsor sync {0}' y, opcionalmente, habilita la sincronización automática. + + + El estado de patrocino ha expirado y la sincronización automática no está habilitada. + + + El estado de patrocino ha expirado + + + Eres un verdadero héroe. Tu patrocinio ayuda a mantener el proyecto vivo y próspero 🙏. + + + Gracias por apoyar a {0} con tu patrocinio 💟! + + + Eres un patrocinador del proyecto, eres lo máximo 💟! + + + El estado de patrocino ha expirado y estás en un período de gracia. Ejecuta 'sponsor sync {0}' y, opcionalmente, habilita la sincronización automática. + + + El estado de patrocino necesita actualización periódica y la sincronización automática no está habilitada. + + + El estado de patrocino ha expirado y el período de gracia terminará pronto + + + y + + + o + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/Resources.resx b/src/SponsorLink/SponsorLink/Resources.resx new file mode 100644 index 0000000..e12a0e5 --- /dev/null +++ b/src/SponsorLink/SponsorLink/Resources.resx @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Sponsoring projects you depend on ensures they remain active, and that you get the support you need. It's also super affordable and available worldwide! +Please consider supporting the project by sponsoring at {0} and running 'sponsor sync {1}' afterwards. + Unknown sponsor description + + + Please consider supporting {0} by sponsoring @{1} 🙏 + + + Unknown sponsor status + + + Sponsor-only features may be disabled. Please run 'sponsor sync {0}' and optionally enable automatic sync. + + + Sponsor status has expired and automatic sync has not been enabled. + + + Sponsor status expired + + + You are a true hero. Your sponsorship helps keep the project alive and thriving 🙏. + + + Thank you for supporting {0} with your sponsorship 💟! + + + You are a sponsor of the project, you rock 💟! + + + Sponsor status has expired and you are in the grace period. Please run 'sponsor sync {0}' and optionally enable automatic sync. + + + Sponsor status needs periodic updating and automatic sync has not been enabled. + + + Sponsor status expired, grace period ending soon + + + and + + + or + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/SponsorLink.cs b/src/SponsorLink/SponsorLink/SponsorLink.cs new file mode 100644 index 0000000..f3d8328 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.cs @@ -0,0 +1,169 @@ +// +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.Sponsors; + +static partial class SponsorLink +{ + public static Dictionary Sponsorables { get; } = typeof(SponsorLink).Assembly + .GetCustomAttributes() + .Where(x => x.Key.StartsWith("Funding.GitHub.")) + .Select(x => new { Key = x.Key[15..], x.Value }) + .ToDictionary(x => x.Key, x => x.Value); + + /// + /// Whether the current process is running in an IDE, either + /// or . + /// + public static bool IsEditor => IsVisualStudio || IsRider; + + /// + /// Whether the current process is running as part of an active Visual Studio instance. + /// + public static bool IsVisualStudio => + Environment.GetEnvironmentVariable("ServiceHubLogSessionKey") != null || + Environment.GetEnvironmentVariable("VSAPPIDNAME") != null; + + /// + /// Whether the current process is running as part of an active Rider instance. + /// + public static bool IsRider => + Environment.GetEnvironmentVariable("RESHARPER_FUS_SESSION") != null || + Environment.GetEnvironmentVariable("IDEA_INITIAL_DIRECTORY") != null; + + /// + /// Manages the sharing and reporting of diagnostics across the source generator + /// and the diagnostic analyzer, to avoid doing the online check more than once. + /// + public static DiagnosticsManager Diagnostics { get; } = new(); + + /// + /// Gets the expiration date from the principal, if any. + /// + /// + /// Whichever "exp" claim is the latest, or if none found. + /// + public static DateTime? GetExpiration(this ClaimsPrincipal principal) + // get all "exp" claims, parse them and return the latest one or null if none found + => principal.FindAll("exp") + .Select(c => c.Value) + .Select(long.Parse) + .Select(DateTimeOffset.FromUnixTimeSeconds) + .Max().DateTime is var exp && exp == DateTime.MinValue ? null : exp; + + /// + /// Reads all manifests, validating their signatures. + /// + /// The combined principal with all identities (and their claims) from each provided and valid JWT + /// The tokens to read and their corresponding JWK for signature verification. + /// if at least one manifest can be successfully read and is valid. + /// otherwise. + public static bool TryRead([NotNullWhen(true)] out ClaimsPrincipal? principal, params (string jwt, string jwk)[] values) + => TryRead(out principal, values.AsEnumerable()); + + /// + /// Reads all manifests, validating their signatures. + /// + /// The combined principal with all identities (and their claims) from each provided and valid JWT + /// The tokens to read and their corresponding JWK for signature verification. + /// if at least one manifest can be successfully read and is valid. + /// otherwise. + public static bool TryRead([NotNullWhen(true)] out ClaimsPrincipal? principal, IEnumerable<(string jwt, string jwk)> values) + { + principal = null; + + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value.jwt) || string.IsNullOrEmpty(value.jwk)) + continue; + + if (Validate(value.jwt, value.jwk, out var token, out var claims, false) == ManifestStatus.Valid && claims != null) + { + if (principal == null) + principal = claims; + else + principal.AddIdentities(claims.Identities); + } + } + + return principal != null; + } + + /// + /// Validates the manifest signature and optional expiration. + /// + /// The JWT to validate. + /// The key to validate the manifest signature with. + /// Except when returning , returns the security token read from the JWT, even if signature check failed. + /// The associated claims, only when return value is not . + /// Whether to check for expiration. + /// The status of the validation. + public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? token, out ClaimsPrincipal? principal, bool validateExpiration) + { + token = default; + principal = default; + + SecurityKey key; + try + { + key = JsonWebKey.Create(jwk); + } + catch (ArgumentException) + { + return ManifestStatus.Unknown; + } + + var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + + if (!handler.CanReadToken(jwt)) + return ManifestStatus.Unknown; + + var validation = new TokenValidationParameters + { + RequireExpirationTime = false, + ValidateLifetime = false, + ValidateAudience = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + RoleClaimType = "roles", + NameClaimType = "sub", + }; + + try + { + principal = handler.ValidateToken(jwt, validation, out token); + if (validateExpiration && token.ValidTo == DateTime.MinValue) + return ManifestStatus.Invalid; + + // The sponsorable manifest does not have an expiration time. + if (validateExpiration && token.ValidTo < DateTimeOffset.UtcNow) + return ManifestStatus.Expired; + + return ManifestStatus.Valid; + } + catch (SecurityTokenInvalidSignatureException) + { + var jwtToken = handler.ReadJwtToken(jwt); + token = jwtToken; + principal = new ClaimsPrincipal(new ClaimsIdentity(jwtToken.Claims)); + return ManifestStatus.Invalid; + } + catch (SecurityTokenException) + { + var jwtToken = handler.ReadJwtToken(jwt); + token = jwtToken; + principal = new ClaimsPrincipal(new ClaimsIdentity(jwtToken.Claims)); + return ManifestStatus.Invalid; + } + } + +} diff --git a/src/SponsorLink/SponsorLink/SponsorLink.csproj b/src/SponsorLink/SponsorLink/SponsorLink.csproj new file mode 100644 index 0000000..824353d --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.csproj @@ -0,0 +1,79 @@ + + + + netstandard2.0 + SponsorLink + disable + false + CoreResGen;$(CoreCompileDependsOn) + + + + + $(Product) + + $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", "")) + + 21 + + + + + + + + + + + + + + + + + + + + + + + + namespace Devlooped.Sponsors%3B + +partial class SponsorLink +{ + public partial class Funding + { + public const string Product = "$(FundingProduct)"%3B + public const string Prefix = "$(FundingPrefix)"%3B + public const int Grace = $(FundingGrace)%3B + } +} + + + + + + + + + + + + + + + + + + + + + $([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)devlooped.jwk')) + + + + + + + diff --git a/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs new file mode 100644 index 0000000..2bf1783 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs @@ -0,0 +1,119 @@ +// +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Humanizer; +using Humanizer.Localisation; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using static Devlooped.Sponsors.SponsorLink; + +namespace Devlooped.Sponsors; + +/// +/// Links the sponsor status for the current compilation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] +public class SponsorLinkAnalyzer : DiagnosticAnalyzer +{ + static readonly Dictionary descriptors = new() + { + // Requires: + // + // + { SponsorStatus.Unknown, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Unknown) }, + { SponsorStatus.Sponsor, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Sponsor) }, + { SponsorStatus.Expiring, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expiring) }, + { SponsorStatus.Expired, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expired) }, + }; + + public override ImmutableArray SupportedDiagnostics { get; } = descriptors.Values.ToImmutableArray(); + +#pragma warning disable RS1026 // Enable concurrent execution + public override void Initialize(AnalysisContext context) +#pragma warning restore RS1026 // Enable concurrent execution + { +#if !DEBUG + // Only enable concurrent execution in release builds, otherwise debugging is quite annoying. + context.EnableConcurrentExecution(); +#endif + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + +#pragma warning disable RS1013 // Start action has no registered non-end actions + // We do this so that the status is set at compilation start so we can use it + // across all other analyzers. We report only on finish because multiple + // analyzers can report the same diagnostic and we want to avoid duplicates. + context.RegisterCompilationStartAction(ctx => + { + var manifests = ctx.Options.AdditionalFiles + .Where(x => + ctx.Options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.AdditionalFiles.SourceItemType", out var itemType) && + itemType == "SponsorManifest" && + Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path))) + .ToImmutableArray(); + + // Setting the status early allows other analyzers to potentially check for it. + var status = SetStatus(manifests); + // Never report any diagnostic unless we're in an editor. + if (IsEditor) + { + // NOTE: even if we don't report the diagnostic, we still set the status so other analyzers can use it. + ctx.RegisterCompilationEndAction(ctx => + { + // NOTE: for multiple projects with the same product name, we only report one diagnostic, + // so it's expected to NOT get a diagnostic back. Also, we don't want to report + // multiple diagnostics for each project in a solution that uses the same product. + if (Diagnostics.Pop(Funding.Product) is Diagnostic diagnostic) + { + ctx.ReportDiagnostic(diagnostic); + } + }); + } + }); +#pragma warning restore RS1013 // Start action has no registered non-end actions + } + + SponsorStatus SetStatus(ImmutableArray manifests) + { + if (!SponsorLink.TryRead(out var claims, manifests.Select(text => + (text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) || + claims.GetExpiration() is not DateTime exp) + { + // report unknown, either unparsed manifest or one with no expiration (which we never emit). + Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Unknown], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)), + Funding.Product, Sponsorables.Keys.Humanize(Resources.Or))); + return SponsorStatus.Unknown; + } + else if (exp < DateTime.Now) + { + // report expired or expiring soon if still within the configured days of grace period + if (exp.AddDays(Funding.Grace) < DateTime.Now) + { + // report expiring soon + Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expiring], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring)))); + return SponsorStatus.Expiring; + } + else + { + // report expired + Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expired], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired)))); + return SponsorStatus.Expired; + } + } + else + { + // report sponsor + Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Sponsor], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)), + Funding.Product)); + return SponsorStatus.Sponsor; + } + } +} diff --git a/src/SponsorLink/SponsorLink/SponsorStatus.cs b/src/SponsorLink/SponsorLink/SponsorStatus.cs new file mode 100644 index 0000000..6cdbc90 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorStatus.cs @@ -0,0 +1,25 @@ +// +namespace Devlooped.Sponsors; + +/// +/// The determined sponsoring status. +/// +public enum SponsorStatus +{ + /// + /// Sponsorship status is unknown. + /// + Unknown, + /// + /// The sponsors manifest is expired but within the grace period. + /// + Expiring, + /// + /// The sponsors manifest is expired and outside the grace period. + /// + Expired, + /// + /// The user is sponsoring. + /// + Sponsor, +} diff --git a/src/SponsorLink/SponsorLink/SponsorableLib.targets b/src/SponsorLink/SponsorLink/SponsorableLib.targets new file mode 100644 index 0000000..8311ca6 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorableLib.targets @@ -0,0 +1,60 @@ + + + + + $([System.IO.Path]::GetFullPath($(MSBuildThisFileDirectory)sponsorable.md)) + + + + + + + + + + $(WarningsNotAsErrors);LIB001;LIB002;LIB003;LIB004;LIB005 + + $(BaseIntermediateOutputPath)autosync.stamp + + $(HOME) + $(USERPROFILE) + + true + $([System.IO.Path]::GetFullPath('$(UserProfileHome)/.sponsorlink')) + + + + + + + + + + + + + + + + + + + + + + + + + + %(GitRoot.FullPath) + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/Tracing.cs b/src/SponsorLink/SponsorLink/Tracing.cs new file mode 100644 index 0000000..9201796 --- /dev/null +++ b/src/SponsorLink/SponsorLink/Tracing.cs @@ -0,0 +1,53 @@ +// +#nullable enable +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Devlooped.Sponsors; + +static class Tracing +{ + public static void Trace(string message, object? value, [CallerArgumentExpression("value")] string? expression = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) + => Trace($"{message}: {value} ({expression})", filePath, lineNumber); + + public static void Trace(object? value, [CallerArgumentExpression("value")] string? expression = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) + => Trace($"{value} ({expression})", filePath, lineNumber); + + public static void Trace([CallerMemberName] string? message = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) + { + var trace = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SPONSORLINK_TRACE")); +#if DEBUG + trace = true; +#endif + + if (!trace) + return; + + var line = new StringBuilder() + .Append($"[{DateTime.Now:O}]") + .Append($"[{Process.GetCurrentProcess().ProcessName}:{Process.GetCurrentProcess().Id}]") + .Append($" {message} ") + .AppendLine($" -> {filePath}({lineNumber})") + .ToString(); + + var dir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.Create); + var tries = 0; + // Best-effort only + while (tries < 10) + { + try + { + File.AppendAllText(Path.Combine(dir, "SponsorLink.log"), line); + Debugger.Log(0, "SponsorLink", line); + return; + } + catch (IOException) + { + tries++; + } + } + } +} diff --git a/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets new file mode 100644 index 0000000..de0563e --- /dev/null +++ b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets @@ -0,0 +1,102 @@ + + + + + $([System.DateTime]::Now.ToString("yyyy-MM-yy")) + + $(BaseIntermediateOutputPath)autosync-$(Today).stamp + + $(BaseIntermediateOutputPath)autosync.stamp + + $(HOME) + $(USERPROFILE) + + $([System.IO.Path]::GetFullPath('$(UserProfileHome)/.sponsorlink')) + + $([System.IO.Path]::Combine('$(SponsorLinkHome)', '.netconfig')) + + + + + + + + + + + + + SL_CollectDependencies + $(SLDependsOn);SL_CheckAutoSync;SL_ReadAutoSyncEnabled;SL_SyncSponsors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %(SLConfigAutoSync.Identity) + true + false + + + + + + + + $([System.IO.File]::ReadAllText($(AutoSyncStampFile)).Trim()) + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/SponsorLink/sponsorable.md b/src/SponsorLink/SponsorLink/sponsorable.md new file mode 100644 index 0000000..c023c25 --- /dev/null +++ b/src/SponsorLink/SponsorLink/sponsorable.md @@ -0,0 +1,5 @@ +# Why Sponsor + +Well, why not? It's super cheap :) + +This could even be partially auto-generated from FUNDING.yml and what-not. \ No newline at end of file diff --git a/src/SponsorLink/SponsorLinkAnalyzer.sln b/src/SponsorLink/SponsorLinkAnalyzer.sln new file mode 100644 index 0000000..be206b1 --- /dev/null +++ b/src/SponsorLink/SponsorLinkAnalyzer.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.34928.147 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzer", "Analyzer\Analyzer.csproj", "{584984D6-926B-423D-9416-519613423BAE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library", "Library\Library.csproj", "{598CD398-A172-492C-8367-827D43276029}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{EA02494C-6ED4-47A0-8D43-20F50BE8554F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SponsorLink", "SponsorLink\SponsorLink.csproj", "{B91C7E99-3D2E-4FDF-B017-9123E810197F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {584984D6-926B-423D-9416-519613423BAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {584984D6-926B-423D-9416-519613423BAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {584984D6-926B-423D-9416-519613423BAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {584984D6-926B-423D-9416-519613423BAE}.Release|Any CPU.Build.0 = Release|Any CPU + {598CD398-A172-492C-8367-827D43276029}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {598CD398-A172-492C-8367-827D43276029}.Debug|Any CPU.Build.0 = Debug|Any CPU + {598CD398-A172-492C-8367-827D43276029}.Release|Any CPU.ActiveCfg = Release|Any CPU + {598CD398-A172-492C-8367-827D43276029}.Release|Any CPU.Build.0 = Release|Any CPU + {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Release|Any CPU.Build.0 = Release|Any CPU + {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1DDA0EFF-BEF6-49BB-8AA8-D71FE1CD3E6F} + EndGlobalSection +EndGlobal diff --git a/src/SponsorLink/Tests/.netconfig b/src/SponsorLink/Tests/.netconfig new file mode 100644 index 0000000..3b3bd0d --- /dev/null +++ b/src/SponsorLink/Tests/.netconfig @@ -0,0 +1,15 @@ +[file "SponsorableManifest.cs"] + url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/SponsorableManifest.cs + sha = 976ecefc44d87217e04933d9cd7f6b950468410b + etag = e0c95e7fc6c0499dbc8c5cd28aa9a6a5a49c9d0ad41fe028a5a085aca7e00eaf + weak +[file "JsonOptions.cs"] + url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/JsonOptions.cs + sha = 79dc56ce45fc36df49e1c4f8875e93c297edc383 + etag = 6e9a1b12757a97491441b9534ced4e5dac6d9d6334008fa0cd20575650bbd935 + weak +[file "Extensions.cs"] + url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/Extensions.cs + sha = d204b667eace818934c49e09b5b08ea82aef87fa + etag = f68e11894103f8748ce290c29927bf1e4f749e743ae33d5350e72ed22c15d245 + weak diff --git a/src/SponsorLink/Tests/Attributes.cs b/src/SponsorLink/Tests/Attributes.cs new file mode 100644 index 0000000..aa5f48d --- /dev/null +++ b/src/SponsorLink/Tests/Attributes.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Configuration; +using Xunit; + +public class SecretsFactAttribute : FactAttribute +{ + public SecretsFactAttribute(params string[] secrets) + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets() + .Build(); + + var missing = new HashSet(); + + foreach (var secret in secrets) + { + if (string.IsNullOrEmpty(configuration[secret])) + missing.Add(secret); + } + + if (missing.Count > 0) + Skip = "Missing user secrets: " + string.Join(',', missing); + } +} + +public class LocalFactAttribute : SecretsFactAttribute +{ + public LocalFactAttribute(params string[] secrets) : base(secrets) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "Non-CI test"; + } +} + +public class CIFactAttribute : FactAttribute +{ + public CIFactAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "CI-only test"; + } +} + +public class LocalTheoryAttribute : TheoryAttribute +{ + public LocalTheoryAttribute() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "Non-CI test"; + } +} + +public class CITheoryAttribute : TheoryAttribute +{ + public CITheoryAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) + Skip = "CI-only test"; + } +} \ No newline at end of file diff --git a/src/SponsorLink/Tests/Extensions.cs b/src/SponsorLink/Tests/Extensions.cs new file mode 100644 index 0000000..75a78b4 --- /dev/null +++ b/src/SponsorLink/Tests/Extensions.cs @@ -0,0 +1,43 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace Devlooped.Sponsors; + +static class Extensions +{ + public static HashCode Add(this HashCode hash, params object[] items) + { + foreach (var item in items) + hash.Add(item); + + return hash; + } + + + public static HashCode AddRange(this HashCode hash, IEnumerable items) + { + foreach (var item in items) + hash.Add(item); + + return hash; + } + + public static Array Cast(this Array array, Type elementType) + { + //Convert the object list to the destination array type. + var result = Array.CreateInstance(elementType, array.Length); + Array.Copy(array, result, array.Length); + return result; + } + + public static void Assert(this ILogger logger, [DoesNotReturnIf(false)] bool condition, [CallerArgumentExpression(nameof(condition))] string? message = default, params object?[] args) + { + if (!condition) + { + //Debug.Assert(condition, message); + logger.LogError(message, args); + throw new InvalidOperationException(message); + } + } +} diff --git a/src/SponsorLink/Tests/JsonOptions.cs b/src/SponsorLink/Tests/JsonOptions.cs new file mode 100644 index 0000000..c816eba --- /dev/null +++ b/src/SponsorLink/Tests/JsonOptions.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.Sponsors; + +static partial class JsonOptions +{ + public static JsonSerializerOptions Default { get; } = +#if NET6_0_OR_GREATER + new(JsonSerializerDefaults.Web) +#else + new() +#endif + { + AllowTrailingCommas = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip, +#if NET6_0_OR_GREATER + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, +#endif + WriteIndented = true, + Converters = + { + new JsonStringEnumConverter(allowIntegerValues: false), +#if NET6_0_OR_GREATER + new DateOnlyJsonConverter() +#endif + } + }; + + public static JsonSerializerOptions JsonWebKey { get; } = new(JsonSerializerOptions.Default) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + info => + { + if (info.Type != typeof(JsonWebKey)) + return; + + foreach (var prop in info.Properties) + { + // Don't serialize empty lists, makes for more concise JWKs + prop.ShouldSerialize = (obj, value) => + value is not null && + (value is not IList list || list.Count > 0); + } + } + } + } + }; + + +#if NET6_0_OR_GREATER + public class DateOnlyJsonConverter : JsonConverter + { + public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => DateOnly.Parse(reader.GetString()?[..10] ?? "", CultureInfo.InvariantCulture); + + public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture)); + } +#endif +} diff --git a/src/SponsorLink/Tests/Resources.Designer.cs b/src/SponsorLink/Tests/Resources.Designer.cs new file mode 100644 index 0000000..7824a60 --- /dev/null +++ b/src/SponsorLink/Tests/Resources.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Tests { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Tests.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/src/SponsorLink/Tests/Resources.resx b/src/SponsorLink/Tests/Resources.resx new file mode 100644 index 0000000..4fdb1b6 --- /dev/null +++ b/src/SponsorLink/Tests/Resources.resx @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/SponsorLink/Tests/Sample.cs b/src/SponsorLink/Tests/Sample.cs new file mode 100644 index 0000000..897c91c --- /dev/null +++ b/src/SponsorLink/Tests/Sample.cs @@ -0,0 +1,59 @@ +extern alias Analyzer; +using System; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using Analyzer::Devlooped.Sponsors; +using Xunit; +using Xunit.Abstractions; + +namespace Tests; + +public class Sample(ITestOutputHelper output) +{ + [Theory] + [InlineData("es-AR", SponsorStatus.Unknown)] + [InlineData("es-AR", SponsorStatus.Expiring)] + [InlineData("es-AR", SponsorStatus.Expired)] + [InlineData("es-AR", SponsorStatus.Sponsor)] + [InlineData("en", SponsorStatus.Unknown)] + [InlineData("en", SponsorStatus.Expiring)] + [InlineData("en", SponsorStatus.Expired)] + [InlineData("en", SponsorStatus.Sponsor)] + [InlineData("", SponsorStatus.Unknown)] + [InlineData("", SponsorStatus.Expiring)] + [InlineData("", SponsorStatus.Expired)] + [InlineData("", SponsorStatus.Sponsor)] + public void Test(string culture, SponsorStatus kind) + { + Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = + culture == "" ? CultureInfo.InvariantCulture : CultureInfo.GetCultureInfo(culture); + + var diag = new DiagnosticsManager().GetDescriptor(["foo"], "bar", "FB", kind); + + output.WriteLine(diag.Title.ToString()); + output.WriteLine(diag.MessageFormat.ToString()); + output.WriteLine(diag.Description.ToString()); + } + + [Fact] + public void RenderSponsorables() + { + Assert.NotEmpty(SponsorLink.Sponsorables); + + foreach (var pair in SponsorLink.Sponsorables) + { + output.WriteLine($"{pair.Key} = {pair.Value}"); + // Read the JWK + var jsonWebKey = Microsoft.IdentityModel.Tokens.JsonWebKey.Create(pair.Value); + + Assert.NotNull(jsonWebKey); + + using var key = RSA.Create(new RSAParameters + { + Modulus = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(jsonWebKey.N), + Exponent = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(jsonWebKey.E), + }); + } + } +} \ No newline at end of file diff --git a/src/SponsorLink/Tests/SponsorLinkTests.cs b/src/SponsorLink/Tests/SponsorLinkTests.cs new file mode 100644 index 0000000..7625e2c --- /dev/null +++ b/src/SponsorLink/Tests/SponsorLinkTests.cs @@ -0,0 +1,126 @@ +extern alias Analyzer; +using System.Security.Cryptography; +using System.Text.Json; +using Analyzer::Devlooped.Sponsors; +using Devlooped.Sponsors; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace Devlooped.Tests; + +public class SponsorLinkTests +{ + // We need to convert to jwk string since the analyzer project has merged the JWT assembly and types. + public static string ToJwk(SecurityKey key) + => JsonSerializer.Serialize( + JsonWebKeyConverter.ConvertFromSecurityKey(key), + JsonOptions.JsonWebKey); + + [Fact] + public void ValidateSponsorable() + { + var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234"); + var jwt = manifest.ToJwt(); + var jwk = ToJwk(manifest.SecurityKey); + + // NOTE: sponsorable manifest doesn't have expiration date. + var status = SponsorLink.Validate(jwt, jwk, out var token, out var principal, false); + + Assert.Equal(ManifestStatus.Valid, status); + } + + [Fact] + public void ValidateWrongKey() + { + var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234"); + var jwt = manifest.ToJwt(); + var jwk = ToJwk(new RsaSecurityKey(RSA.Create())); + + var status = SponsorLink.Validate(jwt, jwk, out var token, out var principal, false); + + Assert.Equal(ManifestStatus.Invalid, status); + + // We should still be a able to read the data, knowing it may have been tampered with. + Assert.NotNull(principal); + Assert.NotNull(token); + } + + [Fact] + public void ValidateExpiredSponsor() + { + var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234"); + var jwk = ToJwk(manifest.SecurityKey); + var sponsor = manifest.Sign([], expiration: TimeSpan.Zero); + + // Will be expired after this. + Thread.Sleep(1000); + + var status = SponsorLink.Validate(sponsor, jwk, out var token, out var principal, true); + + Assert.Equal(ManifestStatus.Expired, status); + + // We should still be a able to read the data, even if expired (but not tampered with). + Assert.NotNull(principal); + Assert.NotNull(token); + } + + [Fact] + public void ValidateUnknownFormat() + { + var manifest = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/bar")], "ASDF1234"); + var jwk = ToJwk(manifest.SecurityKey); + + var status = SponsorLink.Validate("asdfasdf", jwk, out var token, out var principal, false); + + Assert.Equal(ManifestStatus.Unknown, status); + + // Nothing could be read at all. + Assert.Null(principal); + Assert.Null(token); + } + + [Fact] + public void TryRead() + { + var fooSponsorable = SponsorableManifest.Create(new Uri("https://foo.com"), [new Uri("https://github.com/sponsors/foo")], "ASDF1234"); + var barSponsorable = SponsorableManifest.Create(new Uri("https://bar.com"), [new Uri("https://github.com/sponsors/bar")], "GHJK5678"); + + // Org sponsor and member of team + var fooSponsor = fooSponsorable.Sign([new("sub", "kzu"), new("email", "me@foo.com"), new("roles", "org"), new("roles", "team")], expiration: TimeSpan.FromDays(30)); + // Org + personal sponsor + var barSponsor = barSponsorable.Sign([new("sub", "kzu"), new("email", "me@bar.com"), new("roles", "org"), new("roles", "user")], expiration: TimeSpan.FromDays(30)); + + Assert.True(SponsorLink.TryRead(out var principal, [(fooSponsor, ToJwk(fooSponsorable.SecurityKey)), (barSponsor, ToJwk(barSponsorable.SecurityKey))])); + + // Can check role across both JWTs + Assert.True(principal.IsInRole("org")); + Assert.True(principal.IsInRole("team")); + Assert.True(principal.IsInRole("user")); + + Assert.True(principal.HasClaim("sub", "kzu")); + Assert.True(principal.HasClaim("email", "me@foo.com")); + Assert.True(principal.HasClaim("email", "me@bar.com")); + } + + [LocalFact] + public void ValidateCachedManifest() + { + var path = Environment.ExpandEnvironmentVariables("%userprofile%\\.sponsorlink\\github\\devlooped.jwt"); + if (!File.Exists(path)) + return; + + var jwt = File.ReadAllText(path); + + var status = SponsorLink.Validate(jwt, + """ + { + "e": "AQAB", + "kty": "RSA", + "n": "5inhv8QymaDBOihNi1eY-6-hcIB5qSONFZxbxxXAyOtxAdjFCPM-94gIZqM9CDrX3pyg1lTJfml_a_FZSU9dB1ii5mSX_mNHBFXn1_l_gi1ErdbkIF5YbW6oxWFxf3G5mwVXwnPfxHTyQdmWQ3YJR-A3EB4kaFwLqA6Ha5lb2ObGpMTQJNakD4oTAGDhqHMGhu6PupGq5ie4qZcQ7N8ANw8xH7nicTkbqEhQABHWOTmLBWq5f5F6RYGF8P7cl0IWl_w4YcIZkGm2vX2fi26F9F60cU1v13GZEVDTXpJ9kzvYeM9sYk6fWaoyY2jhE51qbv0B0u6hScZiLREtm3n7ClJbIGXhkUppFS2JlNaX3rgQ6t-4LK8gUTyLt3zDs2H8OZyCwlCpfmGmdsUMkm1xX6t2r-95U3zywynxoWZfjBCJf41leM9OMKYwNWZ6LQMyo83HWw1PBIrX4ZLClFwqBcSYsXDyT8_ZLd1cdYmPfmtllIXxZhLClwT5qbCWv73V" + } + """ + , out var token, out var principal, false); + + Assert.Equal(ManifestStatus.Valid, status); + } +} diff --git a/src/SponsorLink/Tests/SponsorableManifest.cs b/src/SponsorLink/Tests/SponsorableManifest.cs new file mode 100644 index 0000000..5ae6e3f --- /dev/null +++ b/src/SponsorLink/Tests/SponsorableManifest.cs @@ -0,0 +1,309 @@ +using System.Diagnostics.CodeAnalysis; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.Sponsors; + +/// +/// The serializable manifest of a sponsorable user, as persisted +/// in the .github/sponsorlink.jwt file. +/// +public class SponsorableManifest +{ + /// + /// Overall manifest status. + /// + public enum Status + { + /// + /// SponsorLink manifest is invalid. + /// + Invalid, + /// + /// The manifest has an audience that doesn't match the sponsorable account. + /// + AccountMismatch, + /// + /// SponsorLink manifest not found for the given account, so it's not supported. + /// + NotFound, + /// + /// Manifest was successfully fetched and validated. + /// + OK, + } + + /// + /// Creates a new manifest with a new RSA key pair. + /// + public static SponsorableManifest Create(Uri issuer, Uri[] audience, string clientId) + { + var rsa = RSA.Create(3072); + var pub = Convert.ToBase64String(rsa.ExportRSAPublicKey()); + + return new SponsorableManifest(issuer, audience, clientId, new RsaSecurityKey(rsa), pub); + } + + public static async Task<(Status, SponsorableManifest?)> FetchAsync(string sponsorable, string? branch, HttpClient? http = default) + { + // Try to detect sponsorlink manifest in the sponsorable .github repo + var url = $"https://github.com/{sponsorable}/.github/raw/{branch ?? "main"}/sponsorlink.jwt"; + + // Manifest should be public, so no need for any special HTTP client. + using (http ??= new HttpClient()) + { + var response = await http.GetAsync(url); + if (!response.IsSuccessStatusCode) + return (Status.NotFound, default); + + var jwt = await response.Content.ReadAsStringAsync(); + if (!TryRead(jwt, out var manifest, out var missingClaim)) + return (Status.Invalid, default); + + // Manifest audience should match the sponsorable account to avoid weird issues? + if (sponsorable != manifest.Sponsorable) + return (Status.AccountMismatch, default); + + return (Status.OK, manifest); + } + } + + /// + /// Parses a JWT into a . + /// + /// The JWT containing the sponsorable information. + /// The parsed manifest, if not required claims are missing. + /// The missing required claim, if any. + /// A validated manifest. + public static bool TryRead(string jwt, [NotNullWhen(true)] out SponsorableManifest? manifest, out string? missingClaim) + { + var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; + missingClaim = null; + manifest = default; + + if (!handler.CanReadToken(jwt)) + return false; + + var token = handler.ReadJwtToken(jwt); + var issuer = token.Issuer; + + if (token.Audiences.FirstOrDefault(x => x.StartsWith("https://github.com/")) is null) + { + missingClaim = "aud"; + return false; + } + + if (token.Claims.FirstOrDefault(c => c.Type == "client_id")?.Value is not string clientId) + { + missingClaim = "client_id"; + return false; + } + + if (token.Claims.FirstOrDefault(c => c.Type == "pub")?.Value is not string pub) + { + missingClaim = "pub"; + return false; + } + + if (token.Claims.FirstOrDefault(c => c.Type == "sub_jwk")?.Value is not string jwk) + { + missingClaim = "sub_jwk"; + return false; + } + + var key = new JsonWebKeySet { Keys = { JsonWebKey.Create(jwk) } }.GetSigningKeys().First(); + manifest = new SponsorableManifest(new Uri(issuer), token.Audiences.Select(x => new Uri(x)).ToArray(), clientId, key, pub); + + return true; + } + + public SponsorableManifest(Uri issuer, Uri[] audience, string clientId, SecurityKey publicKey, string publicRsaKey) + { + Issuer = issuer.AbsoluteUri; + Audience = audience.Select(a => a.AbsoluteUri.TrimEnd('/')).ToArray(); + ClientId = clientId; + SecurityKey = publicKey; + PublicKey = publicRsaKey; + Sponsorable = audience.Where(x => x.Host == "github.com").Select(x => x.Segments.LastOrDefault()?.TrimEnd('/')).FirstOrDefault() ?? + throw new ArgumentException("At least one of the intended audience must be a GitHub sponsors URL."); + } + + /// + /// Converts (and optionally signs) the manifest into a JWT. Never exports the private key. + /// + /// Optional credentials when signing the resulting manifest. Defaults to the if it has a private key. + /// The JWT manifest. + public string ToJwt(SigningCredentials? signing = default) + { + var jwk = JsonWebKeyConverter.ConvertFromSecurityKey(SecurityKey); + + // Automatically sign if the manifest was created with a private key + if (SecurityKey is RsaSecurityKey rsa && rsa.PrivateKeyStatus == PrivateKeyStatus.Exists) + { + signing ??= new SigningCredentials(rsa, SecurityAlgorithms.RsaSha256); + + // Ensure we never serialize the private key + jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(new RsaSecurityKey(rsa.Rsa.ExportParameters(false))); + } + + var token = new JwtSecurityToken( + claims: + new[] { new Claim(JwtRegisteredClaimNames.Iss, Issuer) } + .Concat(Audience.Select(x => new Claim(JwtRegisteredClaimNames.Aud, x))) + .Concat( + [ + // See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6 + new(JwtRegisteredClaimNames.Iat, Math.Truncate((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds).ToString()), + new("client_id", ClientId), + // non-standard claim containing the base64-encoded public key + new("pub", PublicKey), + // standard claim, serialized as a JSON string, not an encoded JSON object + new("sub_jwk", JsonSerializer.Serialize(jwk, JsonOptions.JsonWebKey), JsonClaimValueTypes.Json), + ]), + signingCredentials: signing); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + /// Sign the JWT claims with the provided RSA key. + /// + public string Sign(IEnumerable claims, RSA rsa, TimeSpan? expiration = default) + => Sign(claims, new RsaSecurityKey(rsa), expiration); + + public string Sign(IEnumerable claims, RsaSecurityKey? key = default, TimeSpan? expiration = default) + { + var rsa = key ?? SecurityKey as RsaSecurityKey; + if (rsa?.PrivateKeyStatus != PrivateKeyStatus.Exists) + throw new NotSupportedException("No private key found to sign the manifest."); + + var signing = new SigningCredentials(rsa, SecurityAlgorithms.RsaSha256); + + var expirationDate = expiration != null ? + DateTime.UtcNow.Add(expiration.Value) : + // Expire the first day of the next month + new DateTime( + DateTime.UtcNow.AddMonths(1).Year, + DateTime.UtcNow.AddMonths(1).Month, 1, + // Use current time so they don't expire all at the same time + DateTime.UtcNow.Hour, + DateTime.UtcNow.Minute, + DateTime.UtcNow.Second, + DateTime.UtcNow.Millisecond, + DateTimeKind.Utc); + + var tokenClaims = claims.Where(x => x.Type != JwtRegisteredClaimNames.Iat && x.Type != JwtRegisteredClaimNames.Exp).ToList(); + + // See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6 + tokenClaims.Add(new(JwtRegisteredClaimNames.Iat, Math.Truncate((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds).ToString())); + + if (tokenClaims.Find(c => c.Type == JwtRegisteredClaimNames.Iss) is { } issuer) + { + if (issuer.Value != Issuer) + throw new ArgumentException($"The received claims contain an incompatible 'iss' claim. If present, the claim must contain the value '{Issuer}' but was '{issuer.Value}'."); + } + else + { + tokenClaims.Insert(0, new(JwtRegisteredClaimNames.Iss, Issuer)); + } + + if (tokenClaims.Find(c => c.Type == "client_id") is { } clientId) + { + if (clientId.Value != ClientId) + throw new ArgumentException($"The received claims contain an incompatible 'client_id' claim. If present, the claim must contain the value '{ClientId}' but was '{clientId.Value}'."); + } + else + { + tokenClaims.Add(new("client_id", ClientId)); + } + + // Avoid duplicating audience claims + foreach (var audience in Audience) + { + // Always compare ignoring trailing / + if (tokenClaims.Find(c => c.Type == JwtRegisteredClaimNames.Aud && c.Value.TrimEnd('/') == audience.TrimEnd('/')) == null) + tokenClaims.Insert(1, new(JwtRegisteredClaimNames.Aud, audience)); + } + + // The other claims (client_id, pub, sub_jwk) claims are mostly for the SL manifest itself, + // not for the user, so for now we don't add them. + + // Don't allow mismatches of public manifest key and the one used to sign, to avoid + // weird run-time errors verifiying manifests that were signed with a different key. + var pubKey = Convert.ToBase64String(rsa.Rsa.ExportRSAPublicKey()); + if (pubKey != PublicKey) + throw new ArgumentException($"Cannot sign with a private key that does not match the manifest public key."); + + var jwt = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: tokenClaims, + expires: expirationDate, + signingCredentials: signing + )); + + return jwt; + } + + public ClaimsPrincipal Validate(string jwt, out SecurityToken? token) => new JwtSecurityTokenHandler().ValidateToken(jwt, new TokenValidationParameters + { + RequireExpirationTime = true, + // NOTE: setting this to false allows checking sponsorships even when the manifest is expired. + // This might be useful if package authors want to extend the manifest lifetime beyond the default + // 30 days and issue a warning on expiration, rather than an error and a forced sync. + // If this is not set (or true), a SecurityTokenExpiredException exception will be thrown. + ValidateLifetime = false, + RequireAudience = true, + // At least one of the audiences must match the manifest audiences + AudienceValidator = (audiences, _, _) => Audience.Intersect(audiences.Select(x => x.TrimEnd('/'))).Any(), + ValidIssuer = Issuer, + IssuerSigningKey = SecurityKey, + }, out token); + + /// + /// Gets the GitHub sponsorable account. + /// + public string Sponsorable { get; } + + /// + /// The web endpoint that issues signed JWT to authenticated users. + /// + /// + /// See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1 + /// + public string Issuer { get; } + + /// + /// The audience for the JWT, which includes the sponsorable account and potentially other sponsoring platforms. + /// + /// + /// See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3 + /// + public string[] Audience { get; } + + /// + /// The OAuth client ID (i.e. GitHub OAuth App ID) that is used to + /// authenticate the user. + /// + /// + /// See https://www.rfc-editor.org/rfc/rfc8693.html#name-client_id-client-identifier + /// + public string ClientId { get; internal set; } + + /// + /// Public key that can be used to verify JWT signatures. + /// + public string PublicKey { get; } + + /// + /// Public key in a format that can be used to verify JWT signatures. + /// + public SecurityKey SecurityKey { get; } + + /// + public override int GetHashCode() => new HashCode().Add(Issuer, ClientId, PublicKey).AddRange(Audience).ToHashCode(); + + /// + public override bool Equals(object? obj) => obj is SponsorableManifest other && GetHashCode() == other.GetHashCode(); +} diff --git a/src/SponsorLink/Tests/Tests.csproj b/src/SponsorLink/Tests/Tests.csproj new file mode 100644 index 0000000..0585911 --- /dev/null +++ b/src/SponsorLink/Tests/Tests.csproj @@ -0,0 +1,57 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + + + + + %(GitRoot.FullPath) + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/jwk.ps1 b/src/SponsorLink/jwk.ps1 new file mode 100644 index 0000000..c66f56f --- /dev/null +++ b/src/SponsorLink/jwk.ps1 @@ -0,0 +1 @@ +curl https://raw.githubusercontent.com/devlooped/.github/main/sponsorlink.jwt --silent | jq -R 'split(".") | .[1] | @base64d | fromjson' | jq '.sub_jwk' \ No newline at end of file diff --git a/src/SponsorLink/readme.md b/src/SponsorLink/readme.md new file mode 100644 index 0000000..cb651a1 --- /dev/null +++ b/src/SponsorLink/readme.md @@ -0,0 +1,34 @@ +# SponsorLink .NET Analyzer + +This is one opinionated implementation of [SponsorLink](https://devlooped.com/SponsorLink) +for .NET projects leveraging Roslyn analyzers. + +It is intended for use by [devlooped](https://github.com/devlooped) projects, but can be +used as a template for other sponsorables as well. Supporting arbitrary sponsoring scenarios +is out of scope though, since we just use GitHub sponsors for now. + +## Usage + +A project initializing from this template repo via [dotnet-file](https://github.com/devlooped/dotnet-file) +will have all the sources cloned under `src\SponsorLink`. + +Including the analyzer and targets in a project involves two steps. + +1. Create an analyzer project and add the following property: + +```xml + + ... + $(MSBuildThisFileDirectory)..\SponsorLink\SponsorLink.targets + +``` + +2. Add a `buildTransitive\[PackageId].targets` file with the following import: + +```xml + + + +``` + +As long as NuGetizer is used, the right packaging will be done automatically. \ No newline at end of file From 7f2f4a9b1fd7dbd3213a50e103916a070965e55d Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 05:43:40 -0300 Subject: [PATCH 04/13] Remove incoming SL.sln from dotnet-sync --- .netconfig | 3 +++ SponsorLink.sln | 43 ------------------------------------------- 2 files changed, 3 insertions(+), 43 deletions(-) delete mode 100644 SponsorLink.sln diff --git a/.netconfig b/.netconfig index ebe97f5..c105c99 100644 --- a/.netconfig +++ b/.netconfig @@ -50,6 +50,9 @@ sha = 0683ee777d7d878d4bf013d7deea352685135a05 etag = 2c6335b37e4ae05eea7c01f5d0c9d82b49c488f868a8b5ba7bff7c6ff01f3994 weak +[file "SponsorLink.sln"] + url = https://github.com/devlooped/oss/blob/main/SponsorLink.sln + skip [file "src/Directory.Build.props"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.props sha = 14deaea5cecc64df51781d29891a2f67caf8be16 diff --git a/SponsorLink.sln b/SponsorLink.sln deleted file mode 100644 index d4eab56..0000000 --- a/SponsorLink.sln +++ /dev/null @@ -1,43 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.11.34909.67 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SponsorLink", "src\SponsorLink\SponsorLink\SponsorLink.csproj", "{1E1D01A2-D202-4FAB-B21B-AF21B1C37163}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzer", "src\SponsorLink\Analyzer\Analyzer.csproj", "{87B3A42C-FFA7-49CF-8F3A-656A6D213246}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library", "src\SponsorLink\Library\Library.csproj", "{23371E8B-2401-42A1-9A01-4720D8388105}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "src\SponsorLink\Tests\Tests.csproj", "{A86B253A-340E-4B82-8207-336BF65F36C8}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1E1D01A2-D202-4FAB-B21B-AF21B1C37163}.Release|Any CPU.Build.0 = Release|Any CPU - {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Debug|Any CPU.Build.0 = Debug|Any CPU - {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Release|Any CPU.ActiveCfg = Release|Any CPU - {87B3A42C-FFA7-49CF-8F3A-656A6D213246}.Release|Any CPU.Build.0 = Release|Any CPU - {23371E8B-2401-42A1-9A01-4720D8388105}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {23371E8B-2401-42A1-9A01-4720D8388105}.Debug|Any CPU.Build.0 = Debug|Any CPU - {23371E8B-2401-42A1-9A01-4720D8388105}.Release|Any CPU.ActiveCfg = Release|Any CPU - {23371E8B-2401-42A1-9A01-4720D8388105}.Release|Any CPU.Build.0 = Release|Any CPU - {A86B253A-340E-4B82-8207-336BF65F36C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A86B253A-340E-4B82-8207-336BF65F36C8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A86B253A-340E-4B82-8207-336BF65F36C8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A86B253A-340E-4B82-8207-336BF65F36C8}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {36BC3C24-D4E3-4EB0-A910-4BE4BD8FE01F} - EndGlobalSection -EndGlobal From a79ec58d17622cba89c7f993f12349e0ac508d2d Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 05:43:56 -0300 Subject: [PATCH 05/13] Remove unnecessary RIDs --- src/Config.Tool/Config.Tool.csproj | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Config.Tool/Config.Tool.csproj b/src/Config.Tool/Config.Tool.csproj index de63a7d..2a2ef77 100644 --- a/src/Config.Tool/Config.Tool.csproj +++ b/src/Config.Tool/Config.Tool.csproj @@ -35,7 +35,6 @@ Other Exe net6.0;net8.0 - win-x64;linux-x64 dotnet-config DotNetConfig @@ -56,9 +55,6 @@ Other - - - From 92a6d187ce2ad15aa3519ce825abb47581267b8c Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 05:55:08 -0300 Subject: [PATCH 06/13] Use nugetizer for packing --- readme.md | 4 ++++ src/CommandLine/CommandLine.csproj | 4 ++++ src/Config.Tool/Config.Tool.csproj | 4 ++++ src/Config.Tool/readme.md | 3 +++ src/Config/Config.csproj | 4 ++++ src/Configuration/Configuration.csproj | 4 ++++ 6 files changed, 23 insertions(+) create mode 100644 src/Config.Tool/readme.md diff --git a/readme.md b/readme.md index 9efaee4..f7b4b24 100644 --- a/readme.md +++ b/readme.md @@ -478,6 +478,7 @@ For numbers, the argument/option can be either `long` or `int`. Keep in mind tha [![Version](https://img.shields.io/nuget/v/dotnet-config.svg?color=royalblue)](https://www.nuget.org/packages/dotnet-config) [![Downloads](https://img.shields.io/nuget/dt/dotnet-config.svg?color=darkmagenta)](https://www.nuget.org/packages/dotnet-config) + The command line tool allows you to inspect and modify configuration files used by your dotnet tools. Installation is the same as for any other dotnet tool: @@ -533,3 +534,6 @@ Command line parsing is done with [Mono.Options](https://www.nuget.org/packages/ all the following variants for arguments are supported: `-flag`, `--flag`, `/flag`, `-flag=value`, `--flag=value`, `/flag=value`, `-flag:value`, `--flag:value`, `/flag:value`, `-flag value`, `--flag value`, `/flag value`. + + + \ No newline at end of file diff --git a/src/CommandLine/CommandLine.csproj b/src/CommandLine/CommandLine.csproj index 230d4f4..be505d5 100644 --- a/src/CommandLine/CommandLine.csproj +++ b/src/CommandLine/CommandLine.csproj @@ -35,6 +35,10 @@ The following heuristics are applied when providing default values: + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Config.Tool/Config.Tool.csproj b/src/Config.Tool/Config.Tool.csproj index 2a2ef77..29bb93d 100644 --- a/src/Config.Tool/Config.Tool.csproj +++ b/src/Config.Tool/Config.Tool.csproj @@ -52,6 +52,10 @@ Other + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Config.Tool/readme.md b/src/Config.Tool/readme.md new file mode 100644 index 0000000..f7599d8 --- /dev/null +++ b/src/Config.Tool/readme.md @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/Config/Config.csproj b/src/Config/Config.csproj index 049f8ed..9c5c84b 100644 --- a/src/Config/Config.csproj +++ b/src/Config/Config.csproj @@ -33,6 +33,10 @@ Usage: + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Configuration/Configuration.csproj b/src/Configuration/Configuration.csproj index ace4925..2166517 100644 --- a/src/Configuration/Configuration.csproj +++ b/src/Configuration/Configuration.csproj @@ -21,6 +21,10 @@ Note: section is required and subsection is optional, just like in dotnet-config + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From e47facef91c72143dbf5219d9952ed7055230665 Mon Sep 17 00:00:00 2001 From: kzu Date: Tue, 25 Jun 2024 09:05:59 +0000 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=96=89=20Update=20changelog=20with?= =?UTF-8?q?=20v1.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/changelog.md b/changelog.md index 08f7a40..cb05fc6 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,22 @@ +# Changelog +## [v1.1.0](https://github.com/dotnetconfig/dotnet-config/tree/v1.1.0) (2024-06-25) + +[Full Changelog](https://github.com/dotnetconfig/dotnet-config/compare/v1.0.6...v1.1.0) + +:sparkles: Implemented enhancements: + +- Use nugetizer for packing [\#150](https://github.com/dotnetconfig/dotnet-config/pull/150) (@kzu) +- Bump to .net6/8 for the CLI [\#148](https://github.com/dotnetconfig/dotnet-config/pull/148) (@kzu) + +:hammer: Other: + +- .Net 5 reached EOL, please upgrade this tool to use .Net 6 or 8 [\#146](https://github.com/dotnetconfig/dotnet-config/issues/146) +- Can't save empty/blank values [\#145](https://github.com/dotnetconfig/dotnet-config/issues/145) + +:twisted_rightwards_arrows: Merged: + +- Add how to work with array of complex objects [\#98](https://github.com/dotnetconfig/dotnet-config/pull/98) (@PadreSVK) ## [v1.0.6](https://github.com/dotnetconfig/dotnet-config/tree/v1.0.6) (2021-07-30) From 90333f192b75bb5eea5377aaedb6f8a17c907f2d Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 14:52:21 -0300 Subject: [PATCH 08/13] Make options and note high compat level with git config --- readme.md | 19 +++++++++++++------ src/Config.Tool/Program.cs | 23 ++++++++++++++++------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/readme.md b/readme.md index f7b4b24..ff540e5 100644 --- a/readme.md +++ b/readme.md @@ -486,6 +486,11 @@ Installation is the same as for any other dotnet tool: > dotnet tool install -g dotnet-config ``` +The available options and actions are (for the most part) compatible with the behavior of `git config`. + +> When reading and writing from a single file, you can for the most part just use `git config` +> along with the compatibility option `-f|--file` specifying the file to read/write from. + Reading and writing variables don't require any special options. The following lines first write a variable value and then retrieve its value: @@ -504,29 +509,31 @@ All current options from running `dotnet config -?` are: Usage: dotnet config [options] Location (uses all locations by default) - --local use .netconfig.user file --global use global config file --system use system config file + --local use .netconfig.user file + -f, --file use given config file (git config compat) --path[=VALUE] use given config file or directory Action --get get value: name [value-regex] --get-all get all values: key [value-regex] --get-regexp get values for regexp: name-regex [value-regex] - --set set value: name value [value-regex] - --set-all set all matches: name value [value-regex] --add add a new variable: name value --unset remove a variable: name [value-regex] --unset-all remove all matches: name [value-regex] - --remove-section remove a section: name + --set set value: name value [value-regex] + --set-all set all matches: name value [value-regex] --rename-section rename section: old-name new-name + --remove-section remove a section: name -l, --list list all -e, --edit edit the config file in an editor Other - --default[=VALUE] with --get, use default value when missing entry --name-only show variable names only - --type[=VALUE] value is given this type, can be 'boolean', 'datetime' or 'number' + --default[=VALUE] with --get, use default value when missing entry + --type[=VALUE] value is given this type, can be 'boolean', ' + datetime' or 'number' -?, -h, --help Display this help ``` diff --git a/src/Config.Tool/Program.cs b/src/Config.Tool/Program.cs index 2f2eef9..0a65dd0 100644 --- a/src/Config.Tool/Program.cs +++ b/src/Config.Tool/Program.cs @@ -30,7 +30,8 @@ static int Run(string[] args) var useSystem = false; var useGlobal = false; var useLocal = false; - var path = Directory.GetCurrentDirectory(); + string? path = default; + string? file = default; var nameOnly = false; string? defaultValue = default; string? type = default; @@ -41,18 +42,17 @@ static int Run(string[] args) { Environment.NewLine }, { Environment.NewLine }, { "Location (uses all locations by default)" }, - { "local", "use .netconfig.user file", _ => useLocal = true }, { "global", "use global config file", _ => useGlobal = true }, { "system", "use system config file", _ => useSystem = true }, - { "path:", "use given config file or directory", f => path = f }, + { "local", "use .netconfig.user file", _ => useLocal = true }, + { "f|file", "use given config file (git config compat)", f => file = f }, + { "path:", "use given config file or directory", p => path = p }, { Environment.NewLine }, { "Action" }, { "get", "get value: name [value-regex]", _ => action = ConfigAction.Get }, { "get-all", "get all values: key [value-regex]", _ => action = ConfigAction.GetAll }, { "get-regexp", "get values for regexp: name-regex [value-regex]", _ => action = ConfigAction.GetRegexp }, - { "set", "set value: name value [value-regex]", _ => action = ConfigAction.Set }, - { "set-all", "set all matches: name value [value-regex]", _ => action = ConfigAction.SetAll }, { "replace-all", "replace all matches: name value [value-regex]", _ => action = ConfigAction.SetAll, true }, //{ "get-urlmatch", "get value specific for the URL: section[.var] URL", _ => action = ConfigAction.Get }, @@ -60,16 +60,19 @@ static int Run(string[] args) { "unset", "remove a variable: name [value-regex]", _ => action = ConfigAction.Unset }, { "unset-all", "remove all matches: name [value-regex]", _ => action = ConfigAction.UnsetAll }, - { "remove-section", "remove a section: name", _ => action = ConfigAction.RemoveSection }, + { "set", "set value: name value [value-regex]", _ => action = ConfigAction.Set }, + { "set-all", "set all matches: name value [value-regex]", _ => action = ConfigAction.SetAll }, + { "rename-section", "rename section: old-name new-name", _ => action = ConfigAction.RenameSection }, + { "remove-section", "remove a section: name", _ => action = ConfigAction.RemoveSection }, { "l|list", "list all", _ => action = ConfigAction.List }, { "e|edit", "edit the config file in an editor", _ => action = ConfigAction.Edit }, { Environment.NewLine }, { "Other" }, - { "default:", "with --get, use default value when missing entry", v => defaultValue = v }, { "name-only", "show variable names only", _ => nameOnly = true }, + { "default:", "with --get, use default value when missing entry", v => defaultValue = v }, { "type:", "value is given this type, can be 'boolean', 'datetime' or 'number'", t => type = t }, { "debug", "add some extra logging for troubleshooting purposes", _ => debug = true, true }, @@ -95,6 +98,12 @@ static int Run(string[] args) return ShowError("Can only specify one config location."); } + if (file != null && !File.Exists(file)) + return ShowError($"Specified config file {file} not found."); + + // --file overrides --path since it's more specific. + path = file ?? path ?? Directory.GetCurrentDirectory(); + ConfigLevel? level = null; Config config; if (useGlobal) From c0d76dbff31b3cee031b06dc8b5e2c718ff71d58 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 14:54:58 -0300 Subject: [PATCH 09/13] Improve docs, add package readme for extensions --- readme.md | 5 ++++ src/CommandLine/CommandLine.csproj | 5 +--- src/Config.Tool/Config.Tool.csproj | 37 ++------------------------ src/Config/Config.csproj | 10 +------ src/Config/readme.md | 3 +++ src/Configuration/Configuration.csproj | 11 +++++--- src/Configuration/readme.md | 3 +++ 7 files changed, 22 insertions(+), 52 deletions(-) create mode 100644 src/Config/readme.md create mode 100644 src/Configuration/readme.md diff --git a/readme.md b/readme.md index ff540e5..109c1d0 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,7 @@ CLI

+ # Why `dotnet-config` (or `.netconfig`) provides a uniform mechanism for @@ -339,6 +340,7 @@ You can explore the entire API in the [docs site](https://dotnetconfig.org/api/) PM> Install-Package DotNetConfig.Configuration ``` + Usage (in this example, also chaining other providers): ```csharp @@ -366,6 +368,8 @@ string port = config["serve:port"]; // == "8080"; string timeout = config["security:admin:timeout"]; // == "60"; ``` + + ### System.CommandLine [![Version](https://img.shields.io/nuget/v/DotNetConfig.CommandLine.svg?color=royalblue)](https://www.nuget.org/packages/DotNetConfig.CommandLine) [![Downloads](https://img.shields.io/nuget/dt/DotNetConfig.CommandLine.svg?color=darkmagenta)](https://www.nuget.org/packages/DotNetConfig.CommandLine) @@ -543,4 +547,5 @@ all the following variants for arguments are supported: `-flag`, `--flag`, `/fla `--flag value`, `/flag value`. + \ No newline at end of file diff --git a/src/CommandLine/CommandLine.csproj b/src/CommandLine/CommandLine.csproj index be505d5..2855a1a 100644 --- a/src/CommandLine/CommandLine.csproj +++ b/src/CommandLine/CommandLine.csproj @@ -35,10 +35,7 @@ The following heuristics are applied when providing default values: - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Config.Tool/Config.Tool.csproj b/src/Config.Tool/Config.Tool.csproj index 29bb93d..2093a67 100644 --- a/src/Config.Tool/Config.Tool.csproj +++ b/src/Config.Tool/Config.Tool.csproj @@ -1,37 +1,7 @@  - A global tool for managing hierarchical configurations for dotnet tools, using git config format. - -Usage: dotnet config [options] - -Location (uses all locations by default) - --local use .netconfig.user file - --global use global config file - --system use system config file - --path[=VALUE] use given config file or directory - -Action - --get get value: name [value-regex] - --get-all get all values: key [value-regex] - --get-regexp get values for regexp: name-regex [value-regex] - --set set value: name value [value-regex] - --set-all set all matches: name value [value-regex] - --add add a new variable: name value - --unset remove a variable: name [value-regex] - --unset-all remove all matches: name [value-regex] - --remove-section remove a section: name - --rename-section rename section: old-name new-name - -l, --list list all - -e, --edit edit the config file in an editor - -Other - --default[=VALUE] with --get, use default value when missing entry - --name-only show variable names only - --type[=VALUE] value is given this type, can be 'boolean', ' - datetime' or 'number' - -?, -h, --help Display this help - + A global tool for managing hierarchical configurations for dotnet tools, using git config format. Exe net6.0;net8.0 @@ -52,10 +22,7 @@ Other - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Config/Config.csproj b/src/Config/Config.csproj index 9c5c84b..1f243e1 100644 --- a/src/Config/Config.csproj +++ b/src/Config/Config.csproj @@ -20,7 +20,6 @@ Usage: true true true - readme.md @@ -33,18 +32,11 @@ Usage: - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - - diff --git a/src/Config/readme.md b/src/Config/readme.md new file mode 100644 index 0000000..2bf6101 --- /dev/null +++ b/src/Config/readme.md @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/Configuration/Configuration.csproj b/src/Configuration/Configuration.csproj index 2166517..c32bc37 100644 --- a/src/Configuration/Configuration.csproj +++ b/src/Configuration/Configuration.csproj @@ -21,14 +21,17 @@ Note: section is required and subsection is optional, just like in dotnet-config - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + %(Filename)%(Extension) + + + diff --git a/src/Configuration/readme.md b/src/Configuration/readme.md new file mode 100644 index 0000000..7c486fd --- /dev/null +++ b/src/Configuration/readme.md @@ -0,0 +1,3 @@ + + + \ No newline at end of file From d6bd67810157e8483dc3963e2fb9275b1a14b66b Mon Sep 17 00:00:00 2001 From: kzu Date: Tue, 25 Jun 2024 17:58:54 +0000 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=96=89=20Update=20changelog=20with?= =?UTF-8?q?=20v1.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/changelog.md b/changelog.md index cb05fc6..8b10f3e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## [v1.1.1](https://github.com/dotnetconfig/dotnet-config/tree/v1.1.1) (2024-06-25) + +[Full Changelog](https://github.com/dotnetconfig/dotnet-config/compare/v1.1.0...v1.1.1) + +:sparkles: Implemented enhancements: + +- Improve docs, add package readme for extensions [\#152](https://github.com/dotnetconfig/dotnet-config/pull/152) (@kzu) +- Make options and note high compat level with git config [\#151](https://github.com/dotnetconfig/dotnet-config/pull/151) (@kzu) + +:bug: Fixed bugs: + +- DotNetConfig.CommandLine not compatible with latest prerelease of System.Commandline [\#105](https://github.com/dotnetconfig/dotnet-config/issues/105) + ## [v1.1.0](https://github.com/dotnetconfig/dotnet-config/tree/v1.1.0) (2024-06-25) [Full Changelog](https://github.com/dotnetconfig/dotnet-config/compare/v1.0.6...v1.1.0) From 24bd9714e7ee11725ee3bcf59d00fd21958664c2 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 25 Jun 2024 15:12:31 -0300 Subject: [PATCH 11/13] Add test that ensures current tab-based behavior To ensure compatibility with git config. Closes #122 --- src/Config.Tests/ConfigTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Config.Tests/ConfigTests.cs b/src/Config.Tests/ConfigTests.cs index a3a23ee..bc910d5 100644 --- a/src/Config.Tests/ConfigTests.cs +++ b/src/Config.Tests/ConfigTests.cs @@ -249,6 +249,19 @@ public void can_roundtrip() Assert.Equal("bar", value); } + [Fact] + public void when_setting_variable_then_uses_tab() + { + var file = Path.GetTempFileName(); + var config = Config.Build(file); + + config.SetString("section", "subsection", "foo", "bar"); + + var line = File.ReadAllLines(file).SkipWhile(x => x[0] == '#' || x[0] == '[').First(); + + Assert.Equal('\t', line[0]); + } + [Fact] public void when_setting_global_variable_then_writes_global_file() { From 5bf47f78893133c09e5fed0885d65774efc5452f Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sun, 7 Jul 2024 18:19:11 -0300 Subject: [PATCH 12/13] Drop immutability which doesn't add any value Config writing is such a rare occurrence (compared with reading) that the additional complexity of making the config/document immutable doesn't bring much value. Quite the contrary, it's a source of hard to detect bugs. The pattern of returning the same object for chaining is preserved (so the API is backs-compat), and the mutability is compatible with how ServiceCollection works, for example. The chaining is just for convenience. --- docs/api/index.md | 5 +- readme.md | 5 +- src/Config.Tests/ConfigTests.cs | 15 ++++ src/Config/Config.cs | 6 +- src/Config/Config.csproj | 4 +- src/Config/ConfigDocument.cs | 28 +++++--- src/Config/FileConfig.cs | 121 +++++++++++++++++--------------- 7 files changed, 105 insertions(+), 79 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 9b1b0f9..bd4acc1 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -96,12 +96,9 @@ configuration level to use for persisting the value, by passing a `ConfigLevel`: //[vs "alias"] // comexp = run|community|exp -config = config.AddString("vs", "alias", "comexp", "run|community|exp", ConfigLevel.Global); +config.AddString("vs", "alias", "comexp", "run|community|exp", ConfigLevel.Global); ``` -> IMPORTANT: the Config API is immutable, so if you make changes, you should update your reference -> to the newly updated Config, otherwise, subsequent changes would override prior ones. - You can explore the entire API in the [docs site](https://dotnetconfig.org/api/). ### Microsoft.Extensions.Configuration diff --git a/readme.md b/readme.md index 109c1d0..533f274 100644 --- a/readme.md +++ b/readme.md @@ -324,12 +324,9 @@ configuration level to use for persisting the value, by passing a `ConfigLevel`: //[vs "alias"] // comexp = run|community|exp -config = config.AddString("vs", "alias", "comexp", "run|community|exp", ConfigLevel.Global); +config.AddString("vs", "alias", "comexp", "run|community|exp", ConfigLevel.Global); ``` -> IMPORTANT: the Config API is immutable, so if you make changes, you should update your reference -> to the newly updated Config, otherwise, subsequent changes would override prior ones. - You can explore the entire API in the [docs site](https://dotnetconfig.org/api/). ### Microsoft.Extensions.Configuration diff --git a/src/Config.Tests/ConfigTests.cs b/src/Config.Tests/ConfigTests.cs index bc910d5..669c54b 100644 --- a/src/Config.Tests/ConfigTests.cs +++ b/src/Config.Tests/ConfigTests.cs @@ -262,6 +262,21 @@ public void when_setting_variable_then_uses_tab() Assert.Equal('\t', line[0]); } + [Fact] + public void when_setting_muliplevariables_then_can_reuse_instance() + { + var file = Path.GetTempFileName(); + var config = Config.Build(file); + + config.SetString("section", "subsection", "foo", "bar"); + config.SetString("section", "subsection", "bar", "baz"); + + var saved = Config.Build(file); + + Assert.Equal("bar", saved.GetString("section", "subsection", "foo")); + Assert.Equal("baz", saved.GetString("section", "subsection", "bar")); + } + [Fact] public void when_setting_global_variable_then_writes_global_file() { diff --git a/src/Config/Config.cs b/src/Config/Config.cs index 4e00dbf..627da43 100644 --- a/src/Config/Config.cs +++ b/src/Config/Config.cs @@ -159,9 +159,9 @@ public static Config Build(ConfigLevel store) => /// public ConfigLevel? Level => this is AggregateConfig ? null : - FilePath == GlobalLocation ? (ConfigLevel?)ConfigLevel.Global : - FilePath == SystemLocation ? (ConfigLevel?)ConfigLevel.System : - FilePath.EndsWith(UserExtension) ? (ConfigLevel?)ConfigLevel.Local : null; + FilePath == GlobalLocation ? ConfigLevel.Global : + FilePath == SystemLocation ? ConfigLevel.System : + FilePath.EndsWith(UserExtension) ? ConfigLevel.Local : null; /// /// Gets the section and optional subsection from the configuration. diff --git a/src/Config/Config.csproj b/src/Config/Config.csproj index 1f243e1..23842e5 100644 --- a/src/Config/Config.csproj +++ b/src/Config/Config.csproj @@ -7,8 +7,8 @@ Usage: var config = Config.Build(); var value = config.GetString("section", "subsection", "variable"); - // Setting values, Config is immutable, so chain calls and update var - config = config + // Setting values + config .SetString("section", "subsection", "variable", value) .SetBoolean("section", "subsection", "enabled", true); diff --git a/src/Config/ConfigDocument.cs b/src/Config/ConfigDocument.cs index 9c92551..7870c27 100644 --- a/src/Config/ConfigDocument.cs +++ b/src/Config/ConfigDocument.cs @@ -38,7 +38,7 @@ record ConfigDocument : IEnumerable public ConfigLevel? Level { get; } - internal ImmutableList Lines { get; init; } = ImmutableList.Empty; + internal ImmutableList Lines { get; private set; } = ImmutableList.Empty; public ConfigDocument Save() { @@ -99,7 +99,8 @@ public ConfigDocument Add(string section, string? subsection, string name, strin lines = lines.Insert(index, Line.CreateVariable(filePath, index, sectionLine.Section, sectionLine.Subsection, name, value)); } - return this with { Lines = lines }; + Lines = lines; + return this; } public ConfigDocument Set(string section, string? subsection, string name, string? value = null, ValueMatcher? valueMatcher = null) @@ -119,7 +120,8 @@ public ConfigDocument Set(string section, string? subsection, string name, strin if (variable != null) { - return this with { Lines = Lines.Replace(variable, variable.WithValue(value)) }; + Lines = Lines.Replace(variable, variable.WithValue(value)); + return this; } else { @@ -143,7 +145,9 @@ public ConfigDocument Unset(string section, string? subsection, string name) var variable = variables.FirstOrDefault(); if (variable != null) { - return (this with { Lines = Lines.Remove(variable) }).CleanupSection(section, subsection); + Lines = Lines.Remove(variable); + CleanupSection(section, subsection); + return this; } return this; @@ -164,7 +168,8 @@ public ConfigDocument SetAll(string section, string? subsection, string name, st lines = lines.Replace(variable, variable.WithValue(value)); } - return this with { Lines = lines }; + Lines = lines; + return this; } public ConfigDocument UnsetAll(string section, string? subsection, string name, ValueMatcher? valueMatcher = null) @@ -185,7 +190,9 @@ public ConfigDocument UnsetAll(string section, string? subsection, string name, lines = lines.Remove(variable); } - return (this with { Lines = lines }).CleanupSection(section, subsection); + Lines = lines; + CleanupSection(section, subsection); + return this; } public ConfigDocument RemoveSection(string section, string? subsection = null) @@ -216,7 +223,8 @@ public ConfigDocument RemoveSection(string section, string? subsection = null) while (lines.Count > 0 && lines[^1].Kind == LineKind.None) lines = lines.RemoveAt(lines.Count - 1); - return this with { Lines = lines }; + Lines = lines; + return this; } public ConfigDocument RenameSection(string oldSection, string? oldSubsection, string newSection, string? newSubsection) @@ -246,7 +254,8 @@ public ConfigDocument RenameSection(string oldSection, string? oldSubsection, st } } - return this with { Lines = lines }; + Lines = lines; + return this; } /// @@ -266,7 +275,8 @@ ConfigDocument CleanupSection(string section, string? subsection) while (index < lines.Count && lines[index].Kind == LineKind.None) lines = lines.RemoveAt(index); - return this with { Lines = lines }; + Lines = lines; + return this; } } diff --git a/src/Config/FileConfig.cs b/src/Config/FileConfig.cs index a167566..6d09378 100644 --- a/src/Config/FileConfig.cs +++ b/src/Config/FileConfig.cs @@ -20,32 +20,33 @@ public override Config AddBoolean(string section, string? subsection, string var if (value) { // Shortcut notation. - return new FileConfig(FilePath, - document.Add(section, subsection, variable, null) - .Save()); + document.Add(section, subsection, variable, null).Save(); } else { - return new FileConfig(FilePath, - document.Add(section, subsection, variable, "false") - .Save()); + document.Add(section, subsection, variable, "false").Save(); } + + return this; } public override Config AddDateTime(string section, string? subsection, string variable, DateTime value) - => new FileConfig(FilePath, document - .Add(section, subsection, variable, value.ToString("O")) - .Save()); + { + document.Add(section, subsection, variable, value.ToString("O")).Save(); + return this; + } public override Config AddNumber(string section, string? subsection, string variable, long value) - => new FileConfig(FilePath, document - .Add(section, subsection, variable, value.ToString()) - .Save()); + { + document.Add(section, subsection, variable, value.ToString()).Save(); + return this; + } public override Config AddString(string section, string? subsection, string variable, string value) - => new FileConfig(FilePath, document - .Add(section, subsection, variable, value) - .Save()); + { + document.Add(section, subsection, variable, value).Save(); + return this; + } public override IEnumerable GetAll(string section, string? subsection, string variable, string? valueRegex) => document.GetAll(section, subsection, variable, valueRegex); @@ -69,78 +70,82 @@ public override IEnumerable GetRegex(string nameRegex, string? valu } public override Config RemoveSection(string section, string? subsection) - => new FileConfig(FilePath, document - .RemoveSection(section, subsection) - .Save()); + { + document.RemoveSection(section, subsection).Save(); + return this; + } public override Config RenameSection(string oldSection, string? oldSubsection, string newSection, string? newSubsection) - => new FileConfig(FilePath, document - .RenameSection(oldSection, oldSubsection, newSection, newSubsection) - .Save()); + { + document.RenameSection(oldSection, oldSubsection, newSection, newSubsection).Save(); + return this; + } public override Config SetAllBoolean(string section, string? subsection, string variable, bool value, string? valueRegex) { if (value) { // Shortcut notation. - return new FileConfig(FilePath, document - .SetAll(section, subsection, variable, null, valueRegex) - .Save()); + document.SetAll(section, subsection, variable, null, valueRegex).Save(); } else { - return new FileConfig(FilePath, document - .SetAll(section, subsection, variable, "false", valueRegex) - .Save()); + document.SetAll(section, subsection, variable, "false", valueRegex).Save(); } + + return this; } public override Config SetAllDateTime(string section, string? subsection, string variable, DateTime value, string? valueRegex) - => new FileConfig(FilePath, document - .SetAll(section, subsection, variable, value.ToString("O"), valueRegex) - .Save()); + { + document.SetAll(section, subsection, variable, value.ToString("O"), valueRegex).Save(); + return this; + } public override Config SetAllNumber(string section, string? subsection, string variable, long value, string? valueRegex) - => new FileConfig(FilePath, document - .SetAll(section, subsection, variable, value.ToString(), valueRegex) - .Save()); + { + document.SetAll(section, subsection, variable, value.ToString(), valueRegex).Save(); + return this; + } public override Config SetAllString(string section, string? subsection, string variable, string value, string? valueRegex) - => new FileConfig(FilePath, document - .SetAll(section, subsection, variable, value, valueRegex) - .Save()); + { + document.SetAll(section, subsection, variable, value, valueRegex).Save(); + return this; + } public override Config SetBoolean(string section, string? subsection, string variable, bool value, string? valueRegex) { if (value) { // Shortcut notation. - return new FileConfig(FilePath, document - .Set(section, subsection, variable, null, valueRegex) - .Save()); + document.Set(section, subsection, variable, null, valueRegex).Save(); } else { - return new FileConfig(FilePath, document - .Set(section, subsection, variable, "false", valueRegex) - .Save()); + document.Set(section, subsection, variable, "false", valueRegex).Save(); } + + return this; } public override Config SetDateTime(string section, string? subsection, string variable, DateTime value, string? valueRegex) - => new FileConfig(FilePath, document - .Set(section, subsection, variable, value.ToString("O"), valueRegex) - .Save()); + { + document.Set(section, subsection, variable, value.ToString("O"), valueRegex).Save(); + return this; + } public override Config SetNumber(string section, string? subsection, string variable, long value, string? valueRegex) - => new FileConfig(FilePath, document - .Set(section, subsection, variable, value.ToString(), valueRegex) - .Save()); + { + document.Set(section, subsection, variable, value.ToString(), valueRegex).Save(); + return this; + } public override Config SetString(string section, string? subsection, string variable, string value, string? valueRegex) - => new FileConfig(FilePath, document - .Set(section, subsection, variable, value, valueRegex) - .Save()); + { + document.Set(section, subsection, variable, value, valueRegex).Save(); + return this; + } public override bool TryGetBoolean(string section, string? subsection, string variable, out bool value) { @@ -211,14 +216,16 @@ public override bool TryGetString(string section, string? subsection, string var } public override Config Unset(string section, string? subsection, string variable) - => new FileConfig(FilePath, document - .Unset(section, subsection, variable) - .Save()); + { + document.Unset(section, subsection, variable).Save(); + return this; + } public override Config UnsetAll(string section, string? subsection, string variable, string? valueMatcher) - => new FileConfig(FilePath, document - .UnsetAll(section, subsection, variable, valueMatcher) - .Save()); + { + document.UnsetAll(section, subsection, variable, valueMatcher).Save(); + return this; + } protected override IEnumerable GetEntries() => document; From a33573daafc4168d147e5730323e5f3e589570b2 Mon Sep 17 00:00:00 2001 From: kzu Date: Sun, 7 Jul 2024 21:36:59 +0000 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=96=89=20Update=20changelog=20with?= =?UTF-8?q?=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/changelog.md b/changelog.md index 8b10f3e..fb92fb1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,21 @@ # Changelog +## [v1.2.0](https://github.com/dotnetconfig/dotnet-config/tree/v1.2.0) (2024-07-07) + +[Full Changelog](https://github.com/dotnetconfig/dotnet-config/compare/v1.1.1...v1.2.0) + +:sparkles: Implemented enhancements: + +- Drop immutability which doesn't add any value [\#155](https://github.com/dotnetconfig/dotnet-config/pull/155) (@kzu) + +:hammer: Other: + +- Tabs vs spaces [\#122](https://github.com/dotnetconfig/dotnet-config/issues/122) + +:twisted_rightwards_arrows: Merged: + +- Add test that ensures current tab-based behavior [\#153](https://github.com/dotnetconfig/dotnet-config/pull/153) (@kzu) + ## [v1.1.1](https://github.com/dotnetconfig/dotnet-config/tree/v1.1.1) (2024-06-25) [Full Changelog](https://github.com/dotnetconfig/dotnet-config/compare/v1.1.0...v1.1.1)