diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d8ff904 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,139 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# All files +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# JSON files +[*.json] +indent_size = 2 + +# YAML files +[*.{yml,yaml}] +indent_size = 2 + +# Shell scripts +[*.sh] +end_of_line = lf + +# Markdown files +[*.md] +trim_trailing_whitespace = false + +# Dotnet code style settings +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# this. preferences +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +dotnet_style_readonly_field = true:warning + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +# CSharp code style settings +[*.cs] +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:silent + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion + +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false + +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2cbd30 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +env: + DOTNET_VERSION: '9.0.x' + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install Aspire workload + run: dotnet workload install aspire + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./**/coverage.cobertura.xml + fail_ci_if_error: false diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..c9d4666 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,52 @@ +name: "CodeQL" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: '0 0 * * 1' + +env: + DOTNET_VERSION: '9.0.x' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'csharp' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install Aspire workload + run: dotnet workload install aspire + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7d65bbe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,243 @@ +name: Release + +on: + push: + branches: [ main ] + workflow_dispatch: + +env: + DOTNET_VERSION: '9.0.x' + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + permissions: + contents: read + + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install Aspire workload + run: dotnet workload install aspire + + - name: Extract version from Directory.Build.props + id: version + run: | + VERSION=$(grep -oPm1 "(?<=)[^<]+" Directory.Build.props) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version from Directory.Build.props: $VERSION" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Pack NuGet packages + run: dotnet pack --configuration Release --no-build --output ./artifacts + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/*.nupkg + retention-days: 5 + + publish-nuget: + name: Publish to NuGet + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + permissions: + contents: read + + outputs: + published: ${{ steps.publish.outputs.published }} + version: ${{ needs.build.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download artifacts + uses: actions/download-artifact@v5 + with: + name: nuget-packages + path: ./artifacts + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Publish to NuGet + id: publish + continue-on-error: true + run: | + set +e + OUTPUT="" + PUBLISHED=false + + for package in ./artifacts/*.nupkg; do + echo "Publishing $package..." + RESULT=$(dotnet nuget push "$package" \ + --api-key ${{ secrets.NUGET_API_KEY }} \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate 2>&1) + EXIT_CODE=$? + echo "$RESULT" + OUTPUT="$OUTPUT$RESULT" + + if [ $EXIT_CODE -eq 0 ]; then + echo "Successfully published $package" + PUBLISHED=true + elif echo "$RESULT" | grep -q "already exists"; then + echo "Package already exists, skipping..." + else + echo "Failed to publish $package" + exit 1 + fi + done + + # Check if at least one package was successfully published + if [ "$PUBLISHED" = true ] || echo "$OUTPUT" | grep -q "Your package was pushed"; then + echo "published=true" >> $GITHUB_OUTPUT + echo "At least one package was successfully published" + else + echo "published=false" >> $GITHUB_OUTPUT + echo "No new packages were published (all already exist)" + fi + + create-release: + name: Create GitHub Release and Tag + needs: publish-nuget + runs-on: ubuntu-latest + if: needs.publish-nuget.outputs.published == 'true' + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download artifacts + uses: actions/download-artifact@v5 + with: + name: nuget-packages + path: ./artifacts + + - name: Create and push tag + id: create_tag + run: | + VERSION="${{ needs.publish-nuget.outputs.version }}" + TAG="v$VERSION" + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Check if tag already exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists" + echo "tag_exists=true" >> $GITHUB_OUTPUT + else + echo "Creating tag $TAG" + git tag -a "$TAG" -m "Release $VERSION" + git push origin "$TAG" + echo "tag_exists=false" >> $GITHUB_OUTPUT + fi + + - name: Get previous tag + id: prev_tag + run: | + CURRENT_TAG="v${{ needs.publish-nuget.outputs.version }}" + # Get the second most recent tag (previous to current) + PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -A1 "^$CURRENT_TAG$" | tail -n1 || echo "") + if [ "$PREVIOUS_TAG" = "$CURRENT_TAG" ] || [ -z "$PREVIOUS_TAG" ]; then + # If no previous tag found, get the most recent tag before current + PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^$CURRENT_TAG$" | head -n1 || echo "") + fi + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "Current tag: $CURRENT_TAG" + echo "Previous tag: $PREVIOUS_TAG" + + - name: Generate release notes + id: release_notes + run: | + VERSION="${{ needs.publish-nuget.outputs.version }}" + CURRENT_TAG="v$VERSION" + PREVIOUS_TAG="${{ steps.prev_tag.outputs.previous_tag }}" + + echo "# Release $VERSION" > release_notes.md + echo "" >> release_notes.md + echo "Released on $(date +'%Y-%m-%d')" >> release_notes.md + echo "" >> release_notes.md + + if [ -n "$PREVIOUS_TAG" ]; then + echo "## ๐Ÿ“‹ Changes since $PREVIOUS_TAG" >> release_notes.md + echo "" >> release_notes.md + + # Group commits by type + echo "### โœจ Features" >> release_notes.md + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^feat" --grep="^feature" >> release_notes.md || true + echo "" >> release_notes.md + + echo "### ๐Ÿ› Bug Fixes" >> release_notes.md + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^fix" --grep="^bugfix" >> release_notes.md || true + echo "" >> release_notes.md + + echo "### ๐Ÿ“š Documentation" >> release_notes.md + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^docs" --grep="^doc" >> release_notes.md || true + echo "" >> release_notes.md + + echo "### ๐Ÿ”ง Other Changes" >> release_notes.md + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --invert-grep --grep="^feat" --grep="^feature" --grep="^fix" --grep="^bugfix" --grep="^docs" --grep="^doc" >> release_notes.md || true + echo "" >> release_notes.md + else + echo "## ๐ŸŽ‰ Initial Release" >> release_notes.md + echo "" >> release_notes.md + echo "### Recent Changes" >> release_notes.md + git log --pretty=format:"- %s (%h)" --max-count=20 >> release_notes.md + echo "" >> release_notes.md + fi + + echo "" >> release_notes.md + echo "## ๐Ÿ“ฆ NuGet Packages" >> release_notes.md + echo "" >> release_notes.md + for package in ./artifacts/*.nupkg; do + PACKAGE_NAME=$(basename "$package" .nupkg) + # Extract package name without version + BASE_NAME=$(echo "$PACKAGE_NAME" | sed "s/\.$VERSION//") + echo "- [$BASE_NAME v$VERSION](https://www.nuget.org/packages/$BASE_NAME/$VERSION)" >> release_notes.md + done + + echo "" >> release_notes.md + echo "---" >> release_notes.md + echo "*This release was automatically created by GitHub Actions*" >> release_notes.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.publish-nuget.outputs.version }} + name: v${{ needs.publish-nuget.outputs.version }} + body_path: release_notes.md + draft: false + prerelease: false + files: ./artifacts/*.nupkg + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ce89292..2d69201 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,8 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Aspire +.aspire/ +*.dcplog +*.dcp.log diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..83b5a13 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,19 @@ + + + 1.0.0 + ManagedCode + ManagedCode + Copyright ยฉ ManagedCode $(Year) + https://github.com/managedcode/ProjectTemplate + https://github.com/managedcode/ProjectTemplate + git + MIT + false + README.md + aspire;template;dotnet + + + + + + diff --git a/ProjectTemplate.sln b/ProjectTemplate.sln new file mode 100644 index 0000000..adcd7be --- /dev/null +++ b/ProjectTemplate.sln @@ -0,0 +1,89 @@ +๏ปฟ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.0.0 +MinimumVisualStudioVersion = 17.8.0.0 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectTemplate.AppHost", "src\ProjectTemplate.AppHost\ProjectTemplate.AppHost.csproj", "{9E4100E0-5295-4C16-9AD6-924125924EEF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectTemplate.ServiceDefaults", "src\ProjectTemplate.ServiceDefaults\ProjectTemplate.ServiceDefaults.csproj", "{CA53EB05-24E9-4500-B9D4-75AA7EA202BA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectTemplate.Api", "src\ProjectTemplate.Api\ProjectTemplate.Api.csproj", "{0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectTemplate.Tests", "tests\ProjectTemplate.Tests\ProjectTemplate.Tests.csproj", "{B841A184-9D45-486B-A648-564CB9AB32CA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Debug|x64.Build.0 = Debug|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Debug|x86.Build.0 = Debug|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Release|Any CPU.Build.0 = Release|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Release|x64.ActiveCfg = Release|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Release|x64.Build.0 = Release|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Release|x86.ActiveCfg = Release|Any CPU + {9E4100E0-5295-4C16-9AD6-924125924EEF}.Release|x86.Build.0 = Release|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Debug|x64.ActiveCfg = Debug|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Debug|x64.Build.0 = Debug|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Debug|x86.ActiveCfg = Debug|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Debug|x86.Build.0 = Debug|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Release|Any CPU.Build.0 = Release|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Release|x64.ActiveCfg = Release|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Release|x64.Build.0 = Release|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Release|x86.ActiveCfg = Release|Any CPU + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA}.Release|x86.Build.0 = Release|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Debug|x64.Build.0 = Debug|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Debug|x86.ActiveCfg = Debug|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Debug|x86.Build.0 = Debug|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Release|Any CPU.Build.0 = Release|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Release|x64.ActiveCfg = Release|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Release|x64.Build.0 = Release|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Release|x86.ActiveCfg = Release|Any CPU + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3}.Release|x86.Build.0 = Release|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Debug|x64.Build.0 = Debug|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Debug|x86.Build.0 = Debug|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Release|Any CPU.Build.0 = Release|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Release|x64.ActiveCfg = Release|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Release|x64.Build.0 = Release|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Release|x86.ActiveCfg = Release|Any CPU + {B841A184-9D45-486B-A648-564CB9AB32CA}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9E4100E0-5295-4C16-9AD6-924125924EEF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {CA53EB05-24E9-4500-B9D4-75AA7EA202BA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {0EFD3B59-3FFE-4845-8EC2-1E55781B97A3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {B841A184-9D45-486B-A648-564CB9AB32CA} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F730FFF4-5526-4553-83E6-B4D1B185C24C} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index de789a5..b833365 100644 --- a/README.md +++ b/README.md @@ -1 +1,184 @@ -# ProjectTemplate \ No newline at end of file +# ProjectTemplate + +A .NET 9 project template with [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) orchestration, including a sample API with tests and automated CI/CD pipelines. + +## Features + +- โœจ **ASP.NET Core Web API** - Minimal API with weather forecast endpoint +- ๐Ÿš€ **.NET Aspire** - Modern cloud-native application orchestration +- ๐Ÿงช **xUnit Tests** - Integration tests using WebApplicationFactory +- ๐Ÿ“ฆ **Service Defaults** - Shared telemetry, service discovery, and resilience patterns +- ๐Ÿ”„ **GitHub Actions** - Automated CI/CD pipelines for testing, security analysis, and releases +- ๐Ÿ“Š **Code Coverage** - Integrated with Codecov for coverage tracking +- ๐Ÿ”’ **CodeQL Analysis** - Automated security scanning on every push + +## Project Structure + +``` +ProjectTemplate/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ ProjectTemplate.Api/ # ASP.NET Core Web API +โ”‚ โ”œโ”€โ”€ ProjectTemplate.AppHost/ # Aspire orchestration host +โ”‚ โ””โ”€โ”€ ProjectTemplate.ServiceDefaults/ # Shared configuration and services +โ”œโ”€โ”€ tests/ +โ”‚ โ””โ”€โ”€ ProjectTemplate.Tests/ # Integration tests +โ”œโ”€โ”€ .github/ +โ”‚ โ””โ”€โ”€ workflows/ +โ”‚ โ”œโ”€โ”€ ci.yml # Build and test on PR +โ”‚ โ”œโ”€โ”€ codeql-analysis.yml # Security analysis +โ”‚ โ””โ”€โ”€ release.yml # Release and publish to NuGet +โ””โ”€โ”€ Directory.Build.props # Shared build properties and versioning +``` + +## Getting Started + +### Prerequisites + +- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) +- [Docker Desktop](https://www.docker.com/products/docker-desktop) (for Aspire Dashboard) + +### Installation + +1. Install the .NET Aspire workload: + ```bash + dotnet workload install aspire + ``` + +2. Clone the repository: + ```bash + git clone https://github.com/your-username/your-repository.git + cd your-repository + ``` + +3. Restore dependencies: + ```bash + dotnet restore + ``` + +### Running the Application + +#### Using .NET Aspire AppHost (Recommended) + +This will start the API and open the Aspire Dashboard: + +```bash +dotnet run --project src/ProjectTemplate.AppHost +``` + +The Aspire Dashboard will be available at the URL shown in the console output (typically `http://localhost:15888`). + +#### Running the API Directly + +```bash +dotnet run --project src/ProjectTemplate.Api +``` + +The API will be available at: +- HTTP: `http://localhost:5000` +- HTTPS: `https://localhost:5001` + +### Running Tests + +```bash +dotnet test +``` + +### Building + +```bash +dotnet build --configuration Release +``` + +## API Endpoints + +### Weather Forecast +- **GET** `/weatherforecast` - Returns a 5-day weather forecast + +### Health & Metrics (via ServiceDefaults) +- **GET** `/health` - Health check endpoint +- **GET** `/alive` - Liveness probe +- **GET** `/metrics` - Prometheus metrics + +## Development + +### Adding a New Service + +1. Create a new project in the `src` folder +2. Reference `ProjectTemplate.ServiceDefaults` to get shared configurations +3. Add the project to the AppHost in `src/ProjectTemplate.AppHost/Program.cs`: + +```csharp +var myService = builder.AddProject("myservice"); +``` + +### Running Tests in Development + +The project includes two types of tests: + +1. **WebApplicationFactory Tests** - Run in any environment (CI/CD friendly) +2. **Aspire Integration Tests** - Require the DCP (Development Control Plane), skipped in CI + +## CI/CD Pipelines + +### CI Workflow (`.github/workflows/ci.yml`) +Runs on every pull request and push to main: +- Builds the solution +- Runs all tests +- Collects code coverage +- Uploads coverage to Codecov + +### CodeQL Workflow (`.github/workflows/codeql-analysis.yml`) +Runs on: +- Every push to main +- Every pull request +- Weekly schedule (Monday at midnight) + +Performs automated security scanning to detect vulnerabilities. + +### Release Workflow (`.github/workflows/release.yml`) +Runs on push to main: +- Builds and tests the solution +- Packs NuGet packages +- Publishes to NuGet.org (requires `NUGET_API_KEY` secret) +- Creates GitHub release with auto-generated release notes +- Tags the release + +## Configuration + +### Versioning + +Version is managed in `Directory.Build.props`: + +```xml +1.0.0 +``` + +Update this version to release new versions to NuGet. + +### GitHub Secrets + +To enable the full CI/CD pipeline, configure these secrets in your GitHub repository: + +> **Note:** These secrets are only required if you want to publish NuGet packages or track code coverage. The CI workflow will work without them for development and testing purposes. + +- `NUGET_API_KEY` - API key for publishing to NuGet.org (required only for publishing packages) + - Get your API key from [NuGet.org](https://www.nuget.org/account/apikeys) +- `CODECOV_TOKEN` - Token for uploading coverage to Codecov (optional, for coverage tracking) + - Get your token from [Codecov](https://codecov.io/) after connecting your repository + +## Technologies + +- [.NET 9](https://dotnet.microsoft.com/download/dotnet/9.0) +- [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) +- [ASP.NET Core](https://docs.microsoft.com/aspnet/core) +- [xUnit](https://xunit.net/) +- [OpenTelemetry](https://opentelemetry.io/) +- [GitHub Actions](https://github.com/features/actions) + +## License + +MIT + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. \ No newline at end of file diff --git a/src/ProjectTemplate.Api/Program.cs b/src/ProjectTemplate.Api/Program.cs new file mode 100644 index 0000000..7313183 --- /dev/null +++ b/src/ProjectTemplate.Api/Program.cs @@ -0,0 +1,49 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire client integrations. +builder.AddServiceDefaults(); + +// Add services to the container. +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast"); + +app.MapDefaultEndpoints(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} + +// Make the implicit Program class accessible to the test project +public partial class Program { } diff --git a/src/ProjectTemplate.Api/ProjectTemplate.Api.csproj b/src/ProjectTemplate.Api/ProjectTemplate.Api.csproj new file mode 100644 index 0000000..3b79e7a --- /dev/null +++ b/src/ProjectTemplate.Api/ProjectTemplate.Api.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/src/ProjectTemplate.Api/ProjectTemplate.Api.http b/src/ProjectTemplate.Api/ProjectTemplate.Api.http new file mode 100644 index 0000000..14128f1 --- /dev/null +++ b/src/ProjectTemplate.Api/ProjectTemplate.Api.http @@ -0,0 +1,6 @@ +@ProjectTemplate.Api_HostAddress = http://localhost:5213 + +GET {{ProjectTemplate.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/ProjectTemplate.Api/Properties/launchSettings.json b/src/ProjectTemplate.Api/Properties/launchSettings.json new file mode 100644 index 0000000..ffa6fc0 --- /dev/null +++ b/src/ProjectTemplate.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +๏ปฟ{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5213", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7099;http://localhost:5213", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/ProjectTemplate.Api/appsettings.Development.json b/src/ProjectTemplate.Api/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/ProjectTemplate.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/ProjectTemplate.Api/appsettings.json b/src/ProjectTemplate.Api/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/src/ProjectTemplate.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/ProjectTemplate.AppHost/Program.cs b/src/ProjectTemplate.AppHost/Program.cs new file mode 100644 index 0000000..e31fe8c --- /dev/null +++ b/src/ProjectTemplate.AppHost/Program.cs @@ -0,0 +1,5 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder.AddProject("api"); + +builder.Build().Run(); diff --git a/src/ProjectTemplate.AppHost/ProjectTemplate.AppHost.csproj b/src/ProjectTemplate.AppHost/ProjectTemplate.AppHost.csproj new file mode 100644 index 0000000..c21b646 --- /dev/null +++ b/src/ProjectTemplate.AppHost/ProjectTemplate.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + enable + enable + true + 7ea1821e-c42c-47eb-bc58-c37029efdcff + + + + + + + + + + + diff --git a/src/ProjectTemplate.AppHost/Properties/launchSettings.json b/src/ProjectTemplate.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..b4c2c4b --- /dev/null +++ b/src/ProjectTemplate.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17020;http://localhost:15132", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21080", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22117" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15132", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19055", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20062" + } + } + } +} diff --git a/src/ProjectTemplate.AppHost/appsettings.Development.json b/src/ProjectTemplate.AppHost/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/ProjectTemplate.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/ProjectTemplate.AppHost/appsettings.json b/src/ProjectTemplate.AppHost/appsettings.json new file mode 100644 index 0000000..2185f95 --- /dev/null +++ b/src/ProjectTemplate.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/ProjectTemplate.ServiceDefaults/Extensions.cs b/src/ProjectTemplate.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..e95afc3 --- /dev/null +++ b/src/ProjectTemplate.ServiceDefaults/Extensions.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/ProjectTemplate.ServiceDefaults/ProjectTemplate.ServiceDefaults.csproj b/src/ProjectTemplate.ServiceDefaults/ProjectTemplate.ServiceDefaults.csproj new file mode 100644 index 0000000..388f894 --- /dev/null +++ b/src/ProjectTemplate.ServiceDefaults/ProjectTemplate.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/tests/ProjectTemplate.Tests/IntegrationTest1.cs b/tests/ProjectTemplate.Tests/IntegrationTest1.cs new file mode 100644 index 0000000..6c68b33 --- /dev/null +++ b/tests/ProjectTemplate.Tests/IntegrationTest1.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace ProjectTemplate.Tests; + +public class IntegrationTest1 : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public IntegrationTest1(WebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetWeatherForecastReturnsOkStatusCode() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/weatherforecast"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} + +// Aspire integration test - only works in development environment with DCP +public class AspireIntegrationTest +{ + [Fact(Skip = "Requires DCP - only works in development environment")] + public async Task GetWeatherForecastReturnsOkStatusCode_Aspire() + { + // Arrange + var appHost = await DistributedApplicationTestingBuilder.CreateAsync(); + appHost.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.AddStandardResilienceHandler(); + }); + + await using var app = await appHost.BuildAsync(); + var resourceNotificationService = app.Services.GetRequiredService(); + await app.StartAsync(); + + // Act + var httpClient = app.CreateHttpClient("api"); + await resourceNotificationService.WaitForResourceAsync("api", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30)); + var response = await httpClient.GetAsync("/weatherforecast"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/tests/ProjectTemplate.Tests/ProjectTemplate.Tests.csproj b/tests/ProjectTemplate.Tests/ProjectTemplate.Tests.csproj new file mode 100644 index 0000000..8fea5bf --- /dev/null +++ b/tests/ProjectTemplate.Tests/ProjectTemplate.Tests.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + + +