diff --git a/.editorconfig b/.editorconfig index 4cab270..e17d14e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -107,3 +107,6 @@ dotnet_analyzer_diagnostic.category-Style.severity = none # VSTHRD200: Use "Async" suffix for async methods dotnet_diagnostic.VSTHRD200.severity = none + +[**/*SponsorLink*/**] +generated_code = true \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md deleted file mode 100644 index 775f221..0000000 --- a/.github/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,6 +0,0 @@ -# Code of Conduct - -This project has adopted the code of conduct defined by the Contributor Covenant -to clarify expected behavior in our community. - -For more information, see the [.NET Foundation Code of Conduct](https://dotnetfoundation.org/code-of-conduct). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index f577cde..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,35 +0,0 @@ -# Contributing - -You can contribute to the project with issues and PRs. -Simply filing issues for problems you encounter is a great way to contribute. Contributing implementations is greatly appreciated. - -## Before you file a bug... -* Is this a question, or are you looking for help? Ask it in the project's **Discussions** tab instead, if available. - -* Did you search the issues list to see if someone already reported it? _When looking for duplicates, make sure to look through open **and** closed issues._ -* Did you create a simple repro for the problem? _We won't look at your bug until you provide a repro project or complete repro steps._ - -## Before you submit a PR... - -* Did you ensure there is a corresponding issue labelled as 🟢 help wanted? - If not, please open one to start the discussion. -* Does the code follow existing coding styles? (spaces, comments, no regions, etc.)? - We enforce the basic styles by running `dotnet format` on the repository root. - You can do the same before submitting a PR to speed up the process. -* Did you write unit tests? - Typically required if there are other unit tests for existing or related code. - -## Looking for something to work on? - -Look at the repo issues labelled as 🟢 help wanted and 🟣 good first issue to find -something to contribute! - -# Contributor License Agreement - -You must sign the [Contribution License Agreement (CLA)](https://cla-assistant.io/devlooped/) before your PR will be merged. -This is a one-time requirement for projects in the Devlooped organization. - You can read more about [Contribution License Agreements (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) on Wikipedia. - - You don't have to do this up-front. You can simply clone, fork, and submit your pull-request as usual. - When your pull-request is created, it is classified by a CLA bot, which will let - you know if signing is pending. \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md deleted file mode 100644 index 01b219a..0000000 --- a/.github/SECURITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Security Policy - -## Reporting a Vulnerability - -Security issues and bugs should be reported privately by emailing security@devlooped.com. -You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your -original message and ping [@devlooped](https://twitter.com/devlooped) on Twitter. - -Please do not open issues for anything you think might have a security implication. \ No newline at end of file diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md deleted file mode 100644 index cfde22d..0000000 --- a/.github/SUPPORT.md +++ /dev/null @@ -1,9 +0,0 @@ -# Support - -Support is typically available thorugh GitHub Discussions and Issues. - -**Found a bug? File an issue.** If you found something that isn't working as expected and you can reproduce it, go to the repo Issues tab and file an issue. - -**Want a new feature? Start a discussion.** It's always good to start a discussion (Discussion tab in the project, Ideas category) to explain the problem you're trying to solve before jumping in with the pull request. We may already have a solution in place or have an idea on ways to solve that issue. After the details are nailed down, we will migrate the discussion to a proper Issue (with the `enhancement`) label. At that point, we'd love to see your PRs :). - -Consider [sponsoring](https://github.com/sponsors/devlooped) the project to get priority support and many other perks! diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ed06dbe..c95eb73 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -29,7 +29,7 @@ updates: - "Microsoft.AspNetCore*" Tests: patterns: - - "Microsoft.NET.Tests*" + - "Microsoft.NET.Test*" - "xunit*" - "coverlet*" ThisAssembly: 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 a438e35..6639458 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ bin -app obj artifacts pack TestResults +results +BenchmarkDotNet.Artifacts +/app .vs .vscode .idea diff --git a/.netconfig b/.netconfig index 32fe484..69f4fae 100644 --- a/.netconfig +++ b/.netconfig @@ -27,8 +27,8 @@ skip [file ".editorconfig"] url = https://github.com/devlooped/oss/blob/main/.editorconfig - sha = c779d3d4e468358106dea03e93ba2cd35bb01ecb - etag = 7298c6450967975a8782b5c74f3071e1910fc59686e48f9c9d5cd7c68213cf59 + sha = f571a42eac3cad554810dad15139ff390db5e1db + etag = ba2655b8b3ce5491b1c0eea5e0af201a085c48e07542bb9ec2c928084944ea86 weak [file ".gitattributes"] url = https://github.com/devlooped/oss/blob/main/.gitattributes @@ -42,33 +42,33 @@ weak [file ".github/dependabot.yml"] url = https://github.com/devlooped/oss/blob/main/.github/dependabot.yml - sha = 35ca3f3405452465058d89005f8a88a65847c377 - etag = f8080f8f04d87529e90d9a66751d304a7141196fb9734aa2d110784e52e66898 + 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 ".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/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/publish.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/publish.yml - sha = d3022567c9ef2bc9461511e53b8abe065afdf03b - etag = 58601b5a71c805647ab26e84053acdfb8d174eaa93330487af8a5503753c5707 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = 4e9a9885a28ce867fd6139e1ae23735ad0073775145af96ff7d96d047d750973 weak [file ".gitignore"] url = https://github.com/devlooped/oss/blob/main/.gitignore - sha = 4bd702593c10df189cd4a0f6e6fb72e55de02198 - etag = f11dcc8c057bd2f526aa9c7090c9568d1656fafaaedc6719da42a00283018ffa + sha = 02811fa23b0a102b9b33048335d41e515bf75737 + etag = a9c37ae312afac14b78436a7d018af4483d88736b5f780576f2c5a0b3f14998c weak [file "Directory.Build.rsp"] url = https://github.com/devlooped/oss/blob/main/Directory.Build.rsp @@ -92,8 +92,8 @@ weak [file "src/Directory.Build.props"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.props - sha = 1bf1eacc7ac3920d52c8e7045bfa34abc7c05302 - etag = e9fd8ef4740d5559fa495580b04ec3437b99edbfb8452813907337f9374c1282 + sha = 14deaea5cecc64df51781d29891a2f67caf8be16 + etag = f177eb767aaa6a347da43ff7ff419c9a0736c562cb171e17ded8007a1945a8b0 weak [file "src/Directory.Build.targets"] url = https://github.com/devlooped/oss/blob/main/src/Directory.Build.targets @@ -110,15 +110,10 @@ sha = 9a1b07589b9bde93bc12528e9325712a32dec418 etag = b54216ac431a83ce5477828d391f02046527e7f6fffd21da1d03324d352c3efb weak -[file "src/nuget.config"] - url = https://github.com/devlooped/oss/blob/main/src/nuget.config - sha = b2fa09bd9db6de89e37a8ba6705b5659e435dafd - etag = eb2d09e546aa1e11c0b464d9ed6ab2d3c028a1d86c3ac40a318053625fb72819 - weak [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/combine-prs.yml"] url = https://github.com/devlooped/oss/blob/main/.github/workflows/combine-prs.yml @@ -135,38 +130,218 @@ sha = 055a8b7c94b74ae139cce919d60b83976d2a9942 etag = ddb17acb5872e9e69a76f9dec0ca590f25382caa2ccf750df058dcabb674db2b weak -[file ".github/CODE_OF_CONDUCT.md"] - url = https://github.com/devlooped/.github/blob/main/.github/CODE_OF_CONDUCT.md - sha = c97cdeb04769df3f397d9e70b345d4549628c388 - etag = 4857c01bb695f09bf6912d778951c3065d9dd565e5de3d0827f40432d0e4c613 - weak -[file ".github/CONTRIBUTING.md"] - url = https://github.com/devlooped/.github/blob/main/.github/CONTRIBUTING.md - sha = 1dc5908c7711c17c1fe06abd191f2c09f37ac56a - etag = b2bc4ee0e910ea60c3daa4e7d391e6260b5644d3e3c2a19145525c8db3f454fd - weak -[file ".github/SECURITY.md"] - url = https://github.com/devlooped/.github/blob/main/.github/SECURITY.md - sha = c97cdeb04769df3f397d9e70b345d4549628c388 - etag = fc8101fd914820db3e6b42d608dc46aefbc60c830ac721ed3917daead3727dbd - weak -[file ".github/SUPPORT.md"] - url = https://github.com/devlooped/.github/blob/main/.github/SUPPORT.md - sha = c97cdeb04769df3f397d9e70b345d4549628c388 - etag = 2d47e2fc4fdfa3515203d452674566e3df507461f9938f451a06d79deb687d24 - 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/ISSUE_TEMPLATE/bug.md"] url = https://github.com/devlooped/.github/blob/main/.github/ISSUE_TEMPLATE/bug.md - sha = 50faf1212e849f3ebd675be93a6c2d9917faa6d0 - etag = 6d3c88228649aa36431b5a8017baa46cd5d1584542fbc87e0d907b17dd882899 + sha = 80e81d21c020841dfc8678f218d42ddaffad78db + etag = 6ab86c474f24c915681abf4dca6c5becf3a211ab09d11e290385890dadd6f97f weak [file "profile/readme.md"] url = https://github.com/devlooped/.github/blob/main/profile/readme.md - sha = 7676dd2cb4bc284b699dfd7e0888c4c8fe28de9a - etag = d055b4cb45f2c3a5c553869d317cdb90de91c4bccb2b967ce4a59e50ba2e8335 + sha = ecb79e3ef2e803f6dd39d10dba2afc25696143af + etag = 636347c529242f82026eed728a5f4ce12735f3d2628ae5748b7c2aea1a0ca31d + weak +[file "src/SponsorLink/Analyzer/Analyzer.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Analyzer/Analyzer.csproj + sha = 7cda4a18313b0b38b26c0152e1007cdbb9b6ba3a + etag = d9444fa36daa8f4ff8f06fc2f9f600dbd8032f25ff58542d3b96676e0305677e + 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 = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 23d4cd16294974d85164fc26d6a7e2ae52698f23a62463db5025d69d9c166dc5 + 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 = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 6823e1e914ecedd174276e3d53517cc0b332bb47c56402a9512cfa6aeeeb067e + 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 = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 3720f8ae0605aa64df8f6c1d9769969162175b79c93a21024653f210a42348e6 + 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/SponsorLink.targets"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink.targets + sha = 7cda4a18313b0b38b26c0152e1007cdbb9b6ba3a + etag = d725bd9cfa33f35224e91748f64237e4dc66270f7e5ec7c835b78164531ae3db + 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 = b2a11faac6c1c300bce8c1d45f95b585c19f2953 + etag = 9f289f45169f35916fff1857840d4118ed134215639d6dae9016dc62004291a5 + 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/SponsorLink.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.cs + sha = 4fca946c3201d90d30e2183f699c850dcc1bf8d5 + etag = 96e1b1b28bfb2372bd5ffcc6bdef65ee926822b3489ce65be4e5a400884dce21 + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.csproj"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.csproj + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = ffaea0b580d8dccd672e749a5efd11fda318c484ca4a34428ff81524ec80ec4b + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.es.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.es.resx + sha = 1c7fd8d13411eeaf23c9f3be754b680e72052731 + etag = 3ccb4b82fffbb0a4244596d3081ff06664dc86fb687c30a8ed700780fe167d3c + weak +[file "src/SponsorLink/SponsorLink/SponsorLink.resx"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLink.resx + sha = 1c7fd8d13411eeaf23c9f3be754b680e72052731 + etag = bfe2910a5952c3b8a5834aa5b9edb9ea069d48b6609b74fb31b8e61919d4bea1 + weak +[file "src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs + sha = b2a11faac6c1c300bce8c1d45f95b585c19f2953 + etag = fc96f7f5642cbf69b35b7e8de1756822580315f0cee61e47da3b2b1b03f68e1a + 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/ThisAssembly.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/ThisAssembly.cs + sha = b2a11faac6c1c300bce8c1d45f95b585c19f2953 + etag = 978269025f58e2bae872af25fdfd94659e234e8365e3014c18b1b20fdcd155bf + 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 = b8fd87b94547860f560054ba9bf1272be0c46eac + etag = 95fd763e4c512e54f007d67d2f019c2fabbaaf083d118e5d53ec0cec537d9c71 + weak +[file "src/SponsorLink/SponsorLink/devlooped.pub.jwk"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/SponsorLink/devlooped.pub.jwk + sha = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = cf884781ff88b4d096841e3169282762a898b2050c9b5dac0013bc15bdbee267 + 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/Sample.cs"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/Tests/Sample.cs + sha = e732f6a2c44a2f7940a1868a92cd66523f74ed24 + etag = db968d1d665b77a17e13bc7ca3d43ea65ed05cbebc18669f1b607ebe0e38a59a + 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 = a0ae7272f31c766ebb129ea38c11c01df93b6b5d + etag = 237409e155202ec1b845593195d30057a949b2b18ae46a575e4cf480e4e2c8fe + weak +[file "src/SponsorLink/readme.md"] + url = https://github.com/devlooped/oss/blob/main/src/SponsorLink/readme.md + sha = 827a1d18bf0245978d81bcd3d52e9e6f1584d1ef + etag = 079b4aedba2aa9851e609b569f25c55db8d5922e3dbb1adc22611ce4d6cfe465 + weak +[file ".github/code_of_conduct.md"] + url = https://github.com/devlooped/.github/blob/main/.github/code_of_conduct.md + sha = 4d38bd1a1662e69a131dfe2e860a0a832ea89db4 + etag = fcc35d9f63ba728070d34229be6f8ca403f4cde03e1da183f95e530bf8c632a0 + weak +[file ".github/contributing.md"] + url = https://github.com/devlooped/.github/blob/main/.github/contributing.md + sha = 80e81d21c020841dfc8678f218d42ddaffad78db + etag = b966999d5ed1927c5139d04bbd057546d0f3c2e8226c10a71c4cab78b92c3796 + weak +[file ".github/security.md"] + url = https://github.com/devlooped/.github/blob/main/.github/security.md + sha = 80e81d21c020841dfc8678f218d42ddaffad78db + etag = fc8101fd914820db3e6b42d608dc46aefbc60c830ac721ed3917daead3727dbd + weak +[file ".github/support.md"] + url = https://github.com/devlooped/.github/blob/main/.github/support.md + sha = 80e81d21c020841dfc8678f218d42ddaffad78db + etag = 2d47e2fc4fdfa3515203d452674566e3df507461f9938f451a06d79deb687d24 + weak +[file "sponsorlink.jwt"] + url = https://github.com/devlooped/.github/blob/main/sponsorlink.jwt + sha = 51e44c3926b1816c04d34aef0ae541138350ca60 + etag = b69d75dd622e327b9cf433c8d5399da24f96607bb87758514b9cb0142d6f67ea weak diff --git a/profile/readme.md b/profile/readme.md index 9b2881d..c10ccb2 100644 --- a/profile/readme.md +++ b/profile/readme.md @@ -1,17 +1,18 @@ -

Hi 👋, I'm kzu

-

sounds like 'kah-zu' rather than 'kzoo' :)

-

A hardcode .NET/C# developer from Argentina

+Hello there! This is [@kzu](https://github.com/kzu)'s (sounds like 'kah-zu' rather than 'kzoo' 😉) organization account +where I publish projects I think might be useful to others and not just personal experiments. -[![X](https://img.shields.io/badge/X-@kzu-blue?logo=twitter&logoColor=1D9BF0&labelColor=black)](https://x.com/kzu) -[![Discord](https://img.shields.io/badge/discord-danielkzu-7289DA.svg?logo=discord&logoColor=white)](https://discord.gg/8PtpGdu) -[![LinkedIn](https://img.shields.io/badge/LinkedIn-danielkzu-0A66C2.svg?logo=linkedin&logoColor=0A66C2&labelColor=silver)]([https://discord.gg/8PtpGdu](https://linkedin.com/in/danielcazzulino)) +This is also the organization account you can actually [sponsor](https://github.com/sponsors/devlooped) +to support my ongoing opensource work. -This is my sponsor account so you can contribute to my ongoing opensource work, no minimum amount required! +## Stats -[![Sponsor me 💜](https://raw.githubusercontent.com/devlooped/sponsors/main/sponsor.png#gh-light-mode-only "Sponsor me 💜")](https://github.com/sponsors/devlooped#gh-light-mode-only) -[![Sponsor me 💜](https://raw.githubusercontent.com/devlooped/sponsors/main/sponsor-dark.png#gh-dark-mode-only "Sponsor me 💜")](https://github.com/sponsors/devlooped#gh-dark-mode-only) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/devlooped?style=social&logo=githubsponsors)](https://github.com/sponsors/devlooped) +[![GitHub followers](https://img.shields.io/github/followers/kzu?logo=GitHub&label=@kzu%20followers)](https://github.com/kzu) +[![GitHub stars](https://img.shields.io/github/stars/kzu?logo=GitHub&label=@kzu%20stars&affiliations=OWNER,COLLABORATOR&color=FFC83D)](https://github.com/kzu) +[![GitHub followers](https://img.shields.io/github/followers/devlooped?logo=GitHub&label=@devlooped%20followers)](https://github.com/devlooped) +[![GitHub Org's stars](https://img.shields.io/github/stars/devlooped?logo=GitHub&label=@devlooped%20stars)](https://github.com/devlooped) -Some fancy stats about my favorite hobbie (coding on GitHub, of course!): +Some fancy stats about my favorite hobby (coding on GitHub, of course!):

@@ -37,23 +38,44 @@ Some fancy stats about my favorite hobbie (coding on GitHub, of course!):

-

- - - - - -

-

+ -And since I'm a total fan of [Shields.io](https://shields.io/) badges, some additional GitHub stats in badge format: +## SponsorLink + +I created [SponsorLink](https://www.devlooped.com/SponsorLink/) as a mechanism to remind users that they can +sponsor my projects if they find them useful. It also allows attribution on the dev machine of a sponsorship +to potentially unlock additional functionality (or just remove the reminder and thank instead!). SponsorLink +never issues any messages outside of IDE usage, so it will never disrupt your CI/CD workflows or CLI builds. + +If you arrived here from an IDE and are interested in sponsoring, the (one-time) steps are: + +1. Select your [sponsor](https://github.com/sponsors/devlooped) tier 🙏 +1. Install the [sponsors dotnet global tool](https://nuget.org/packages/dotnet-sponsors) by running + ```shell + dotnet tool install -g dotnet-sponsors + ``` +4. Sync your sponsorship status by running + ```shell + sponsors sync devlooped + ``` + +Feel free to dive deeper into the [technical details](https://www.devlooped.com/SponsorLink/github.html) of how +this works. You can also implement SponsorLink yourself with minimal effort for your own projects. + +### Implicit or Indirect Sponsorships + +If you have ever sent a PR that was merged into any repository owned by [@devlooped](https://github.com/devlooped), +you are considered a sponsor already! Contributing your time and code is the most awesome way to support a project 🫶. + +If you belong to an organization that sponsors [@devlooped](https://github.com/sponsors/devlooped), then you are +an indirect sponsor too! This allows organizations to support projects their employees love and streamline invoicing. + +
+ +### AutoSync + +Sponsorships are renewed monthly (even if paid anually), so your dev machine manifest needs monthly renewal too. +You can simplify this process by enabling [autosync](https://www.devlooped.com/SponsorLink/github.html#auto-sync) +so that the IDE tooling can automatically do this for you by checking at most once a day for expiration and running +the same command you'd have to run manually: `sponsor sync devlooped`. -![GitHub Sponsors](https://img.shields.io/github/sponsors/devlooped?color=C65197&label=@devlooped%20sponsors) -![GitHub followers](https://img.shields.io/github/followers/kzu?logo=GitHub&label=@kzu%20followers) -![GitHub stars](https://img.shields.io/github/stars/kzu?logo=GitHub&label=@kzu%20stars&affiliations=OWNER,COLLABORATOR&color=FFC83D) -[![GitHub followers](https://img.shields.io/github/followers/devlooped?logo=GitHub&label=@devlooped%20followers)](https://github.com/devlooped) -[![GitHub Org's stars](https://img.shields.io/github/stars/devlooped?logo=GitHub&label=@devlooped%20stars)](https://github.com/devlooped) diff --git a/readme.md b/readme.md index 7348c97..161cb59 100644 --- a/readme.md +++ b/readme.md @@ -97,21 +97,18 @@ The versioning scheme for packages is: [![Stephen Shaw](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/decriptor.png "Stephen Shaw")](https://github.com/decriptor) [![Torutek](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/torutek-gh.png "Torutek")](https://github.com/torutek-gh) [![DRIVE.NET, Inc.](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/drivenet.png "DRIVE.NET, Inc.")](https://github.com/drivenet) -[![Daniel Gnägi](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/dgnaegi.png "Daniel Gnägi")](https://github.com/dgnaegi) [![Ashley Medway](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/AshleyMedway.png "Ashley Medway")](https://github.com/AshleyMedway) [![Keith Pickford](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Keflon.png "Keith Pickford")](https://github.com/Keflon) [![Thomas Bolon](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/tbolon.png "Thomas Bolon")](https://github.com/tbolon) [![Kori Francis](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/kfrancis.png "Kori Francis")](https://github.com/kfrancis) [![Toni Wenzel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/twenzel.png "Toni Wenzel")](https://github.com/twenzel) [![Giorgi Dalakishvili](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Giorgi.png "Giorgi Dalakishvili")](https://github.com/Giorgi) -[![Mike James](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/MikeCodesDotNET.png "Mike James")](https://github.com/MikeCodesDotNET) +[![Uno Platform](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/unoplatform.png "Uno Platform")](https://github.com/unoplatform) [![Dan Siegel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/dansiegel.png "Dan Siegel")](https://github.com/dansiegel) [![Reuben Swartz](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/rbnswartz.png "Reuben Swartz")](https://github.com/rbnswartz) [![Jacob Foshee](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/jfoshee.png "Jacob Foshee")](https://github.com/jfoshee) [![](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Mrxx99.png "")](https://github.com/Mrxx99) [![Eric Johnson](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/eajhnsn1.png "Eric Johnson")](https://github.com/eajhnsn1) -[![Norman Mackay](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/mackayn.png "Norman Mackay")](https://github.com/mackayn) -[![Certify The Web](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/certifytheweb.png "Certify The Web")](https://github.com/certifytheweb) [![Ix Technologies B.V.](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/IxTechnologies.png "Ix Technologies B.V.")](https://github.com/IxTechnologies) [![David JENNI](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/davidjenni.png "David JENNI")](https://github.com/davidjenni) [![Jonathan ](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/Jonathan-Hickey.png "Jonathan ")](https://github.com/Jonathan-Hickey) @@ -121,13 +118,14 @@ The versioning scheme for packages is: [![Seann Alexander](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/seanalexander.png "Seann Alexander")](https://github.com/seanalexander) [![Tino Hager](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/tinohager.png "Tino Hager")](https://github.com/tinohager) [![Mark Seemann](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/ploeh.png "Mark Seemann")](https://github.com/ploeh) -[![Angelo Belchior](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/angelobelchior.png "Angelo Belchior")](https://github.com/angelobelchior) [![Ken Bonny](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/KenBonny.png "Ken Bonny")](https://github.com/KenBonny) [![Simon Cropp](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/SimonCropp.png "Simon Cropp")](https://github.com/SimonCropp) [![agileworks-eu](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/agileworks-eu.png "agileworks-eu")](https://github.com/agileworks-eu) -[![](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/sorahex.png "")](https://github.com/sorahex) +[![sorahex](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/sorahex.png "sorahex")](https://github.com/sorahex) [![Zheyu Shen](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/arsdragonfly.png "Zheyu Shen")](https://github.com/arsdragonfly) [![Vezel](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/vezel-dev.png "Vezel")](https://github.com/vezel-dev) +[![ChilliCream](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/ChilliCream.png "ChilliCream")](https://github.com/ChilliCream) +[![4OTC](https://raw.githubusercontent.com/devlooped/sponsors/main/.github/avatars/4OTC.png "4OTC")](https://github.com/4OTC) diff --git a/sponsorlink.jwt b/sponsorlink.jwt new file mode 100644 index 0000000..06b1c8e --- /dev/null +++ b/sponsorlink.jwt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Nwb25zb3JsaW5rLmRldmxvb3BlZC5jb20vIiwiYXVkIjoiaHR0cHM6Ly9naXRodWIuY29tL3Nwb25zb3JzL2Rldmxvb3BlZCIsImlhdCI6IjE3MTgyMjQ4NzMiLCJjbGllbnRfaWQiOiJhODIzNTBmYjJiYWU0MDdiMzAyMSIsInN1Yl9qd2siOnsiZSI6IkFRQUIiLCJrdHkiOiJSU0EiLCJuIjoiNWluaHY4UXltYURCT2loTmkxZVktNi1oY0lCNXFTT05GWnhieHhYQXlPdHhBZGpGQ1BNLTk0Z0lacU05Q0RyWDNweWcxbFRKZm1sX2FfRlpTVTlkQjFpaTVtU1hfbU5IQkZYbjFfbF9naTFFcmRia0lGNVliVzZveFdGeGYzRzVtd1ZYd25QZnhIVHlRZG1XUTNZSlItQTNFQjRrYUZ3THFBNkhhNWxiMk9iR3BNVFFKTmFrRDRvVEFHRGhxSE1HaHU2UHVwR3E1aWU0cVpjUTdOOEFOdzh4SDduaWNUa2JxRWhRQUJIV09UbUxCV3E1ZjVGNlJZR0Y4UDdjbDBJV2xfdzRZY0laa0dtMnZYMmZpMjZGOUY2MGNVMXYxM0daRVZEVFhwSjlrenZZZU05c1lrNmZXYW95WTJqaEU1MXFidjBCMHU2aFNjWmlMUkV0bTNuN0NsSmJJR1hoa1VwcEZTMkpsTmFYM3JnUTZ0LTRMSzhnVVR5THQzekRzMkg4T1p5Q3dsQ3BmbUdtZHNVTWttMXhYNnQyci05NVUzenl3eW54b1daZmpCQ0pmNDFsZU05T01LWXdOV1o2TFFNeW84M0hXdzFQQklyWDRaTENsRndxQmNTWXNYRHlUOF9aTGQxY2RZbVBmbXRsbElYeFpoTENsd1Q1cWJDV3Y3M1YifX0.bTH1YjSuhaR0FGsGGcovzXpbT23pRdgZHiwjstlJ7FIyv_6UJ7SsR2x3K5Om8M1C7GaCQWdI_Hu92-0elLHG8yMeD-XbTtYD2z7td1DjmYV7mFNArKOKrg5pjNthrMP8U2yklC44bXcUOjWxHa5amT75Gr7L7mx5Evoe69yoE30ZhW1FgWJgQOHLvEGVyeEEXN96DzU3Ng5GqZZVNAs1zMd6RcraurmY_fr7FAMOlrCCnf6XmFxM8N0paJMWqBkgaO7h4bgzqVjGgUc1l6YKTR-zyLySOh4dcMbxLpAI6gwNImOVAtpC0cO8uL1bXQM86xrmYEDZ8HgwcRnrFtKZ3WQ7PoVs77fjiv8lChgDTEJWalWI7nGapkQDVA2-Hn1Ex_XiXW_KIuUrR7Y-Zt4f6GBiSIJmRL4s4YcgboEW61Lto0h5k_AZ5S2kL-OD4Qx2rudhsEEi2QP1oUW4aLTpmTHy5RAgLD-sGYnSZKDSaOBJ5aOUSilHEbBxy4_yeJgY diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4527560..1648dcd 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -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/SponsorLink/Analyzer/Analyzer.csproj b/src/SponsorLink/Analyzer/Analyzer.csproj new file mode 100644 index 0000000..963c77b --- /dev/null +++ b/src/SponsorLink/Analyzer/Analyzer.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.0 + true + analyzers/dotnet/roslyn4.0 + true + $(MSBuildThisFileDirectory)..\SponsorLink.targets + true + disable + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SponsorLink/Analyzer/Properties/launchSettings.json b/src/SponsorLink/Analyzer/Properties/launchSettings.json new file mode 100644 index 0000000..de45107 --- /dev/null +++ b/src/SponsorLink/Analyzer/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "SponsorableLib": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\Tests\\Tests.csproj", + "environmentVariables": { + "SPONSORLINK_TRACE": "true" + } + } + } +} \ No newline at end of file diff --git a/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs new file mode 100644 index 0000000..e21acb7 --- /dev/null +++ b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs @@ -0,0 +1,26 @@ +using System.Collections.Immutable; +using Devlooped.Sponsors; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using static Devlooped.Sponsors.SponsorLink; +using static ThisAssembly.Constants; + +namespace Analyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class StatusReportingAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Empty; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCodeBlockAction(c => + { + var status = Diagnostics.GetStatus(Funding.Product); + Tracing.Trace($"Status: {status}"); + }); + } +} \ No newline at end of file diff --git a/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets new file mode 100644 index 0000000..fd1e6e4 --- /dev/null +++ b/src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/SponsorLink/Directory.Build.props b/src/SponsorLink/Directory.Build.props new file mode 100644 index 0000000..c0a3e42 --- /dev/null +++ b/src/SponsorLink/Directory.Build.props @@ -0,0 +1,43 @@ + + + + false + latest + true + annotations + true + + false + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)bin')) + + https://pkg.kzu.app/index.json;https://api.nuget.org/v3/index.json + $(PackageOutputPath);$(RestoreSources) + + + 42.42.$([System.Math]::Floor($([MSBuild]::Divide($([System.DateTime]::Now.TimeOfDay.TotalSeconds), 10)))) + + 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..f351273 --- /dev/null +++ b/src/SponsorLink/Library/Library.csproj @@ -0,0 +1,31 @@ + + + + netstandard2.0 + true + SponsorableLib + Sample library incorporating SponsorLink checks + true + + + + + + + + + + + + + + MSBuild:Compile + $(IntermediateOutputPath)\$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.'))%(Filename).g$(DefaultLanguageSourceExtension) + $(Language) + $(RootNamespace) + $(RootNamespace).$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.').TrimEnd('.')) + %(Filename) + + + + 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/SponsorLink.targets b/src/SponsorLink/SponsorLink.targets new file mode 100644 index 0000000..de93845 --- /dev/null +++ b/src/SponsorLink/SponsorLink.targets @@ -0,0 +1,141 @@ + + + + + + + true + + true + + true + + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)SponsorLink/devlooped.pub.jwk')) + + + $(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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $([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)"', ' ') + + + + + + + + + + + + + + + + + + + \ 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..49143d9 --- /dev/null +++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs @@ -0,0 +1,138 @@ +// +#nullable enable +using System; +using System.Collections.Concurrent; +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 + { + get => 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", + ThisAssembly.Strings.Sponsor.Title, + ThisAssembly.Strings.Sponsor.MessageFormat, + "SponsorLink", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: ThisAssembly.Strings.Sponsor.Description, + helpLinkUri: "https://github.com/devlooped#sponsorlink", + "DoesNotSupportF1Help"); + + static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new( + $"{prefix}101", + ThisAssembly.Strings.Unknown.Title, + ThisAssembly.Strings.Unknown.MessageFormat, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ThisAssembly.Strings.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", + ThisAssembly.Strings.Expiring.Title, + ThisAssembly.Strings.Expiring.MessageFormat, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ThisAssembly.Strings.Expiring.Description(string.Join(" ", sponsorable)), + helpLinkUri: "https://github.com/devlooped#autosync", + "DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable); + + static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new( + $"{prefix}104", + ThisAssembly.Strings.Expired.Title, + ThisAssembly.Strings.Expired.MessageFormat, + "SponsorLink", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ThisAssembly.Strings.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/SponsorLink.cs b/src/SponsorLink/SponsorLink/SponsorLink.cs new file mode 100644 index 0000000..a5e5beb --- /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.jwk) || 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..4b00feb --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.csproj @@ -0,0 +1,46 @@ + + + + netstandard2.0 + SponsorLink + disable + false + + + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)devlooped.pub.jwk')) + + $(Product) + + $([System.Text.RegularExpressions.Regex]::Replace("$(FundingProduct)", "[^A-Z]", "")) + + 21 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SponsorLink/SponsorLink/SponsorLink.es.resx b/src/SponsorLink/SponsorLink/SponsorLink.es.resx new file mode 100644 index 0000000..9af3240 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.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 {links} y ejecutando posteriormente 'sponsors sync {spaced}'. + + + Por favor considere apoyar {0} patrocinando @{1} 🙏 + + + Estado de patrocinio desconocido + + + Funcionalidades exclusivas para patrocinadores pueden no estar disponibles. Ejecuta 'sponsors sync {spaced}' 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 'sponsors sync {spaced}' 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/SponsorLink.resx b/src/SponsorLink/SponsorLink/SponsorLink.resx new file mode 100644 index 0000000..3149ee0 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLink.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 {links} and running 'sponsors sync {spaced}' afterwards. + Unknown sponsor description + + + Please consider supporting {0} by sponsoring @{1} 🙏 + + + Unknown sponsor status + + + Sponsor-only features may be disabled. Please run 'sponsors sync {spaced}' 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 'sponsors sync {spaced}' 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/SponsorLinkAnalyzer.cs b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs new file mode 100644 index 0000000..2e97528 --- /dev/null +++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs @@ -0,0 +1,126 @@ +// +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Humanizer; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using static Devlooped.Sponsors.SponsorLink; +using static ThisAssembly.Constants; + +namespace Devlooped.Sponsors; + +/// +/// Links the sponsor status for the current compilation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] +public class SponsorLinkAnalyzer : DiagnosticAnalyzer +{ + static readonly int graceDays = int.Parse(Funding.Grace); + 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 => + { + if (Diagnostics.Pop(Funding.Product) is Diagnostic diagnostic) + { + ctx.ReportDiagnostic(diagnostic); + } + else + { + // This should never happen and would be a bug. + Debug.Assert(true, "We should have provided a diagnostic of some kind for " + Funding.Product); + // We'll report it as unknown as a fallback for now. + ctx.ReportDiagnostic(Diagnostic.Create(descriptors[SponsorStatus.Unknown], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)), + Funding.Product, Sponsorables.Keys.Humanize(ThisAssembly.Strings.Or))); + } + }); + } + }); +#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(ThisAssembly.Strings.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(graceDays) < 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/ThisAssembly.cs b/src/SponsorLink/SponsorLink/ThisAssembly.cs new file mode 100644 index 0000000..89f2316 --- /dev/null +++ b/src/SponsorLink/SponsorLink/ThisAssembly.cs @@ -0,0 +1,31 @@ +// +partial class ThisAssembly +{ + partial class Strings + { + partial class Unknown + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Unknown_Message"); + } + + partial class Expiring + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Expiring_Message"); + } + + partial class Expired + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Expired_Message"); + } + + partial class Grace + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Grace_Message"); + } + + partial class Sponsor + { + public static string MessageFormat => GetResourceManager("Devlooped.SponsorLink").GetString("Sponsor_Message"); + } + } +} \ 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..02b89f7 --- /dev/null +++ b/src/SponsorLink/SponsorLink/buildTransitive/Devlooped.Sponsors.targets @@ -0,0 +1,99 @@ + + + + + $([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/devlooped.pub.jwk b/src/SponsorLink/SponsorLink/devlooped.pub.jwk new file mode 100644 index 0000000..cdf45c2 --- /dev/null +++ b/src/SponsorLink/SponsorLink/devlooped.pub.jwk @@ -0,0 +1,5 @@ +{ + "e": "AQAB", + "kty": "RSA", + "n": "5inhv8QymaDBOihNi1eY-6-hcIB5qSONFZxbxxXAyOtxAdjFCPM-94gIZqM9CDrX3pyg1lTJfml_a_FZSU9dB1ii5mSX_mNHBFXn1_l_gi1ErdbkIF5YbW6oxWFxf3G5mwVXwnPfxHTyQdmWQ3YJR-A3EB4kaFwLqA6Ha5lb2ObGpMTQJNakD4oTAGDhqHMGhu6PupGq5ie4qZcQ7N8ANw8xH7nicTkbqEhQABHWOTmLBWq5f5F6RYGF8P7cl0IWl_w4YcIZkGm2vX2fi26F9F60cU1v13GZEVDTXpJ9kzvYeM9sYk6fWaoyY2jhE51qbv0B0u6hScZiLREtm3n7ClJbIGXhkUppFS2JlNaX3rgQ6t-4LK8gUTyLt3zDs2H8OZyCwlCpfmGmdsUMkm1xX6t2r-95U3zywynxoWZfjBCJf41leM9OMKYwNWZ6LQMyo83HWw1PBIrX4ZLClFwqBcSYsXDyT8_ZLd1cdYmPfmtllIXxZhLClwT5qbCWv73V" +} \ 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/Sample.cs b/src/SponsorLink/Tests/Sample.cs new file mode 100644 index 0000000..6249e62 --- /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..f753aad --- /dev/null +++ b/src/SponsorLink/Tests/Tests.csproj @@ -0,0 +1,42 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + %(GitRoot.FullPath) + + + + + + + + + + \ 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 diff --git a/src/nuget.config b/src/nuget.config deleted file mode 100644 index ef2b768..0000000 --- a/src/nuget.config +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -