diff --git a/.editorconfig b/.editorconfig index 6c43eef..7b0f7a6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,280 +1,290 @@ root = true -# Top-most EditorConfig file # All files [*] charset = utf-8 -end_of_line = crlf -insert_final_newline = true -indent_style = tab +end_of_line = lf +indent_style = space indent_size = 4 +insert_final_newline = true trim_trailing_whitespace = true -# Microsoft .NET properties -csharp_style_expression_bodied_constructors = false:warning -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion - -# ReSharper properties -resharper_csharp_wrap_lines = false -resharper_local_function_body = expression_body -dotnet_style_operator_placement_when_wrapping = beginning_of_line -tab_width = 4 -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_diagnostic.CA1707.severity = none -dotnet_style_allow_statement_immediately_after_block_experimental = true:silent - -# C# files -[*.cs] -# Use file-scoped namespaces -csharp_style_namespace_declarations = file_scoped:suggestion - -# Prefer `var` when type is apparent -csharp_style_var_when_type_is_apparent = true:suggestion -# Prefer explicit type when type is not apparent -csharp_style_var_elsewhere = true:suggestion - -# Prefer expression-bodied members -csharp_style_expression_bodied_methods = true:suggestion +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 -# Prefer 'this.' qualification for members -dotnet_style_qualification_for_field = true:suggestion -dotnet_style_qualification_for_property = true:suggestion -dotnet_style_qualification_for_method = false:suggestion - -# Analyzer severity: treat warnings as errors for analyzers -dotnet_analyzer_diagnostic.severity = suggestion - -# Example: StyleCop rule as error (if StyleCop is installed) -dotnet_diagnostic.sa1101.severity = error - -# Suppress a specific warning (example: unused variable) -dotnet_diagnostic.cs0168.severity = none - -# Allow underscores in test method names -dotnet_diagnostic.CA1707.severity = none +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 -# Require 'using' directives to be inside namespaces -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = true - -# Organize usings on save (supported in some editors) -csharp_indent_labels = one_less_than_current -csharp_using_directive_placement = outside_namespace:silent -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_braces = true:silent -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_top_level_statements = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_prefer_system_threading_lock = true:suggestion -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_var_for_built_in_types = true:suggestion -#### Core EditorConfig Options #### - -# Indentation and spacing -indent_style = space -tab_width = 4 - -# New line preferences -insert_final_newline = false +# JSON files +[*.json] +indent_size = 2 -#### .NET Code Actions #### +# YAML files +[*.{yml,yaml}] +indent_size = 2 -# Type members -dotnet_hide_advanced_members = false -dotnet_member_insertion_location = with_other_members_of_the_same_kind -dotnet_property_generation_behavior = prefer_throwing_properties - -# Symbol search -dotnet_search_reference_assemblies = true - -#### .NET Coding Conventions #### +# PowerShell files +# PowerShell uses CRLF to maintain compatibility with Windows and PowerShell conventions +# This overrides the global end_of_line = lf setting and aligns with .gitattributes line 14 +[*.ps1] +indent_size = 4 +end_of_line = crlf +charset = utf-8-bom -# Organize usings -dotnet_separate_import_directive_groups = false -file_header_template = - -# this. and Me. preferences -dotnet_style_qualification_for_event = false:warning -dotnet_style_qualification_for_field = false -dotnet_style_qualification_for_method = false:warning -dotnet_style_qualification_for_property = false:warning - -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true -dotnet_style_predefined_type_for_member_access = true - -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity -dotnet_style_parentheses_in_other_operators = never_if_unnecessary -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity - -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members - -# Expression-level preferences -dotnet_prefer_system_hash_code = true -dotnet_style_coalesce_expression = true -dotnet_style_collection_initializer = true -dotnet_style_explicit_tuple_names = true -dotnet_style_namespace_match_folder = true -dotnet_style_null_propagation = true -dotnet_style_object_initializer = true -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true -dotnet_style_prefer_collection_expression = when_types_loosely_match -dotnet_style_prefer_compound_assignment = true -dotnet_style_prefer_conditional_expression_over_assignment = true -dotnet_style_prefer_conditional_expression_over_return = true -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed -dotnet_style_prefer_inferred_anonymous_type_member_names = true -dotnet_style_prefer_inferred_tuple_names = true -dotnet_style_prefer_is_null_check_over_reference_equality_method = true -dotnet_style_prefer_simplified_boolean_expressions = true -dotnet_style_prefer_simplified_interpolation = true - -# Field preferences -dotnet_style_readonly_field = true - -# Parameter preferences -dotnet_code_quality_unused_parameters = all - -# Suppression preferences -dotnet_remove_unnecessary_suppression_exclusions = none +# C# files +[*.cs] -# New line preferences -dotnet_style_allow_multiple_blank_lines_experimental = true -dotnet_style_allow_statement_immediately_after_block_experimental = true - -#### C# Coding Conventions #### - -# var preferences -csharp_style_var_elsewhere = true:warning -csharp_style_var_for_built_in_types = true:warning -csharp_style_var_when_type_is_apparent = true:warning - -# Expression-bodied members -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_constructors = true:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_lambdas = true:silent -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_expression_bodied_methods = true:silent -csharp_style_expression_bodied_operators = true:silent -csharp_style_expression_bodied_properties = true:silent - -# Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true -csharp_style_pattern_matching_over_is_with_cast_check = true -csharp_style_prefer_extended_property_pattern = true -csharp_style_prefer_not_pattern = true -csharp_style_prefer_pattern_matching = true -csharp_style_prefer_switch_expression = true - -# Null-checking preferences -csharp_style_conditional_delegate_call = true - -# Modifier preferences -csharp_prefer_static_anonymous_function = true -csharp_prefer_static_local_function = true -csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async -csharp_style_prefer_readonly_struct = true -csharp_style_prefer_readonly_struct_member = true - -# Code-block preferences -csharp_prefer_braces = true:warning -csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_system_threading_lock = true:suggestion -csharp_style_namespace_declarations = file_scoped:warning -csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_primary_constructors = true:suggestion -csharp_style_prefer_top_level_statements = true:silent - -# Expression-level preferences -csharp_prefer_simple_default_expression = true -csharp_style_deconstructed_variable_declaration = true -csharp_style_implicit_object_creation_when_type_is_apparent = true -csharp_style_inlined_variable_declaration = true -csharp_style_prefer_index_operator = true -csharp_style_prefer_local_over_anonymous_function = true -csharp_style_prefer_null_check_over_type_check = true -csharp_style_prefer_range_operator = true -csharp_style_prefer_tuple_swap = true -csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion -csharp_style_prefer_utf8_string_literals = true -csharp_style_throw_expression = true -csharp_style_unused_value_assignment_preference = discard_variable -csharp_style_unused_value_expression_statement_preference = discard_variable - -# 'using' directive preferences -csharp_using_directive_placement = outside_namespace:silent +# SA0001 - Disable XML documentation file requirement +dotnet_diagnostic.SA0001.severity = none -# New line preferences -csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true -csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true -csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true -csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true -csharp_style_allow_embedded_statements_on_same_line_experimental = true +# .NET Code Analysis Rules +# Enable .NET analyzers with conservative defaults +dotnet_analyzer_diagnostic.severity = suggestion -#### C# Formatting Rules #### +# IDE (Code Style) Rules +dotnet_diagnostic.IDE0005.severity = suggestion # Remove unnecessary usings +# Allow var usage - modern C# style +dotnet_diagnostic.IDE0007.severity = none # Use var instead of explicit type +dotnet_diagnostic.IDE0008.severity = none # Use explicit type instead of var + +# CA (Code Analysis) Rules - Set defaults +dotnet_diagnostic.CA1000.severity = warning +dotnet_diagnostic.CA1001.severity = warning +dotnet_diagnostic.CA1010.severity = warning +dotnet_diagnostic.CA1016.severity = warning +dotnet_diagnostic.CA1063.severity = warning +dotnet_diagnostic.CA1849.severity = warning # Call async methods when in async method + +# AsyncFixer Rules (all 5 rules explicitly configured) +dotnet_diagnostic.AsyncFixer01.severity = error # Unnecessary async/await +dotnet_diagnostic.AsyncFixer02.severity = error # Blocking synchronous operations inside async methods +dotnet_diagnostic.AsyncFixer03.severity = warning # Fire-and-forget async void +dotnet_diagnostic.AsyncFixer04.severity = error # Fire-and-forget async call inside using block +dotnet_diagnostic.AsyncFixer05.severity = suggestion # Downcasting from Task to Task + +# VSTHRD (Visual Studio Threading) Rules - Common rules explicitly configured +dotnet_diagnostic.VSTHRD100.severity = warning # Avoid async void methods +dotnet_diagnostic.VSTHRD101.severity = warning # Avoid unsupported async delegates +dotnet_diagnostic.VSTHRD102.severity = warning # Implement internal logic asynchronously +dotnet_diagnostic.VSTHRD103.severity = warning # Call async methods when in async method +dotnet_diagnostic.VSTHRD104.severity = warning # Offer async option +dotnet_diagnostic.VSTHRD105.severity = warning # Avoid method overloads that assume TaskScheduler.Current +dotnet_diagnostic.VSTHRD106.severity = warning # Use InvokeAsync to raise async events +dotnet_diagnostic.VSTHRD107.severity = warning # Await Task within using expression +dotnet_diagnostic.VSTHRD108.severity = warning # Assert thread affinity unconditionally +dotnet_diagnostic.VSTHRD109.severity = warning # Switch instead of assert in async methods +dotnet_diagnostic.VSTHRD110.severity = warning # Observe result of async calls +dotnet_diagnostic.VSTHRD111.severity = none # ConfigureAwait - not needed in library code targeting modern .NET +dotnet_diagnostic.VSTHRD112.severity = warning # Implement System.IAsyncDisposable +dotnet_diagnostic.VSTHRD114.severity = warning # Avoid returning null from a Task-returning method +dotnet_diagnostic.VSTHRD200.severity = suggestion # Use Async naming convention + +# Roslynator Rules - Common rules explicitly configured +dotnet_diagnostic.RCS1001.severity = suggestion # Add braces +dotnet_diagnostic.RCS1036.severity = none # Remove unnecessary blank line +dotnet_diagnostic.RCS1037.severity = suggestion # Remove trailing white-space +dotnet_diagnostic.RCS1138.severity = warning # Add summary to documentation comment +dotnet_diagnostic.RCS1140.severity = warning # Add exception to documentation comment +dotnet_diagnostic.RCS1141.severity = suggestion # Add parameter to documentation comment +dotnet_diagnostic.RCS1163.severity = warning # Unused parameter +dotnet_diagnostic.RCS1175.severity = suggestion # Unused this parameter +dotnet_diagnostic.RCS1180.severity = suggestion # Inline lazy initialization +dotnet_diagnostic.RCS1181.severity = suggestion # Convert comment to documentation comment +dotnet_diagnostic.RCS1186.severity = suggestion # Use Regex instance instead of static method +dotnet_diagnostic.RCS1197.severity = suggestion # Optimize StringBuilder.Append/AppendLine call +dotnet_diagnostic.RCS1214.severity = suggestion # Unnecessary interpolated string +dotnet_diagnostic.RCS1227.severity = suggestion # Validate arguments correctly + +# Meziantou Analyzer Rules +dotnet_diagnostic.MA0001.severity = suggestion # StringComparison missing +dotnet_diagnostic.MA0002.severity = suggestion # IEqualityComparer missing +dotnet_diagnostic.MA0003.severity = warning # Add parameter name to improve readability +dotnet_diagnostic.MA0004.severity = suggestion # Use Task.ConfigureAwait(false) +dotnet_diagnostic.MA0006.severity = warning # Use String.Equals instead of equality operator +dotnet_diagnostic.MA0007.severity = suggestion # Add comma after the last value +dotnet_diagnostic.MA0011.severity = suggestion # IFormatProvider is missing +dotnet_diagnostic.MA0016.severity = suggestion # Prefer returning collection abstraction instead of implementation +dotnet_diagnostic.MA0025.severity = warning # Implement the functionality instead of throwing NotImplementedException +dotnet_diagnostic.MA0026.severity = suggestion # Fix TODO comment +dotnet_diagnostic.MA0028.severity = warning # Optimize StringBuilder usage +dotnet_diagnostic.MA0029.severity = warning # Combine LINQ methods +dotnet_diagnostic.MA0036.severity = suggestion # Make class static +dotnet_diagnostic.MA0038.severity = suggestion # Make method static +dotnet_diagnostic.MA0040.severity = warning # Flow the cancellation token +dotnet_diagnostic.MA0048.severity = warning # File name must match type name +dotnet_diagnostic.MA0051.severity = warning # Method is too long +dotnet_diagnostic.MA0053.severity = suggestion # Make class sealed +dotnet_diagnostic.MA0056.severity = suggestion # Do not call overridable members in constructor +dotnet_diagnostic.MA0073.severity = suggestion # Avoid comparison with bool constant +dotnet_diagnostic.MA0076.severity = suggestion # Do not use implicit culture-sensitive ToString in interpolated strings + +# SonarAnalyzer Rules +dotnet_diagnostic.S1118.severity = suggestion # Utility classes should not have public constructors +dotnet_diagnostic.S1135.severity = none # (Disabled: overlaps with MA0026 "Fix TODO comment") +dotnet_diagnostic.S1199.severity = warning # Nested code blocks should not be used +dotnet_diagnostic.S2223.severity = warning # Non-constant static fields should not be visible +dotnet_diagnostic.S2259.severity = warning # Null pointers should not be dereferenced +dotnet_diagnostic.S2583.severity = warning # Conditionally executed code should be reachable +dotnet_diagnostic.S2589.severity = warning # Boolean expressions should not be gratuitous +dotnet_diagnostic.S2696.severity = suggestion # Instance members should not write to "static" fields +dotnet_diagnostic.S2933.severity = warning # Fields that are only assigned in the constructor should be "readonly" +dotnet_diagnostic.S2934.severity = warning # Property assignments should not be made for "readonly" fields +dotnet_diagnostic.S3215.severity = suggestion # "interface" instances should not be cast to concrete types +dotnet_diagnostic.S3216.severity = suggestion # "ConfigureAwait(false)" should be used in library code (especially for .NET Framework 4.6.2 / .NET Standard 2.0 targets) +dotnet_diagnostic.S3218.severity = suggestion # Inner class members should not shadow outer class "static" or type members +dotnet_diagnostic.S3236.severity = warning # Caller information arguments should not be provided explicitly +dotnet_diagnostic.S3242.severity = suggestion # Method parameters should be declared with base types +dotnet_diagnostic.S3247.severity = warning # Duplicate casts should not be made +dotnet_diagnostic.S3253.severity = suggestion # Constructor and destructor declarations should not be redundant +dotnet_diagnostic.S3257.severity = warning # Declarations and initializations should be as concise as possible +dotnet_diagnostic.S3358.severity = warning # Ternary operators should not be nested +dotnet_diagnostic.S3400.severity = warning # Methods should not return constants +dotnet_diagnostic.S3441.severity = warning # Redundant property names should be omitted in anonymous classes +dotnet_diagnostic.S3442.severity = warning # "abstract" classes should not have "public" constructors +dotnet_diagnostic.S3443.severity = warning # Type should not be examined on "System.Type" instances +dotnet_diagnostic.S3449.severity = suggestion # Right operands of shift operators should be integers +dotnet_diagnostic.S3451.severity = warning # Classes should not have only "private" constructors +dotnet_diagnostic.S3604.severity = warning # Member initializer values should not be redundant +dotnet_diagnostic.S3776.severity = suggestion # Cognitive Complexity of methods should not be too high +dotnet_diagnostic.S3881.severity = warning # "IDisposable" should be implemented correctly +dotnet_diagnostic.S3897.severity = suggestion # Classes that provide "Equals()" should implement "IEquatable" +dotnet_diagnostic.S3898.severity = warning # Value types should implement "IEquatable" +dotnet_diagnostic.S3902.severity = warning # "Assembly.GetExecutingAssembly" should not be called +dotnet_diagnostic.S3903.severity = warning # Types should be defined in named namespaces +dotnet_diagnostic.S3904.severity = warning # Assemblies should have version information +dotnet_diagnostic.S3925.severity = warning # "ISerializable" should be implemented correctly +dotnet_diagnostic.S3926.severity = warning # Deserialization methods should be provided for "OptionalField" members +dotnet_diagnostic.S3927.severity = warning # Serialization event handlers should be implemented correctly +dotnet_diagnostic.S4049.severity = suggestion # Properties should be preferred +dotnet_diagnostic.S4056.severity = suggestion # Overloads with a "CultureInfo" or an "IFormatProvider" parameter should be used +dotnet_diagnostic.S4136.severity = warning # Method overloads should be grouped together + +# SecurityCodeScan Rules +dotnet_diagnostic.SCS0005.severity = warning # Weak random number generator +dotnet_diagnostic.SCS0006.severity = warning # Weak hash algorithm +dotnet_diagnostic.SCS0015.severity = warning # Hardcoded password +dotnet_diagnostic.SCS0016.severity = warning # Controller method is vulnerable to CSRF +dotnet_diagnostic.SCS0017.severity = warning # Request validation disabled +dotnet_diagnostic.SCS0018.severity = warning # Path traversal +dotnet_diagnostic.SCS0019.severity = warning # OutputCache conflict +dotnet_diagnostic.SCS0020.severity = warning # SQL injection via EF raw query +dotnet_diagnostic.SCS0026.severity = warning # SQL injection via EF FromSqlRaw +dotnet_diagnostic.SCS0029.severity = warning # Cross-Site Scripting (XSS) +dotnet_diagnostic.SCS0031.severity = warning # SQL injection via EF ExecuteSqlRaw + +# Performance-critical rules for library code +dotnet_diagnostic.CA1062.severity = warning # Validate arguments of public methods +dotnet_diagnostic.CA1508.severity = warning # Avoid dead conditional code +dotnet_diagnostic.CA1510.severity = none # Disabled for multi-targeting: recommends ArgumentNullException.ThrowIfNull (not available on net462/netstandard2.0) +dotnet_diagnostic.CA1810.severity = warning # Initialize static fields inline +dotnet_diagnostic.CA1822.severity = suggestion # Mark members as static +dotnet_diagnostic.CA1825.severity = warning # Avoid zero-length array allocations +dotnet_diagnostic.CA1826.severity = warning # Use property instead of Linq Enumerable method +dotnet_diagnostic.CA1827.severity = warning # Do not use Count/LongCount when Any can be used +dotnet_diagnostic.CA1828.severity = warning # Do not use CountAsync/LongCountAsync when AnyAsync can be used +dotnet_diagnostic.CA1829.severity = warning # Use Length/Count property instead of Enumerable.Count method +dotnet_diagnostic.CA1851.severity = warning # Possible multiple enumerations of IEnumerable collection + +# Async/IAsyncEnumerable specific rules (CRITICAL for this library) +dotnet_diagnostic.CA2007.severity = warning # ConfigureAwait - enforce usage in library async code +dotnet_diagnostic.CA2012.severity = error # Use ValueTasks correctly +dotnet_diagnostic.CA2016.severity = warning # Forward CancellationToken parameter + +# Banned API Analyzer (RS0030) - Enforce async-first best practices +dotnet_diagnostic.RS0030.severity = error # Using banned API - treat as error # New line preferences -csharp_new_line_before_catch = true +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_anonymous_types = true csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all +csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_between_query_expression_clauses = true # Indentation preferences -csharp_indent_block_contents = true -csharp_indent_braces = false csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = true csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents_when_block = false # Space preferences csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +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_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false csharp_space_after_comma = true +csharp_space_before_comma = false csharp_space_after_dot = false -csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_before_dot = false csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after +csharp_space_before_semicolon_in_for_statement = false csharp_space_around_declaration_statements = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_declaration_name_and_open_parenthesis = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false csharp_space_between_square_brackets = false +# Organize usings +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Code style rules +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +dotnet_style_readonly_field = true:suggestion +csharp_prefer_braces = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion + +# var preferences - prefer 'var' usage for modern C# style +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_elsewhere = true:silent + +# Expression preferences +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion + +# Pattern matching +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 +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion + # Wrapping preferences +# Preserve manual line breaks and allow flexible parameter formatting +csharp_preserve_single_line_statements = false csharp_preserve_single_line_blocks = true -csharp_preserve_single_line_statements = true - -#### Naming styles #### -# Naming rules +# Line length guidance (not enforced by dotnet format, but used by some IDEs) +csharp_max_line_length = 120 +# Naming conventions dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i @@ -288,39 +298,144 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_m dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case # Symbol specifications - dotnet_naming_symbols.interface.applicable_kinds = interface dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interface.required_modifiers = dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = # Naming styles +dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = dotnet_naming_style.begins_with_i.capitalization = pascal_case -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_auto_properties = true:silent -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:silent -dotnet_diagnostic.ca1707.severity = suggestion -dotnet_style_qualification_for_event = false:silent +# Disable file header requirements +dotnet_diagnostic.SA1633.severity = none +dotnet_diagnostic.SA1634.severity = none +dotnet_diagnostic.SA1635.severity = none +dotnet_diagnostic.SA1636.severity = none +dotnet_diagnostic.SA1637.severity = none +dotnet_diagnostic.SA1638.severity = none +dotnet_diagnostic.SA1639.severity = none +dotnet_diagnostic.SA1640.severity = none +dotnet_diagnostic.SA1641.severity = none +file_header_template = unset + +# Disable overly strict formatting rules globally +dotnet_diagnostic.SA1505.severity = none +dotnet_diagnostic.SA1508.severity = none +dotnet_diagnostic.SA1110.severity = none +dotnet_diagnostic.SA1009.severity = none +dotnet_diagnostic.SA1111.severity = none +dotnet_diagnostic.SA1500.severity = none +dotnet_diagnostic.SA1101.severity = none + +# Naming - error by default (strict) +dotnet_diagnostic.SA1300.severity = error +dotnet_diagnostic.IDE1006.severity = error +dotnet_diagnostic.CA1707.severity = error + +# Source code - strict rules +[src/**/*.cs] +# Documentation required +dotnet_diagnostic.SA1600.severity = warning +dotnet_diagnostic.SA1601.severity = warning +dotnet_diagnostic.SA1602.severity = warning + +# Library code should preserve synchronization context by default +# Consumers decide whether to use ConfigureAwait(false) when calling library methods +dotnet_diagnostic.MA0004.severity = none # Meziantou: Use ConfigureAwait +dotnet_diagnostic.S3216.severity = none # SonarAnalyzer: ConfigureAwait +dotnet_diagnostic.CA2007.severity = none # .NET Analyzer: Use ConfigureAwait + +# Test projects - relaxed naming, no doc requirements +[tests/**/*.cs] +# Allow Test_Method_Names_With_Underscores +dotnet_diagnostic.SA1300.severity = none +dotnet_diagnostic.IDE1006.severity = none +dotnet_diagnostic.CA1707.severity = none + +# Allow synchronous CancellationTokenSource.Cancel() in tests +dotnet_diagnostic.CA1849.severity = none + +# Relax async/await analyzer rules for tests +dotnet_diagnostic.AsyncFixer01.severity = none # Allow unnecessary async/await in tests +dotnet_diagnostic.AsyncFixer02.severity = none # Allow synchronous blocking in tests +dotnet_diagnostic.AsyncFixer05.severity = none # Allow downcasting in tests +dotnet_diagnostic.IDE0058.severity = none # Allow unused expression values in tests +dotnet_diagnostic.VSTHRD103.severity = none # Allow calling sync methods when async alternatives exist in tests +dotnet_diagnostic.VSTHRD102.severity = none # Allow synchronous implementation in tests +dotnet_diagnostic.VSTHRD104.severity = none # Allow missing async options in tests +dotnet_diagnostic.VSTHRD107.severity = none # Allow Task in using without await in tests +dotnet_diagnostic.VSTHRD114.severity = none # Allow returning null from Task methods in tests + +# Banned API Analyzer - Just warn in tests (allow for testing purposes) +dotnet_diagnostic.RS0030.severity = warning # Using banned API - warn instead of error in tests + +# Meziantou - Relax in tests +dotnet_diagnostic.MA0004.severity = none # ConfigureAwait not needed in tests +dotnet_diagnostic.MA0011.severity = none # IFormatProvider not critical in tests +dotnet_diagnostic.MA0026.severity = none # TODO comments OK in tests +dotnet_diagnostic.MA0040.severity = none # CancellationToken flow not critical in tests +dotnet_diagnostic.MA0048.severity = none # File name matching not critical in tests +dotnet_diagnostic.MA0051.severity = none # Method length OK in tests + +# SonarAnalyzer - Relax in tests +dotnet_diagnostic.S1118.severity = none # Utility class constructors OK in tests +dotnet_diagnostic.S1135.severity = none # TODO tags OK in tests +dotnet_diagnostic.S3216.severity = none # ConfigureAwait not needed in tests +dotnet_diagnostic.S3776.severity = none # Complexity OK in tests +dotnet_diagnostic.S4049.severity = none # Properties vs methods flexibility in tests + +# .NET Analyzer - Relax in tests +dotnet_diagnostic.CA2007.severity = none # ConfigureAwait not needed in tests + +# SecurityCodeScan - Relax in tests (but keep serious ones) +dotnet_diagnostic.SCS0005.severity = suggestion # Weak random OK for test data + +# No documentation required for tests +dotnet_diagnostic.SA1600.severity = none +dotnet_diagnostic.SA1601.severity = none +dotnet_diagnostic.SA1602.severity = none + +# Benchmark projects - relaxed naming, no doc requirements +[benchmarks/**/*.cs] +# Allow Benchmark_Method_Names +dotnet_diagnostic.SA1300.severity = none +dotnet_diagnostic.IDE1006.severity = none +dotnet_diagnostic.CA1707.severity = none + +# Relax async/await analyzer rules for benchmarks +dotnet_diagnostic.AsyncFixer01.severity = none # Allow unnecessary async/await in benchmarks + +# ConfigureAwait not needed in benchmarks +dotnet_diagnostic.MA0004.severity = none # Meziantou: Use ConfigureAwait +dotnet_diagnostic.S3216.severity = none # SonarAnalyzer: ConfigureAwait +dotnet_diagnostic.CA2007.severity = none # .NET Analyzer: Use ConfigureAwait + +# Banned API Analyzer - Just warn in benchmarks (allow for benchmarking purposes) +dotnet_diagnostic.RS0030.severity = warning # Using banned API - warn instead of error in benchmarks + +# No documentation required for benchmarks +dotnet_diagnostic.SA1600.severity = none +dotnet_diagnostic.SA1601.severity = none +dotnet_diagnostic.SA1602.severity = none + +# Example projects - relaxed naming, docs encouraged +[examples/**/*.cs] +# Allow Example_Method_Names +dotnet_diagnostic.SA1300.severity = none +dotnet_diagnostic.IDE1006.severity = none +dotnet_diagnostic.CA1707.severity = none + +# Documentation helpful but not required +dotnet_diagnostic.SA1600.severity = suggestion +dotnet_diagnostic.SA1601.severity = suggestion +dotnet_diagnostic.SA1602.severity = suggestion -dotnet_diagnostic.ide0051.severity = none +# Banned API Analyzer - Allow in examples for demonstration purposes +dotnet_diagnostic.RS0030.severity = none # Allow banned APIs in examples for demonstration diff --git a/.github/workflows/create-labels.yaml b/.github/workflows/create-labels.yaml deleted file mode 100644 index ce661f5..0000000 --- a/.github/workflows/create-labels.yaml +++ /dev/null @@ -1,86 +0,0 @@ -name: Create Dependabot Security and Dependencies Labels -on: - workflow_dispatch: - -jobs: - create-labels: - permissions: - issues: write - runs-on: ubuntu-latest - steps: - - name: Create "dependabot - security" label - uses: actions/github-script@v6 - with: - script: | - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: "dependabot - security", - color: "b60205" - }); - } catch (error) { - if (error.status === 422 && error.response?.data?.errors?.[0]?.code === 'already_exists') { - console.log('Label "dependabot - security" already exists, skipping creation'); - } else { - console.error('Failed to create label "dependabot - security":', error.message); - throw error; - } - } - - name: Create "dependabot-dependencies" label - uses: actions/github-script@v6 - with: - script: | - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: "dependabot-dependencies", - color: "d93f0b" - }); - } catch (error) { - if (error.status === 422 && error.response?.data?.errors?.[0]?.code === 'already_exists') { - console.log('Label "dependabot-dependencies" already exists, skipping creation'); - } else { - console.error('Failed to create label "dependabot-dependencies":', error.message); - throw error; - } - } - - name: Create "dependencies" label - uses: actions/github-script@v6 - with: - script: | - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: "dependencies", - color: "0366d6" - }); - } catch (error) { - if (error.status === 422 && error.response?.data?.errors?.[0]?.code === 'already_exists') { - console.log('Label "dependencies" already exists, skipping creation'); - } else { - console.error('Failed to create label "dependencies":', error.message); - throw error; - } - } - - name: Create "dotnet" label - uses: actions/github-script@v6 - with: - script: | - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: "dotnet", - color: "512bd4" - }); - } catch (error) { - if (error.status === 422 && error.response?.data?.errors?.[0]?.code === 'already_exists') { - console.log('Label "dotnet" already exists, skipping creation'); - } else { - console.error('Failed to create label "dotnet":', error.message); - throw error; - } - } diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 22504c1..b716439 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -126,19 +126,84 @@ jobs: # Sorting descending by Stable places stable (1) before prerelease (0) of the same version. $stable = if ([string]::IsNullOrEmpty($Matches['prerelease'])) { 1 } else { 0 } [PSCustomObject]@{ - Tag = $t - Major = [int]$Matches['major'] - Minor = [int]$Matches['minor'] - Patch = [int]$Matches['patch'] - Stable = $stable + Tag = $t + Major = [int]$Matches['major'] + Minor = [int]$Matches['minor'] + Patch = [int]$Matches['patch'] + Stable = $stable + PreRelease = $Matches['prerelease'] } } } - # Sort descending by SemVer components so newest stable version comes first - $orderedTags = $taggedVersions | - Sort-Object -Property Major, Minor, Patch, Stable -Descending | - Select-Object -ExpandProperty Tag + # Sort descending by full SemVer precedence (Major, Minor, Patch, Stable, PreRelease) + # Convert to a mutable list so we can use a custom comparison for proper SemVer prerelease ordering. + $tagList = [System.Collections.Generic.List[object]]::new() + foreach ($item in $taggedVersions) { + [void]$tagList.Add($item) + } + + $comparison = [System.Comparison[object]]{ + param($a, $b) + + # Compare Major, Minor, Patch (descending) + if ($a.Major -ne $b.Major) { return [Math]::Sign($b.Major - $a.Major) } + if ($a.Minor -ne $b.Minor) { return [Math]::Sign($b.Minor - $a.Minor) } + if ($a.Patch -ne $b.Patch) { return [Math]::Sign($b.Patch - $a.Patch) } + + # Compare Stable flag (descending: stable=1 > prerelease=0) + if ($a.Stable -ne $b.Stable) { return [Math]::Sign($b.Stable - $a.Stable) } + + # At this point, Major/Minor/Patch/Stable are equal. + # If both are stable (no prerelease), they are equal for our purposes. + $aPre = [string]$a.PreRelease + $bPre = [string]$b.PreRelease + + if ([string]::IsNullOrEmpty($aPre) -and [string]::IsNullOrEmpty($bPre)) { return 0 } + + # Both should be prereleases when Stable is 0, but handle any unexpected cases gracefully. + if ([string]::IsNullOrEmpty($aPre) -and -not [string]::IsNullOrEmpty($bPre)) { return -1 } + if (-not [string]::IsNullOrEmpty($aPre) -and [string]::IsNullOrEmpty($bPre)) { return 1 } + + $aIds = $aPre -split '\.' + $bIds = $bPre -split '\.' + $maxLen = [Math]::Max($aIds.Length, $bIds.Length) + + for ($i = 0; $i -lt $maxLen; $i++) { + if ($i -ge $aIds.Length) { return -1 } # a has fewer identifiers -> lower precedence + if ($i -ge $bIds.Length) { return 1 } # b has fewer identifiers -> lower precedence + + $aId = $aIds[$i] + $bId = $bIds[$i] + + $aIsNum = [int]::TryParse($aId, [ref]([int]$null)) + $bIsNum = [int]::TryParse($bId, [ref]([int]$null)) + + if ($aIsNum -and $bIsNum) { + $aVal = [int]$aId + $bVal = [int]$bId + if ($aVal -ne $bVal) { return [Math]::Sign($bVal - $aVal) } + } + elseif ($aIsNum -and -not $bIsNum) { + # Numeric identifiers have lower precedence than non-numeric. + return 1 + } + elseif (-not $aIsNum -and $bIsNum) { + return -1 + } + else { + $cmp = [string]::CompareOrdinal($aId, $bId) + if ($cmp -ne 0) { return -$cmp } + } + } + + # All identifiers equal + return 0 + } + + $tagList.Sort($comparison) + # We implemented comparison for descending SemVer order directly in the comparison. + $orderedTags = $tagList | Select-Object -ExpandProperty Tag # Build the base path for this GitHub Pages project site: // # GITHUB_REPOSITORY is "owner/repo"; we need just the repo name. diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index aff0342..5939209 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -1,27 +1,41 @@ # Sequential PR validation workflow with coverage gating # Stage 1: Linux tests with 90% coverage requirement -# Stage 2: Windows and macOS tests (only if Linux passes) -# Stage 3: .NET Framework 4.x tests on Windows (only if Stage 2 passes) +# Stage 2: Windows .NET (5.0-10.0) and .NET Framework (4.6.2-4.8.1) tests (only if Linux passes) +# Stage 3: macOS tests (only if Stage 2 passes) +# +# SECURITY NOTE: +# - Uses pull_request_target to run workflow from the trusted main branch, not from the PR branch +# - This prevents malicious workflow YAML changes in untrusted PR branches from taking effect +# - All checkout steps use PR refs (refs/pull/*/head) to check out PR code from the base repo +# - After checkout, configuration files (.editorconfig, BannedSymbols.txt, etc.) are fetched from +# the main branch to prevent malicious PRs from disabling analyzers or bypassing code quality checks +# - If a PR changes any of these protected configuration files, CI explicitly fails with instructions +# for a maintainer to manually review and verify the changes before merging +# - persist-credentials: false prevents the checkout token from being written to git config for subsequent git commands +# (it does NOT, by itself, prevent steps from accessing github.token / GITHUB_TOKEN if you explicitly expose it) +# - After checkout, configuration files (.editorconfig, BannedSymbols.txt, etc.) are fetched from +# the main branch to prevent malicious PRs from disabling analyzers or bypassing code quality checks +# - Default GITHUB_TOKEN permissions are restricted to read-only repository contents to limit impact if exposed -name: PR Checks v2 (Gated) +name: PR Checks v3 (Gated) permissions: contents: read + +env: + CODECOV_MINIMUM: 90 on: pull_request_target: # Runs from the main branch, not from PR branch branches: - main - - develop - paths-ignore: - - '**.md' - - 'docs/**' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + # ============================================================================ # SECRETS SCAN: Detect leaked credentials before merge # ============================================================================ secrets-scan: @@ -37,18 +51,147 @@ jobs: fetch-depth: 0 - name: Run gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # gitleaks-action@v2 does not support pull_request_target, so invoke the CLI directly + run: | + GITLEAKS_VERSION="8.24.0" + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar xz -C /usr/local/bin gitleaks + gitleaks detect --source . --verbose --redact + shell: bash # ============================================================================ + # DETECTION: Check if .csproj files exist + # ============================================================================ + detect-projects: + name: "Detect .NET Projects" + runs-on: ubuntu-latest + if: github.repository != 'Chris-Wolfgang/repo-template' + outputs: + has-projects: ${{ steps.check-projects.outputs.has-projects }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + persist-credentials: false + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" + + - name: Detect protected configuration file changes + run: | + echo "Checking for changes to protected configuration files in this PR..." + + # Verify main-branch ref is available (it was fetched in the previous step) + if ! git cat-file -e main-branch 2>/dev/null; then + echo "❌ main-branch ref not found - cannot detect configuration file changes" + exit 1 + fi + + changed_files=() + + # Check exact file matches against main branch git objects + # 2>/dev/null suppresses output when a file doesn't exist in one ref (new/deleted file), + # which git diff handles correctly via its exit code + exact_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + ) + + for config_file in "${exact_files[@]}"; do + if ! git diff --quiet main-branch HEAD -- "$config_file" 2>/dev/null; then + changed_files+=("$config_file") + fi + done + + # Check .globalconfig and .ruleset files using the same git diff approach + # --diff-filter=AMRC: Added, Modified, Renamed, Copied (excludes Deleted) + while IFS= read -r file; do + changed_files+=("$file") + done < <(git diff --name-only --diff-filter=AMRC main-branch HEAD 2>/dev/null | grep -E '\.(globalconfig|ruleset)$' || true) + + if [ ${#changed_files[@]} -gt 0 ]; then + echo "" + echo "⚠️ PROTECTED CONFIGURATION FILES CHANGED IN THIS PR:" + for file in "${changed_files[@]}"; do + echo " - $file" + done + echo "" + echo "❌ CI uses the main branch version of these files to prevent security bypasses." + echo " The PR's changes to these files were NOT tested by CI." + echo " A maintainer must manually review and verify these changes before merging." + echo "" + echo "To proceed, a maintainer should:" + echo " 1. Review the configuration changes in this PR carefully" + echo " 2. Test the changes locally to confirm they work correctly" + echo " 3. Merge with awareness that CI did not validate these configuration changes" + exit 1 + else + echo "✅ No protected configuration files changed - CI fully validates this PR" + fi + + - name: Check for .NET project files + id: check-projects + run: | + if git ls-files '*.csproj' '*.vbproj' '*.fsproj' | grep -q .; then + echo "has-projects=true" >> $GITHUB_OUTPUT + echo "✅ Found .NET project files - .NET build and test jobs will run" + else + echo "has-projects=false" >> $GITHUB_OUTPUT + echo "ℹ️ No .NET project files found - skipping .NET build and test jobs" + fi + # ============================================================================ # STAGE 1: Linux - .NET Core/5+ Tests with Coverage Gate # ============================================================================ test-linux-core: name: "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" runs-on: ubuntu-latest - if: github.repository != 'Chris-Wolfgang/repo-template' + needs: detect-projects + if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' steps: - name: Checkout code @@ -56,17 +199,107 @@ jobs: with: ref: refs/pull/${{ github.event.pull_request.number }}/head persist-credentials: false + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." - # Fix for .NET 5.0 on Ubuntu 22.04+ - install libssl1.1 + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" + + # Fix for .NET 5.0 on Ubuntu 22.04+ - install libssl1.1 from the focal-security + # repository so APT verifies the package via GPG instead of a plain wget download. - name: Install OpenSSL 1.1 for .NET 5.0 run: | - wget http://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb - sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb + echo "deb https://security.ubuntu.com/ubuntu focal-security main" | sudo tee /etc/apt/sources.list.d/focal-security.list + sudo apt-get update -q + sudo apt-get install --yes libssl1.1 + sudo rm /etc/apt/sources.list.d/focal-security.list - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: | + 3.1.x 5.0.x 6.0.x 7.0.x @@ -74,44 +307,94 @@ jobs: 9.0.x 10.0.x - - name: Restore and build (exclude .NET Framework 4.x projects) + - name: Restore and build (exclude .NET Framework-only projects) run: | - echo "Finding all projects in solution..." + echo "Finding .NET project files in repository (via find command)..." - # Get list of all projects from solution file - if [ -f "*.sln" ]; then - sln_file=$(ls *.sln | head -n 1) - echo "Using solution file: $sln_file" - fi + # Filter out projects that ONLY target .NET Framework 4.x + # Multi-targeting projects (e.g., net8.0;net48) will be INCLUDED + projects=() + project_found=false - # Find all .csproj, .vbproj, and .fsproj files - # Exclude those with 'dotnet4' or 'DotNet4' in the path (case-insensitive) - projects=$(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) | grep -iv "dotnet4") + while IFS= read -r -d '' proj; do + project_found=true + # Check if project has any .NET 5+ target framework + # Look for: net5.0, net6.0, net7.0, net8.0, net9.0, net10.0, or netcoreapp, netstandard + # Normalize line endings to handle multi-line / elements + if tr -d '\n\r' < "$proj" | grep -qE '.*(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp|netstandard)'; then + projects+=("$proj") + echo "✓ Including: $proj (has .NET 5+ or .NET Core target)" + else + echo "⊘ Excluding: $proj (Framework-only, incompatible with Linux)" + fi + done < <(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) + + if [ "$project_found" = false ]; then + echo "❌ No .NET projects found." + echo "This should not occur as detect-projects already verified project existence." + exit 1 + fi - if [ -z "$projects" ]; then - echo "No projects found!" + if [ ${#projects[@]} -eq 0 ]; then + echo "❌ No compatible .NET projects found." + echo "All projects target only .NET Framework 4.x, which is incompatible with Linux." exit 1 fi + echo "" echo "==========================================" - echo "Projects to build (excluding .NET Framework 4.x):" + echo "Projects to build:" echo "==========================================" - echo "$projects" + printf '%s\n' "${projects[@]}" echo "" # Restore each project echo "Restoring projects..." - for proj in $projects; do + for proj in "${projects[@]}"; do echo "Restoring: $proj" dotnet restore "$proj" || exit 1 done echo "" echo "Building projects..." - # Build each project - for proj in $projects; do + # Build each project, handling multi-targeting projects + # For multi-targeting projects, build only Linux-compatible frameworks (.NET 5.0+, .NET Core, .NET Standard) + for proj in "${projects[@]}"; do echo "Building: $proj" - dotnet build "$proj" --no-restore --configuration Release || exit 1 + + # Extract target frameworks via MSBuild property evaluation. + # This handles multi-line XML, conditional property groups, + # and TFMs inherited from Directory.Build.props — all of which break grep-based parsing. + # Falls back from (multiple) to (single). + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true) + + if [ -z "$frameworks" ]; then + echo "⚠️ No Linux-compatible frameworks found in $proj" + continue + fi + + # Check if this is a multi-targeting project + framework_count=$(echo "$frameworks" | wc -l) + + if [ "$framework_count" -eq 1 ]; then + # Single target framework - build normally + echo " Target framework: $frameworks" + dotnet build "$proj" --no-restore --configuration Release || exit 1 + else + # Multi-targeting project - build each compatible framework separately + echo " Target frameworks (multi-targeting): $(echo "$frameworks" | tr '\n' ' ')" + while IFS= read -r fw; do + [ -z "$fw" ] && continue + echo " Building framework: $fw" + dotnet build "$proj" --no-restore --configuration Release --framework "$fw" || exit 1 + done <<< "$frameworks" + fi done echo "" @@ -119,29 +402,56 @@ jobs: - name: Run tests with coverage (.NET Core 5.0 - 10.0) run: | - # Find all test projects - test_projects=$(find ./tests -type f -name "*.csproj") - - if [ -z "$test_projects" ]; then - echo "❌ No test projects found in ./tests directory!" - exit 1 + # Find all test projects (C#, VB.NET, F#). + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + # The downstream coverage steps already handle the no-coverage-files case. + if [ ! -d ./tests ]; then + echo "ℹ️ No ./tests directory — skipping test stage." + exit 0 + fi + + mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) + + if [ ${#test_projects[@]} -eq 0 ]; then + echo "ℹ️ No test projects found under ./tests — skipping test stage." + exit 0 fi echo "==========================================" echo "Found test projects:" echo "==========================================" - echo "$test_projects" + printf '%s\n' "${test_projects[@]}" echo "" - # Test each framework individually to ensure all are tested - frameworks=(net5.0 net6.0 net7.0 net8.0 net9.0 net10.0) - - for test_proj in $test_projects; do + for test_proj in "${test_projects[@]}"; do echo "==========================================" echo "Testing project: $test_proj" echo "==========================================" - for fw in "${frameworks[@]}"; do + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance — both break grep-based parsing). + # Falls back from (multiple) to (single). + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true) + + if [ -z "$frameworks" ]; then + echo "⊘ Skipping: No compatible .NET 5.0-10.0 target frameworks found" + echo "" + continue + fi + + echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')" + echo "" + + # Test each framework that the project actually targets + while IFS= read -r fw; do + [ -z "$fw" ] && continue echo "Testing framework: $fw" dotnet test "$test_proj" \ @@ -150,14 +460,27 @@ jobs: --collect:"XPlat Code Coverage" \ --results-directory "./TestResults" \ --logger "console;verbosity=minimal" || exit 1 - done + done <<< "$frameworks" echo "" done + - name: Check for coverage files + id: check-coverage + run: | + if find TestResults -type f -name "coverage.cobertura.xml" 2>/dev/null | grep -q .; then + echo "has-coverage=true" >> $GITHUB_OUTPUT + echo "✅ Coverage files found" + else + echo "has-coverage=false" >> $GITHUB_OUTPUT + echo "ℹ️ No coverage files found - skipping coverage report generation" + fi + - name: Install ReportGenerator + if: steps.check-coverage.outputs.has-coverage == 'true' run: dotnet tool install -g dotnet-reportgenerator-globaltool - name: Generate coverage report + if: steps.check-coverage.outputs.has-coverage == 'true' run: | reportgenerator \ -reports:"TestResults/**/coverage.cobertura.xml" \ @@ -165,6 +488,7 @@ jobs: -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" - name: Enforce 90% coverage threshold + if: steps.check-coverage.outputs.has-coverage == 'true' run: | if [ ! -f CoverageReport/Summary.txt ]; then echo "❌ Coverage report not generated!" @@ -176,7 +500,7 @@ jobs: echo "" failed_projects="" - threshold=90 + threshold=${CODECOV_MINIMUM:-90} while read -r line; do # Match lines with module names and percentages @@ -231,13 +555,13 @@ jobs: tests/**/bin/Release # ============================================================================ - # STAGE 2: Windows & macOS - .NET Core/5+ Tests (Gated by Stage 1) + # STAGE 2: Windows - All .NET Tests (Gated by Stage 1) # ============================================================================ - test-windows-core: - name: "Stage 2a: Windows Tests (.NET 5.0-10.0)" + test-windows: + name: "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" runs-on: windows-latest - needs: test-linux-core - if: github.repository != 'Chris-Wolfgang/repo-template' + needs: [detect-projects, test-linux-core] + if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' steps: - name: Checkout code @@ -245,11 +569,102 @@ jobs: with: ref: refs/pull/${{ github.event.pull_request.number }}/head persist-credentials: false + + - name: Fetch trusted configuration files from main branch + shell: pwsh + run: | + Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + $configFiles = @( + ".editorconfig", + "Directory.Build.props", + "Directory.Build.targets", + "BannedSymbols.txt" + ) + + # Copy each configuration file from main branch if it exists + foreach ($configFile in $configFiles) { + # Check if file exists in main branch + $exists = git cat-file -e "main-branch:$configFile" 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host " ✓ Copying $configFile from main branch" + git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8 -NoNewline + } else { + Write-Host " ℹ️ $configFile not found in main branch, skipping" + } + } + + # Handle glob patterns for .globalconfig and .ruleset files + $globPatterns = @("*.globalconfig", "*.ruleset") + foreach ($pattern in $globPatterns) { + $files = git ls-tree -r --name-only main-branch | Select-String -Pattern $pattern.Replace("*", ".*") + foreach ($file in $files) { + if ($file) { + Write-Host " ✓ Copying $file from main branch" + $dir = Split-Path -Parent $file + if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8 -NoNewline + } + } + } + + Write-Host "" + Write-Host "✅ Configuration files secured - using versions from main branch" + + - name: Fetch trusted configuration files from main branch + shell: pwsh + run: | + Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + $configFiles = @( + ".editorconfig", + "Directory.Build.props", + "Directory.Build.targets", + "BannedSymbols.txt" + ) + + # Copy each configuration file from main branch if it exists + foreach ($configFile in $configFiles) { + # Check if file exists in main branch + $exists = git cat-file -e "main-branch:$configFile" 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host " ✓ Copying $configFile from main branch" + git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8 -NoNewline + } else { + Write-Host " ℹ️ $configFile not found in main branch, skipping" + } + } + + # Handle glob patterns for .globalconfig and .ruleset files + $globPatterns = @("*.globalconfig", "*.ruleset") + foreach ($pattern in $globPatterns) { + $files = git ls-tree -r --name-only main-branch | Select-String -Pattern $pattern.Replace("*", ".*") + foreach ($file in $files) { + if ($file) { + Write-Host " ✓ Copying $file from main branch" + $dir = Split-Path -Parent $file + if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8 -NoNewline + } + } + } + + Write-Host "" + Write-Host "✅ Configuration files secured - using versions from main branch" - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: | + 3.1.x 5.0.x 6.0.x 7.0.x @@ -263,14 +678,23 @@ jobs: - name: Build solution run: dotnet build --no-restore --configuration Release - - name: Run tests (.NET Core 5.0 - 10.0) + - name: Run all .NET tests (.NET 5.0-10.0 and Framework 4.6.2-4.8.1) shell: pwsh run: | - $testProjects = Get-ChildItem -Path './tests' -Recurse -Filter '*.csproj' - - if ($testProjects.Count -eq 0) { - Write-Error "❌ No test projects found in ./tests directory!" - exit 1 + $ErrorActionPreference = 'Stop' + + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + if (-not (Test-Path -Path './tests' -PathType Container)) { + Write-Host "ℹ️ No ./tests directory — skipping test stage." + exit 0 + } + + $testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj') + + if (@($testProjects).Count -eq 0) { + Write-Host "ℹ️ No test projects found under ./tests — skipping test stage." + exit 0 } Write-Host "==========================================" -ForegroundColor Cyan @@ -279,21 +703,55 @@ jobs: $testProjects | ForEach-Object { Write-Host $_.FullName -ForegroundColor White } Write-Host "" - $frameworks = @('net5.0', 'net6.0', 'net7.0', 'net8.0', 'net9.0', 'net10.0') - foreach ($testProj in $testProjects) { Write-Host "==========================================" -ForegroundColor Cyan Write-Host "Testing project: $($testProj.FullName)" -ForegroundColor Cyan Write-Host "==========================================" -ForegroundColor Cyan + # Extract target frameworks from the project file + # Support both (single) and (multiple) + $content = Get-Content $testProj.FullName -Raw + $tfmMatch = [regex]::Match($content, '([^<]+)') + + if (-not $tfmMatch.Success) { + Write-Host "⊘ Skipping: No target frameworks found" -ForegroundColor Yellow + Write-Host "" + continue + } + + # Split by semicolon for multi-targeting projects + $frameworks = $tfmMatch.Groups[1].Value -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0|462|47|471|472|48|481|coreapp3\.1)$' } + + if ($frameworks.Count -eq 0) { + Write-Host "⊘ Skipping: No compatible .NET 5.0-10.0 or Framework 4.6.2-4.8.1 target frameworks found" -ForegroundColor Yellow + Write-Host "" + continue + } + + Write-Host "Target frameworks: $($frameworks -join ', ')" -ForegroundColor White + Write-Host "" + + # Test each framework; collect coverage only for .NET 5.0+ TFMs. + # netcoreapp3.1 and net4x are tested but excluded from coverage: + # netcoreapp3.1 has no matching test TFM on Linux (Stage 1) so its numbers + # would not be comparable; net4x cannot use the XPlat collector on Windows. foreach ($fw in $frameworks) { - Write-Host "Testing framework: $fw" -ForegroundColor Yellow - - dotnet test $testProj.FullName ` - --configuration Release ` - --framework $fw ` - --logger "console;verbosity=normal" - + Write-Host "Testing framework: $fw" -ForegroundColor Yellow + + if ($fw -match '^net([5-9]|[1-9][0-9]+)\.') { + dotnet test $testProj.FullName ` + --configuration Release ` + --framework $fw ` + --collect:"XPlat Code Coverage" ` + --results-directory "./TestResults" ` + --logger "console;verbosity=normal" + } else { + dotnet test $testProj.FullName ` + --configuration Release ` + --framework $fw ` + --logger "console;verbosity=normal" + } + if ($LASTEXITCODE -ne 0) { Write-Error "Tests failed for $fw in $($testProj.Name)" exit 1 @@ -302,18 +760,191 @@ jobs: Write-Host "" } - test-macos-core: - name: "Stage 2b: macOS Tests (.NET 6.0-10.0)" + - name: Check for coverage files + id: check-coverage + run: | + if (Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue) { + echo "has-coverage=true" >> $env:GITHUB_OUTPUT + Write-Host "✅ Coverage files found" + } else { + echo "has-coverage=false" >> $env:GITHUB_OUTPUT + Write-Host "ℹ️ No coverage files found - skipping coverage report generation" + } + shell: pwsh + + - name: Install ReportGenerator + if: steps.check-coverage.outputs.has-coverage == 'true' + run: dotnet tool install -g dotnet-reportgenerator-globaltool + + - name: Generate coverage report + if: steps.check-coverage.outputs.has-coverage == 'true' + shell: pwsh + run: | + reportgenerator ` + -reports:"TestResults/**/coverage.cobertura.xml" ` + -targetdir:"CoverageReport" ` + -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" + + - name: Enforce 90% coverage threshold + if: steps.check-coverage.outputs.has-coverage == 'true' + shell: pwsh + run: | + if (-not (Test-Path "CoverageReport/Summary.txt")) { + Write-Error "❌ Coverage report not generated!" + exit 1 + } + + Write-Host "Coverage Summary:" + Get-Content "CoverageReport/Summary.txt" + Write-Host "" + + $threshold = if ($env:CODECOV_MINIMUM) { [int]$env:CODECOV_MINIMUM } else { 90 } + $failedProjects = @() + + foreach ($line in (Get-Content "CoverageReport/Summary.txt")) { + if ($line -match '^\s*(\S+)\s+(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { + $module = $Matches[1] + $percent = [int][math]::Floor([double]$Matches[2]) + + Write-Host "Checking module: '$module' - Coverage: ${percent}%" + + if ($percent -lt $threshold) { + Write-Host " ❌ FAIL: Below ${threshold}% threshold" -ForegroundColor Red + $failedProjects += "$module (${percent}%)" + } else { + Write-Host " ✅ PASS: Meets ${threshold}% threshold" -ForegroundColor Green + } + } + } + + if ($failedProjects.Count -gt 0) { + Write-Host "" + Write-Host "==========================================" -ForegroundColor Red + Write-Host "❌ COVERAGE GATE FAILED" -ForegroundColor Red + Write-Host "==========================================" -ForegroundColor Red + Write-Host "Projects below ${threshold}% coverage: $($failedProjects -join ', ')" -ForegroundColor Red + Write-Host "" + Write-Host "Stage 2 failed. macOS tests will NOT run." + exit 1 + } + + Write-Host "" + Write-Host "==========================================" -ForegroundColor Green + Write-Host "✅ COVERAGE GATE PASSED" -ForegroundColor Green + Write-Host "==========================================" -ForegroundColor Green + Write-Host "All projects meet ${threshold}% coverage threshold." + Write-Host "Proceeding to Stage 3 (macOS tests)." + + - name: Upload Windows coverage results + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-windows + path: | + TestResults/ + CoverageReport/ + + # ============================================================================ + # STAGE 3: macOS Tests (Gated by Stage 2) + # ============================================================================ + test-macos-core: + name: "Stage 3: macOS Tests (.NET 6.0-10.0)" runs-on: macos-latest - needs: test-linux-core - if: github.repository != 'Chris-Wolfgang/repo-template' + needs: [detect-projects, test-windows] + if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' steps: - - name: Checkout code + - name: Checkout code uses: actions/checkout@v4 with: ref: refs/pull/${{ github.event.pull_request.number }}/head persist-credentials: false + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -325,38 +956,93 @@ jobs: 9.0.x 10.0.x - - name: Restore and build (exclude .NET Framework 4.x projects) + - name: Restore and build (exclude .NET Framework-only projects) run: | - echo "Finding all projects in solution..." + echo "Enumerating tracked .NET project files (git ls-files)..." + + # Filter out projects that ONLY target .NET Framework 4.x + # Multi-targeting projects (e.g., net8.0;net48) will be INCLUDED + projects=() + project_found=false + + while IFS= read -r -d '' proj; do + project_found=true + # Check if project has any .NET 6+ target framework (macOS ARM64 compatible) + # Look for: net6.0, net7.0, net8.0, net9.0, net10.0 + # Normalize newlines to spaces so multi-line elements are matched correctly + if tr $'\n' ' ' < "$proj" | grep -qE '[^<]*net(6\.0|7\.0|8\.0|9\.0|10\.0)'; then + projects+=("$proj") + echo "✓ Including: $proj (has .NET 6+ target)" + else + echo "⊘ Excluding: $proj (no .NET 6+ target, incompatible with macOS ARM64)" + fi + done < <(git ls-files -z -- '*.csproj' '*.vbproj' '*.fsproj') - # Find all .csproj, .vbproj, and .fsproj files - # Exclude those with 'dotnet4' or 'DotNet4' in the path (case-insensitive) - projects=$(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) | grep -iv "dotnet4") + if [ "$project_found" = false ]; then + echo "❌ No .NET projects found." + echo "This should not occur as detect-projects already verified project existence." + exit 1 + fi - if [ -z "$projects" ]; then - echo "No projects found!" + if [ ${#projects[@]} -eq 0 ]; then + echo "❌ No compatible .NET projects found." + echo "All projects lack .NET 6+ targets, which are required for macOS ARM64." exit 1 fi + echo "" echo "==========================================" - echo "Projects to build (excluding .NET Framework 4.x):" + echo "Projects to build (excluding .NET Framework-only projects):" echo "==========================================" - echo "$projects" + printf '%s\n' "${projects[@]}" echo "" # Restore each project echo "Restoring projects..." - for proj in $projects; do + for proj in "${projects[@]}"; do echo "Restoring: $proj" dotnet restore "$proj" || exit 1 done echo "" echo "Building projects..." - # Build each project - for proj in $projects; do + # Build each project, handling multi-targeting projects + # For multi-targeting projects, build only macOS ARM64-compatible frameworks (net6.0-10.0) + for proj in "${projects[@]}"; do echo "Building: $proj" - dotnet build "$proj" --no-restore --configuration Release || exit 1 + + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility. + # Falls back from (multiple) to (single). + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) + + if [ -z "$frameworks" ]; then + echo "⚠️ No macOS ARM64-compatible frameworks found in $proj" + continue + fi + + # Check if this is a multi-targeting project + framework_count=$(echo "$frameworks" | wc -l) + + if [ "$framework_count" -eq 1 ]; then + # Single target framework - build normally + echo " Target framework: $frameworks" + dotnet build "$proj" --no-restore --configuration Release || exit 1 + else + # Multi-targeting project - build each compatible framework separately + echo " Target frameworks (multi-targeting): $(echo "$frameworks" | tr '\n' ' ')" + while IFS= read -r fw; do + [ -z "$fw" ] && continue + echo " Building framework: $fw" + dotnet build "$proj" --no-restore --configuration Release --framework "$fw" || exit 1 + done <<< "$frameworks" + fi done echo "" @@ -364,39 +1050,138 @@ jobs: - name: Run tests (.NET 6.0 - 10.0 only - ARM64 compatible) run: | - # Find all test projects - test_projects=$(find ./tests -type f -name "*.csproj") - - if [ -z "$test_projects" ]; then - echo "❌ No test projects found in ./tests directory!" - exit 1 + # Find all test projects (C#, VB.NET, F#). + # Gracefully skip if there is no ./tests directory (e.g. template-publishing + # repos or library repos in early development that have no tests yet). + if [ ! -d ./tests ]; then + echo "ℹ️ No ./tests directory — skipping test stage." + exit 0 + fi + + test_projects=() + while IFS= read -r -d '' file; do + test_projects+=("$file") + done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) + + if [ ${#test_projects[@]} -eq 0 ]; then + echo "ℹ️ No test projects found under ./tests — skipping test stage." + exit 0 fi echo "==========================================" echo "Found test projects:" echo "==========================================" - echo "$test_projects" + printf '%s\n' "${test_projects[@]}" echo "" - # Skip .NET Core 5.0 and .NET 5.0 - no ARM64 support on macOS - frameworks=(net6.0 net7.0 net8.0 net9.0 net10.0) - - for test_proj in $test_projects; do + for test_proj in "${test_projects[@]}"; do echo "==========================================" echo "Testing project: $test_proj" echo "==========================================" - for fw in "${frameworks[@]}"; do + # Extract target frameworks via MSBuild property evaluation (handles multi-line XML + # and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility. + # Falls back from (multiple) to (single). + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]') + if [ -z "$tfm_raw" ]; then + tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \ + | grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]') + fi + frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true) + + if [ -z "$frameworks" ]; then + echo "⊘ Skipping: No compatible .NET 6.0-10.0 target frameworks found (ARM64 required)" + echo "" + continue + fi + + echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')" + echo "" + + # Test each framework that the project actually targets + # All frameworks here are net6.0+ so all get coverage + while IFS= read -r fw; do + [ -z "$fw" ] && continue echo "Testing framework: $fw" - + dotnet test "$test_proj" \ --configuration Release \ --framework "$fw" \ + --collect:"XPlat Code Coverage" \ + --results-directory "./TestResults" \ --logger "console;verbosity=normal" || exit 1 - done + done <<< "$frameworks" echo "" done + - name: Install ReportGenerator + run: dotnet tool install -g dotnet-reportgenerator-globaltool + + - name: Generate coverage report + run: | + if find ./TestResults -name "coverage.cobertura.xml" -print -quit 2>/dev/null | grep -q .; then + reportgenerator \ + -reports:"TestResults/**/coverage.cobertura.xml" \ + -targetdir:"CoverageReport" \ + -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" + else + echo "ℹ️ No coverage files found - skipping report generation" + fi + + - name: Enforce 90% coverage threshold + run: | + if [ ! -f "CoverageReport/Summary.txt" ]; then + echo "❌ Coverage report not generated!" + exit 1 + fi + + echo "Coverage Summary:" + cat CoverageReport/Summary.txt + echo "" + + THRESHOLD=${CODECOV_MINIMUM:-90} + FAILED=0 + + while IFS= read -r line; do + if echo "$line" | grep -qE '^[^ ]+.*[0-9]+%$' && ! echo "$line" | grep -q '^Summary'; then + MODULE=$(echo "$line" | awk '{print $1}') + PERCENT=$(echo "$line" | grep -oE '[0-9]+(\.[0-9]+)?%' | tail -1 | grep -oE '^[0-9]+') + echo "Checking module: '$MODULE' - Coverage: ${PERCENT}%" + if [ "$PERCENT" -lt "$THRESHOLD" ]; then + echo " ❌ FAIL: Below ${THRESHOLD}% threshold" + FAILED=1 + else + echo " ✅ PASS: Meets ${THRESHOLD}% threshold" + fi + fi + done < CoverageReport/Summary.txt + + if [ "$FAILED" -ne 0 ]; then + echo "" + echo "==========================================" + echo "❌ COVERAGE GATE FAILED" + echo "==========================================" + echo "One or more modules are below ${THRESHOLD}% coverage." + echo "Stage 3 failed." + exit 1 + fi + + echo "" + echo "==========================================" + echo "✅ COVERAGE GATE PASSED" + echo "==========================================" + echo "All modules meet ${THRESHOLD}% coverage threshold." + + - name: Upload macOS coverage results + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-macos + path: | + TestResults/ + CoverageReport/ + - name: Display macOS architecture info if: always() run: | @@ -417,86 +1202,21 @@ jobs: echo " - .NET 10.0 ✅" echo "" echo ".NET Core 5.0 are tested on Linux and Windows" + echo "" - # ============================================================================ - # STAGE 3: Windows - .NET Framework 4.x Tests (Gated by Stage 2) - # ============================================================================ - test-windows-framework: - name: "Stage 3: Windows .NET Framework Tests (4.6.2-4.8.1)" - runs-on: windows-latest - needs: [test-windows-core, test-macos-core] - if: github.repository != 'Chris-Wolfgang/repo-template' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: refs/pull/${{ github.event.pull_request.number }}/head - persist-credentials: false - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 8.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build solution - run: dotnet build --no-restore --configuration Release - - - name: Run .NET Framework tests (4.6.2 - 4.8.1) - shell: pwsh + - name: Summarize pipeline result run: | - $testProjects = Get-ChildItem -Path './tests' -Recurse -Filter '*.csproj' - - if ($testProjects.Count -eq 0) { - Write-Error "❌ No test projects found in ./tests directory!" - exit 1 - } - - Write-Host "==========================================" -ForegroundColor Cyan - Write-Host "Found test projects:" -ForegroundColor Cyan - Write-Host "==========================================" -ForegroundColor Cyan - $testProjects | ForEach-Object { Write-Host $_.FullName -ForegroundColor White } - Write-Host "" - - $frameworks = @('net462', 'net472', 'net48', 'net481') - - foreach ($testProj in $testProjects) { - Write-Host "==========================================" -ForegroundColor Cyan - Write-Host "Testing project: $($testProj.FullName)" -ForegroundColor Cyan - Write-Host "==========================================" -ForegroundColor Cyan - - foreach ($fw in $frameworks) { - Write-Host "Testing framework: $fw" -ForegroundColor Yellow - - dotnet test $testProj.FullName ` - --configuration Release ` - --framework $fw ` - --logger "console;verbosity=normal" - - if ($LASTEXITCODE -ne 0) { - Write-Error "Tests failed for $fw in $($testProj.Name)" - exit 1 - } - } - Write-Host "" - } - - Write-Host "" - Write-Host "==========================================" -ForegroundColor Green - Write-Host "✅ ALL STAGES PASSED" -ForegroundColor Green - Write-Host "==========================================" -ForegroundColor Green - Write-Host "Stage 1: Linux tests + 90% coverage ✅" -ForegroundColor Green - Write-Host "Stage 2: Windows & macOS tests ✅" -ForegroundColor Green - Write-Host "Stage 3: .NET Framework 4.x tests ✅" -ForegroundColor Green - Write-Host "" - Write-Host "PR is ready to merge! 🎉" -ForegroundColor Green + echo "==========================================" + echo "✅ ALL STAGES PASSED" + echo "==========================================" + echo "Stage 1: Linux tests + 90% coverage ✅" + echo "Stage 2: Windows .NET Core & .NET Framework tests ✅" + echo "Stage 3: macOS tests ✅" + echo "" + echo "PR is ready to merge! 🎉" # ============================================================================ - # Security Scan (Runs in parallel with tests) + # Security Scan (Runs in parallel, independently of .NET jobs) # ============================================================================ security-scan: name: "Security Scan (DevSkim)" @@ -509,18 +1229,102 @@ jobs: with: ref: refs/pull/${{ github.event.pull_request.number }}/head persist-credentials: false + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" + + - name: Fetch trusted configuration files from main branch + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + # Fetch the main branch + git fetch origin main:main-branch + + # List of configuration files that should come from trusted main branch + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ) + + # Copy each configuration file from main branch if it exists + for config_file in "${config_files[@]}"; do + # Handle glob patterns + if [[ "$config_file" == *"*"* ]]; then + # Find files matching the pattern in main branch + git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + fi + done + else + # Check if file exists in main branch + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + git show "main-branch:$config_file" > "$config_file" + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" - name: Install DevSkim CLI run: dotnet tool install --global Microsoft.CST.DevSkim.CLI - name: Run DevSkim security scan - continue-on-error: true run: | devskim analyze \ --source-code . \ --file-format text \ --output-file devskim-results.txt \ - -E \ --ignore-rule-ids DS176209 \ --ignore-globs "**/api/**,**/CoverageReport/**,**/TestResults/**" @@ -535,7 +1339,8 @@ jobs: echo "" if grep -qi "error\|critical\|high" devskim-results.txt; then - echo "⚠️ Security issues detected - review required" + echo "❌ Security issues detected - review required" + exit 1 else echo "✅ No critical security issues found" fi diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 83c772d..569b7ed 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,20 +1,27 @@ -name: Release on Version Tag +name: Release on Published Release + on: - push: - tags: - - 'v*.*.*' + release: + types: [published] permissions: - contents: read + contents: read # Default to read-only; individual jobs declare write where required + +env: + CODECOV_MINIMUM: 90 jobs: - build-and-test: - name: Build and Test + # Streamlined validation: All frameworks, Windows only + validate-release: + name: Validate Release Build runs-on: windows-latest + if: github.repository != 'Chris-Wolfgang/repo-template' steps: - name: Checkout code uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -33,32 +40,261 @@ jobs: - name: Build Solution (Release) run: dotnet build --no-restore --configuration Release - - name: Run tests for all test projects + - name: Run multi-framework tests with coverage shell: pwsh run: | - Get-ChildItem -Path './tests' -Recurse -Filter '*Test*.csproj' | ForEach-Object { - Write-Host "Running tests for $($_.FullName)" - dotnet test $_.FullName --no-build --configuration Release - if ($LASTEXITCODE -ne 0) { - Write-Error "Tests failed for $($_.FullName)" - exit $LASTEXITCODE + $testProjects = Get-ChildItem -Path './tests' -Recurse -Filter '*Test*.csproj' + + if ($testProjects.Count -eq 0) { + Write-Error "❌ No test projects found - release requires tests to validate quality" + exit 1 + } + + foreach ($testProj in $testProjects) { + Write-Host "==========================================" -ForegroundColor Cyan + Write-Host "Testing project: $($testProj.Name)" -ForegroundColor Cyan + Write-Host "==========================================" -ForegroundColor Cyan + + # Parse the project file to extract target frameworks + try { + [xml]$projectXml = Get-Content $testProj.FullName + } catch { + Write-Error "❌ Failed to parse project file $($testProj.Name): $_" + exit 1 } + + # Search all PropertyGroup elements for TargetFramework(s) + $targetFramework = $null + $targetFrameworks = $null + foreach ($propGroup in $projectXml.Project.PropertyGroup) { + if ($propGroup.TargetFrameworks) { + $targetFrameworks = $propGroup.TargetFrameworks + break + } elseif ($propGroup.TargetFramework) { + $targetFramework = $propGroup.TargetFramework + break + } + } + + # Determine which frameworks this project targets + $frameworks = @() + if ($targetFrameworks) { + # Multiple frameworks (semicolon-separated) + $frameworks = $targetFrameworks -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + if ($frameworks.Count -eq 0) { + Write-Error "❌ TargetFrameworks property in $($testProj.Name) is empty or malformed" + exit 1 + } + } elseif ($targetFramework) { + # Single framework + $frameworks = @($targetFramework.Trim()) + if (-not $frameworks[0]) { + Write-Error "❌ TargetFramework property in $($testProj.Name) is empty" + exit 1 + } + } else { + # If no TargetFramework/TargetFrameworks are defined directly in the project file, + # attempt to resolve them via MSBuild (to account for Directory.Build.props, imports, etc.). + Write-Host "No TargetFramework or TargetFrameworks found directly in $($testProj.Name); querying MSBuild..." -ForegroundColor Yellow + + $msbuildOutput = @() + $msbuildExitCode = 0 + foreach ($prop in @("TargetFrameworks", "TargetFramework")) { + $result = dotnet msbuild $testProj.FullName /nologo "-getProperty:$prop" 2>&1 + if ($LASTEXITCODE -ne 0) { + $msbuildExitCode = $LASTEXITCODE + } + if ($result) { + $msbuildOutput += $result + } + } + + if ($msbuildExitCode -ne 0) { + # MSBuild query failed, fall back to running tests without explicit --framework + Write-Warning "MSBuild query failed for $($testProj.Name). Tests will run without explicit --framework." + $frameworks = @('') + } else { + # MSBuild succeeded, parse the output + $resolvedFrameworks = @() + foreach ($line in $msbuildOutput) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + + # Expect lines like "TargetFrameworks=net7.0;net8.0" or "TargetFramework=net8.0" + # Support both '=' and ':' separators for different MSBuild output formats + if ($line -match '^\s*TargetFrameworks\s*[:=]\s*(.+)$') { + $propertyValue = $Matches[1].Trim() + $resolvedFrameworks = $propertyValue -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + break + } elseif ($line -match '^\s*TargetFramework\s*[:=]\s*(.+)$') { + $propertyValue = $Matches[1].Trim() + if ($propertyValue) { + $resolvedFrameworks = @($propertyValue) + } + + if ($resolvedFrameworks.Count -gt 0) { + break + } + } + } + + if ($resolvedFrameworks.Count -gt 0) { + $frameworks = $resolvedFrameworks + } else { + Write-Warning "MSBuild query returned no target frameworks for $($testProj.Name). Tests will run without explicit --framework." + $frameworks = @('') + } + } + } + + Write-Host "Detected frameworks: $($frameworks -join ', ')" -ForegroundColor Cyan + + foreach ($fw in $frameworks) { + if ([string]::IsNullOrWhiteSpace($fw)) { + Write-Host "Testing project $($testProj.Name) without explicit --framework (using SDK/MSBuild defaults)" -ForegroundColor Yellow + + # When framework cannot be determined, run tests once without specifying --framework. + # Collect coverage in this case to avoid missing data due to unknown TFM. + dotnet test $testProj.FullName ` + --configuration Release ` + --no-build ` + --no-restore ` + --collect:"XPlat Code Coverage" ` + --results-directory "./TestResults" ` + --logger "console;verbosity=minimal" + + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ Tests failed (no explicit TargetFramework) in $($testProj.Name)" + exit $LASTEXITCODE + } + + continue + } + + Write-Host "Testing framework: $fw" -ForegroundColor Yellow + + # Collect coverage only for .NET 5.0+ TFMs; still run tests for all frameworks + if ($fw -match '^net([5-9]|[1-9][0-9]+)\.') { + dotnet test $testProj.FullName ` + --configuration Release ` + --framework $fw ` + --no-build ` + --no-restore ` + --collect:"XPlat Code Coverage" ` + --results-directory "./TestResults" ` + --logger "console;verbosity=minimal" + } else { + # For older frameworks (e.g., netstandard, net4x, net3x, etc.), run tests without coverage + dotnet test $testProj.FullName ` + --configuration Release ` + --framework $fw ` + --no-build ` + --no-restore ` + --results-directory "./TestResults" ` + --logger "console;verbosity=minimal" + } + + if ($LASTEXITCODE -ne 0) { + if ($fw) { + Write-Error "❌ Tests failed for $fw in $($testProj.Name)" + } else { + Write-Error "❌ Tests failed in $($testProj.Name)" + } + exit $LASTEXITCODE + } + } + Write-Host "" } + Write-Host "✅ All framework tests passed" -ForegroundColor Green - - name: Upload test results + - name: Verify coverage threshold + shell: pwsh + run: | + # Check if coverage files exist + $coverageFiles = Get-ChildItem -Path "TestResults" -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue + + if ($coverageFiles.Count -eq 0) { + Write-Error "❌ No coverage files found - coverage data is required to enforce the 90% threshold" + exit 1 + } + + dotnet tool install -g dotnet-reportgenerator-globaltool + + reportgenerator ` + -reports:"TestResults/**/coverage.cobertura.xml" ` + -targetdir:"CoverageReport" ` + -reporttypes:"TextSummary;Html" + + Write-Host "==========================================" -ForegroundColor Cyan + Write-Host "Coverage Summary:" -ForegroundColor Cyan + Write-Host "==========================================" -ForegroundColor Cyan + Get-Content CoverageReport/Summary.txt + Write-Host "" + + # Parse coverage and enforce threshold per module (matching pr.yaml) + $summaryContent = Get-Content CoverageReport/Summary.txt + $threshold = if ($env:CODECOV_MINIMUM) { [int]$env:CODECOV_MINIMUM } else { 90 } + $failedModules = @() + $coverageFound = $false + + foreach ($line in $summaryContent) { + # Match lines with module names and percentages (skip Summary line) + if ($line -match '^([^ ]+)\s+.*\s+(\d+(?:\.\d+)?)%$' -and $line -notmatch '^Summary') { + $coverageFound = $true + $module = $matches[1] + $coverage = [decimal]$matches[2] + + Write-Host "Checking module: '$module' - Coverage: ${coverage}%" -ForegroundColor Cyan + + if ($coverage -lt $threshold) { + Write-Host " ❌ FAIL: Below ${threshold}% threshold" -ForegroundColor Red + $failedModules += "$module (${coverage}%)" + } else { + Write-Host " ✅ PASS: Meets ${threshold}% threshold" -ForegroundColor Green + } + } + } + + # Ensure we found and parsed coverage data + if (-not $coverageFound) { + Write-Error "❌ Failed to parse coverage data from Summary.txt - cannot enforce threshold" + exit 1 + } + + if ($failedModules.Count -gt 0) { + Write-Host "" + Write-Host "==========================================" -ForegroundColor Red + Write-Host "❌ COVERAGE GATE FAILED" -ForegroundColor Red + Write-Host "==========================================" -ForegroundColor Red + Write-Host "Modules below ${threshold}% coverage: $($failedModules -join ', ')" -ForegroundColor Red + exit 1 + } + + Write-Host "" + Write-Host "==========================================" -ForegroundColor Green + Write-Host "✅ All modules meet ${threshold}% coverage threshold" -ForegroundColor Green + Write-Host "==========================================" -ForegroundColor Green + + - name: Upload coverage report if: always() uses: actions/upload-artifact@v4 with: - name: test-results - path: '**/TestResults*.trx' + name: release-coverage + path: CoverageReport/ - publish: - name: Pack and Publish NuGet - needs: build-and-test + # Pack and validate NuGet package + pack-and-validate: + name: Pack & Validate NuGet + needs: validate-release runs-on: windows-latest + outputs: + has-packages: ${{ steps.check-packages.outputs.has-packages }} steps: - name: Checkout code uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -71,26 +307,167 @@ jobs: 9.0.x 10.0.x - - name: Restore dependencies - run: dotnet restore - - - name: Build Solution (Release) - run: dotnet build --no-restore --configuration Release + - name: Restore and build + run: | + dotnet restore + dotnet build --no-restore --configuration Release - - name: Pack NuGet Packages + - name: Pack NuGet packages + id: check-packages shell: pwsh run: | + # Create output directory for NuGet packages $packagesPath = Join-Path $PWD 'nuget-packages' New-Item -ItemType Directory -Force -Path $packagesPath | Out-Null - Get-ChildItem -Path 'src' -Recurse -Filter '*.csproj' | ForEach-Object { - Write-Host "Packing $($_.FullName)" - dotnet pack $_.FullName --no-build --configuration Release --output $packagesPath + # Find all .csproj files in the src directory recursively + $projects = Get-ChildItem -Path 'src' -Recurse -Filter '*.csproj' + + # Handle case when no projects are found (e.g., template repository) + if ($projects.Count -eq 0) { + Write-Warning "No projects found in src/ directory - skipping package creation" + Write-Warning "Downstream publish and release jobs will be skipped" + # Create empty directory for artifact upload + New-Item -ItemType File -Path (Join-Path $packagesPath '.placeholder') -Force | Out-Null + # Set output to indicate no packages were created + "has-packages=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + exit 0 + } + + # Iterate through each project and create NuGet package + foreach ($proj in $projects) { + Write-Host "📦 Packing $($proj.Name)" -ForegroundColor Cyan + dotnet pack $proj.FullName --no-build --configuration Release --output $packagesPath + + # Check if pack operation failed and exit with error if ($LASTEXITCODE -ne 0) { - Write-Error "dotnet pack failed for $($_.FullName)" + Write-Error "❌ Pack failed for $($proj.Name)" exit $LASTEXITCODE } } + + # Check whether any .nupkg files were actually created + $packages = Get-ChildItem -Path $packagesPath -Filter '*.nupkg' -ErrorAction SilentlyContinue + + if ($packages.Count -eq 0) { + Write-Warning "No .nupkg files were produced during packing - downstream publish and release jobs will be skipped" + "has-packages=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + exit 0 + } + + # At least one package was created successfully + Write-Host "✅ NuGet packages created successfully" -ForegroundColor Green + "has-packages=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Smoke test NuGet package installation + shell: pwsh + run: | + $packages = Get-ChildItem -Path 'nuget-packages' -Filter '*.nupkg' -ErrorAction SilentlyContinue + + if ($packages.Count -eq 0) { + Write-Warning "No .nupkg files found - skipping smoke test" + exit 0 + } + + # Helper to read package ID and version from the .nuspec inside a .nupkg + Add-Type -AssemblyName System.IO.Compression.FileSystem + function Get-PackageMetadata { + param ( + [Parameter(Mandatory = $true)] + [string] $NupkgPath + ) + + $zip = [System.IO.Compression.ZipFile]::OpenRead($NupkgPath) + try { + $nuspecEntry = $zip.Entries | Where-Object { $_.FullName -like '*.nuspec' } | Select-Object -First 1 + if (-not $nuspecEntry) { + throw "No .nuspec file found in package '$NupkgPath'." + } + + $stream = $nuspecEntry.Open() + try { + $reader = New-Object System.IO.StreamReader($stream) + $nuspecXml = [xml]$reader.ReadToEnd() + $id = $nuspecXml.package.metadata.id + $version = $nuspecXml.package.metadata.version + + if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($version)) { + throw "Failed to read id/version from nuspec in '$NupkgPath'." + } + + [PSCustomObject]@{ + Id = $id + Version = $version + } + } + finally { + $stream.Dispose() + } + } + finally { + $zip.Dispose() + } + } + + # Create temporary test project + $testDir = Join-Path $PWD 'package-smoke-test' + New-Item -ItemType Directory -Force -Path $testDir | Out-Null + + # Restrict NuGet restores in this directory to the local package source only + $nugetConfigPath = Join-Path $testDir 'NuGet.config' + # Build NuGet.config content as array to avoid YAML parsing issues with here-strings + $nugetConfigContent = @( + '' + '' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '' + ) + $nugetConfigContent | Set-Content -Path $nugetConfigPath -Encoding UTF8 + + Push-Location $testDir + try { + dotnet new console -n SmokeTest -f net8.0 + + # Try to install the newly created package(s) + foreach ($package in $packages) { + Write-Host "🧪 Smoke testing package: $($package.Name)" -ForegroundColor Yellow + + $metadata = Get-PackageMetadata -NupkgPath $package.FullName + $packageId = $metadata.Id + $packageVersion = $metadata.Version + + dotnet add SmokeTest/SmokeTest.csproj package $packageId --version $packageVersion --source '../nuget-packages' + + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ Failed to install package $($package.Name)" + exit $LASTEXITCODE + } + + Write-Host "✅ Package $($package.Name) installed successfully" -ForegroundColor Green + } + + # Try to build the test project with the package + Write-Host "Building smoke test project..." -ForegroundColor Yellow + dotnet build SmokeTest/SmokeTest.csproj + + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ Smoke test project failed to build with installed packages" + exit $LASTEXITCODE + } + + Write-Host "✅ Smoke test passed - packages are installable and buildable" -ForegroundColor Green + + } finally { + Pop-Location + } - name: Generate SBOM (CycloneDX) if: steps.check-packages.outputs.has-packages == 'true' @@ -121,26 +498,125 @@ jobs: } - - name: Upload NuGet packages as artifacts + - name: Upload NuGet packages uses: actions/upload-artifact@v4 - with: + with: name: nuget-packages - path: ./nuget-packages/*.nupkg - ./nuget-packages/*.bom.json - retention-days: 30 + path: ./nuget-packages/ + retention-days: 90 + if-no-files-found: warn - - name: Publish NuGet Package + # Publish to NuGet (only if validation passed) + publish-nuget: + name: Publish to NuGet.org + needs: pack-and-validate + if: needs.pack-and-validate.outputs.has-packages == 'true' + runs-on: windows-latest + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 5.0.x + 6.0.x + 7.0.x + 8.0.x + 9.0.x + 10.0.x + + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: ./packages + + - name: Validate NuGet API key shell: pwsh env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | - $packagesPath = Join-Path $PWD 'nuget-packages' - Get-ChildItem -Path $packagesPath -Filter '*.nupkg' | ForEach-Object { - ./nuget-packages/*.bom.json - Write-Host "Publishing $($_.FullName)" - dotnet nuget push $_.FullName --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + if ([string]::IsNullOrEmpty($env:NUGET_API_KEY)) { + Write-Error "❌ NUGET_API_KEY secret not configured!" + Write-Host "Please add it in: Repository Settings → Secrets and variables → Actions → New repository secret" + exit 1 + } + Write-Host "✅ NUGET_API_KEY is configured" -ForegroundColor Green + + - name: Publish to NuGet + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + $packages = Get-ChildItem -Path './packages' -Filter '*.nupkg' -ErrorAction SilentlyContinue + + if ($packages.Count -eq 0) { + Write-Warning "No .nupkg files found - nothing to publish" + exit 0 + } + + foreach ($package in $packages) { + Write-Host "📤 Publishing $($package.Name) to NuGet.org" -ForegroundColor Cyan + + dotnet nuget push $package.FullName ` + --api-key $env:NUGET_API_KEY ` + --source https://api.nuget.org/v3/index.json ` + --skip-duplicate + + # Exit code 0 = success, 409 would be duplicate (handled by --skip-duplicate flag) if ($LASTEXITCODE -ne 0) { - Write-Error "dotnet nuget push failed for $($_.FullName)" + Write-Error "❌ Failed to publish $($package.Name)" exit $LASTEXITCODE } + + Write-Host "✅ Successfully published $($package.Name)" -ForegroundColor Green } + + Write-Host "" + Write-Host "==========================================" -ForegroundColor Green + Write-Host "✅ All packages published to NuGet.org" -ForegroundColor Green + Write-Host "==========================================" -ForegroundColor Green + + # Build and deploy versioned documentation via the shared docfx workflow + # Note: reusable workflow jobs called via `uses:` do not require `runs-on` in the caller + trigger-docs: + name: Build & Deploy Documentation + needs: validate-release + permissions: + contents: write # Required by docfx.yaml to push to gh-pages branch + uses: ./.github/workflows/docfx.yaml + with: + version: ${{ github.event.release.tag_name }} + + # Attach NuGet packages and coverage report to the GitHub Release page + update-release-artifacts: + name: Attach Artifacts to Release + needs: [validate-release, pack-and-validate, publish-nuget] + if: needs.pack-and-validate.outputs.has-packages == 'true' + runs-on: ubuntu-latest + permissions: + contents: write # Required to upload assets to the GitHub Release + steps: + - name: Download NuGet packages artifact + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: ./nuget-packages + + - name: Download coverage report artifact + uses: actions/download-artifact@v4 + with: + name: release-coverage + path: ./release-coverage + + - name: Zip coverage report + run: zip -r release-coverage.zip ./release-coverage + + - name: Attach artifacts to release + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + with: + tag_name: ${{ github.event.release.tag_name }} + files: | + ./nuget-packages/*.nupkg + ./nuget-packages/*.bom.json + release-coverage.zip + diff --git a/BannedSymbols.txt b/BannedSymbols.txt index b13d388..ace175e 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -79,3 +79,4 @@ M:System.Console.ReadLine(); Blocking - avoid in async code paths M:System.Console.Read(); Blocking - avoid in async code paths M:System.Console.ReadKey(); Blocking - avoid in async code paths M:System.Console.ReadKey(System.Boolean); Blocking - avoid in async code paths + diff --git a/LICENSE b/LICENSE index d0a1fa1..194219f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,373 +1,21 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at https://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. +MIT License + +Copyright (c) 2026 Chris Wolfgang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/REPO-INSTRUCTIONS.md b/REPO-INSTRUCTIONS.md new file mode 100644 index 0000000..4132777 --- /dev/null +++ b/REPO-INSTRUCTIONS.md @@ -0,0 +1,266 @@ +# Setting Up Your Repository + +## Automated Setup (Recommended) + +**NEW:** This template now includes automated setup scripts that handle configuration for you! + +### Quick Setup + +```powershell +pwsh ./scripts/setup.ps1 +``` + +**Note:** There are multiple scripts in this template: +- `scripts/setup.ps1` - Main repository setup (replaces placeholders, configures license) +- `scripts/Setup-BranchRuleset.ps1` - Branch protection configuration (run after setup) +- `scripts/Setup-GitHubPages.ps1` - GitHub Pages and DocFX documentation setup (optional) + +The main setup script will: +1. ✅ Prompt for all required information (with examples and defaults) +2. ✅ Auto-detect git repository information where possible +3. ✅ Replace placeholders in core template files (see TEMPLATE-PLACEHOLDERS.md for details and any manual steps, including DocFX docs) +4. ✅ Delete the template README.md +5. ✅ Rename README-TEMPLATE.md to README.md +6. ✅ Set up your chosen LICENSE (MIT, Apache 2.0, or MPL 2.0) +7. ✅ Remove unused license templates +8. ✅ **Optionally create a default .slnx solution file** with proper folder structure (requires Visual Studio 2022 17.10+) +9. ✅ Validate all replacements +10. ✅ Optionally clean up template-specific files + +**For detailed placeholder documentation, see [TEMPLATE-PLACEHOLDERS.md](docs/TEMPLATE-PLACEHOLDERS.md)** +**For license selection guidance, see [LICENSE-SELECTION.md](docs/LICENSE-SELECTION.md)** + +--- + +## Manual Setup Instructions + +After you create your repo from the template, you will still need to configure some settings. +Below is a list of what needs to be done. Once you have completed the checklist below you can delete this file + +## Creating Your Repository + +1. On the `Repositories` page click `New` +1. On the `Create a new repository` page enter + 1. `Repository name` + 2. `Description` + 3. Select `Public` or `Private` +1. `Start with a template` select `Chris-Wolfgang/repo-template` +1. `Include all branches` set `On` - this will include the `develop` branch. If you don't want the `develop` branch or if there are other branches you don't want you can leave this `off` and create the `develop` branch in your new repository + + +## Add Branch Protection Rules + +> **Note:** Branch protection is now configured using a local PowerShell script. After setting up your repository, run the script to configure branch protection: +> ```powershell +> pwsh ./scripts/Setup-BranchRuleset.ps1 +> ``` +> The script includes interactive prompts that allow you to choose between **single developer** or **multi-developer** repository settings during execution. Simply run the script and select option [1] for single-developer mode (no approvals required) or option [2] for multi-developer mode (requires 1+ approval and code owner review). + +If you need to manually configure branch protection instead: + +1. Go to your repository’s Settings → Branches. +2. Under “Branch protection rules,” click `Add branch ruleset` +3. `Ruleset Name` enter `main` +4. `Target branches` click `Add target` +5. Select `Include by pattern` +6. `Branch naming pattern` enter `main` +7. Click `Add Inclusion pattern` + + +## Security Settings + +Prevent Merging When Checks Fail +These settings require that all checks in the pr.yaml file succeed before you can merge a branch into main + +> **Note for Single-Developer Repositories:** This template is configured for single-developer use. The branch protection script (`scripts/Setup-BranchRuleset.ps1`) includes interactive prompts that allow you to choose between single-developer or multi-developer settings during execution. Simply run the script and select option [1] for single-developer mode (no PR approvals required) or option [2] for multi-developer mode (requires 1+ approval and code owner review). +**Note:** The pr.yaml workflow uses `pull_request_target` to always run from the trusted main branch, even for PRs from feature branches. This prevents malicious workflow modifications in untrusted PR branches while still testing the PR's code. + +> **Branch protection is now configured via local script!** Run `pwsh ./scripts/Setup-BranchRuleset.ps1` to automatically configure all required settings. Manual configuration below is only needed if you prefer not to use the automated script. + +1. Go to your repository’s Settings → Branches. +2. Under “Branch protection rules,” edit the rule for main. +3. Check “Require status checks to pass before merging.” +4. In the "Status checks that are required" list, select the status check contexts produced by your PR workflow jobs. These options appear after the workflow has run at least once on `main`. For example: + - "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" + - "Stage 2a: Windows Tests (.NET 5.0-10.0)" + - "Stage 2b: Windows .NET Framework Tests (4.6.2-4.8.1)" + - "Stage 3: macOS Tests (.NET 6.0-10.0)" + - "Security Scan (DevSkim)" + +5. Enable “Require branches to be up to date before merging.” +6. Check `Restrict deletions` +7. Check `Require a pull request before merging` + 1. Check `Dismiss stale pull request approvals when new commits are pushed` + 3. **For multi-developer repos:** Check `Require review from Code Owners` and set required approvals to 1 or more +8. Check `Block force pushes` +9. Check `Require code scanning` + + +## Add Custom Labels + +Run the label setup script once after creating your repository: + +```powershell +pwsh -File ./scripts/Setup-Labels.ps1 +``` + +This creates the following labels used by Dependabot and workflows: + +1. `dependabot - security` +2. `dependabot-dependencies` +3. `dependencies` +4. `dotnet` + +Requires the [GitHub CLI](https://cli.github.com/) to be installed and authenticated (`gh auth login`). + + +## Creating the project + +### Automated Solution Creation (Recommended) + +If you used the automated setup script (`pwsh ./scripts/setup.ps1`), you had the option to create a default solution file automatically. The script creates a `.slnx` format solution (requires Visual Studio 2022 version 17.10+) with the following structure: +- Empty solution folders for `/benchmarks/`, `/examples/`, `/src/`, and `/tests/` +- A `/.root/` folder containing all repository configuration files (preserves directory structure) + +If you chose to create a solution during setup, skip to step 2 below. + +### Manual Solution Creation + +If you didn't create a solution during setup or prefer the traditional `.sln` format: + +1. Create a blank solution and save it in the root folder + ```bash + dotnet new sln -n YourSolutionName + ``` +2. Add new projects to the solution. Each application project will be in its own folder in the /src folder +3. Add one or more test projects each in its own folder in the /tests folder +4. If the solution will have benchmark project add each project in its own folder under /benchmarks + +``` +root +├── MySolution.sln +├── src +│ ├── MyApp +│ │ └── MyApp.csproj +│ └── MyLib +│ └── MyLib.csproj +├── tests +│ ├── MyApp.Tests +│ │ └── MyApp.Tests.csproj +│ └── MyLib.Tests +│ └── MyLib.Tests.csproj +└── benchmarks + └── MyApp.Benchmarks + └── MyApp.Benchmarks.csproj +``` + + +## Configure Release Workflow (Optional) + +If you plan to publish NuGet packages using the automated release workflow, you need to configure the following: + +### Add NuGet API Key Secret + +1. Go to your repository's Settings → Secrets and variables → Actions +2. Click **"New repository secret"** +3. **Name:** `NUGET_API_KEY` +4. **Value:** Your NuGet.org API key + - Get your key from [NuGet.org Account → API Keys](https://www.nuget.org/account/apikeys) + - Recommended scopes: **Push new packages and package versions** + - Set expiration date (recommended: 1 year) +5. Click **"Add secret"** + +**Note:** The release workflow automatically publishes packages to NuGet.org when you publish a GitHub Release (typically associated with a version tag like `v1.0.0`). See [RELEASE-WORKFLOW-SETUP.md](docs/RELEASE-WORKFLOW-SETUP.md) for detailed information about the release workflow, testing, and troubleshooting. + + +## Update Template Files + +After creating your repository from the template, update the following files with your project-specific information: + +### Update README.md + +1. Open `README.md` in the root folder +2. Replace the template content with your project's description +3. Add installation instructions, usage examples, and other relevant information + +### Update CONTRIBUTING.md + +1. Open `CONTRIBUTING.md` +2. Ensure any project name placeholders (for example, `Wolfgang.Extensions.Logging.InMemoryLogger`) have been replaced with your actual project name (the automated setup scripts should normally do this for you) +3. Review and adjust contribution guidelines as needed for your project + +### Update CODEOWNERS + +1. Open `.github/CODEOWNERS` +2. Replace `@Chris-Wolfgang` with your GitHub username or team names +3. Uncomment and customize the example rules if you want different owners for specific directories + +**Note:** The CODEOWNERS file determines who is automatically requested for review when someone opens a pull request. + +### Setup GitHub Pages for Documentation (Optional) + +If you want to publish your DocFX documentation to GitHub Pages automatically when you publish a GitHub Release: + +1. Run the GitHub Pages setup script: + ```powershell + pwsh ./scripts/Setup-GitHubPages.ps1 + ``` + + The script will: + - **Prompt if you want to set up GitHub Pages** for documentation + - **Auto-detect repository information** (name, description, URLs) + - **Prompt for project details** needed for DocFX configuration + - **Replace placeholders** in DocFX files (Wolfgang.Extensions.Logging.InMemoryLogger, https://Chris-Wolfgang.github.io/In-memory-Logger/, etc.) + - Create a `gh-pages` branch if it doesn't exist + - Configure GitHub Pages to serve from the `gh-pages` branch + - Verify that the DocFX workflow is reachable via `workflow_call` from `release.yaml` + + **Note:** If you've already run `scripts/setup.ps1`, the DocFX placeholders are already configured, and this script will skip the configuration step. + +2. After setup, documentation will be automatically published when you publish a GitHub Release: + 1. Go to your repository's **Releases** page + 2. Click **"Draft a new release"** + 3. Choose or create a version tag (e.g., `v1.0.0`) + 4. Click **"Publish release"** + +3. The documentation will be available at: `https://[username].github.io/[repo-name]/` + +**Note:** The DocFX workflow (`.github/workflows/docfx.yaml`) is configured to trigger via: +- **`workflow_call`**: Called automatically by `release.yaml` after a GitHub Release is published (passes the release tag as the version) +- **`workflow_dispatch`**: Manual trigger for ad-hoc builds or dry-runs (available from the Actions tab) + +**Alternative Approach:** If you prefer to configure DocFX placeholders separately from GitHub Pages setup, you can run `scripts/setup.ps1` first (which handles all template placeholders including DocFX), then run `scripts/Setup-GitHubPages.ps1` just to set up the gh-pages branch and GitHub Pages settings. + +### Update Documentation (Optional) + +If you're using DocFX for documentation: +1. Review and customize the generated table of contents in `docfx_project/docs/toc.yml` as needed (the setup scripts already point this to your repository) +2. Customize the rest of the documentation content in `docfx_project/` +### Multi-Version DocFX Documentation + +This repository is configured for versioned documentation using DocFX. The setup consists of: + +#### Key Files +| File | Purpose | +|------|---------| +| `docfx_project/docfx.json` | Per-build DocFX configuration included in this template and used by CI workflows to build docs. Uses `default` + `modern` templates with dark mode enabled (`colorMode: dark`). | +| `docfx_project/logo.svg` | Default repository logo used by DocFX. You can optionally copy this to the repo root as `logo.svg` if you want a root-level logo as well. | + +#### How Versioning Works +- CI workflows discover documentation versions **dynamically at runtime** by querying git tags that match the SemVer pattern `v*.*.*` (e.g. `v1.0.0`, `v0.3.0`). No manual version list is maintained in any config file. +- The `.github/workflows/build-all-versions.yaml` workflow enumerates all matching tags and builds documentation for each — no file updates are required when a new release is published. +- Each release triggers `.github/workflows/release.yaml` (on a published GitHub Release), which calls `.github/workflows/docfx.yaml` via `workflow_call` to build docs and deploy them to the `gh-pages` branch under `versions//`. You can also run `docfx.yaml` directly via `workflow_dispatch` from the Actions tab for ad-hoc builds. +- After every versioned deploy, a `versions.json` is generated and written to `gh-pages`, powering the version-switcher dropdown. +- `versions/latest/` always mirrors the most recent stable release; the site root (`/`) hosts the version-picker landing page that links to the latest and all other available documentation versions. + +#### Adding a New Version +When you publish a new release (e.g. `v1.0.0`): +1. Create and push a version tag (e.g. `v1.0.0`) to the repository. +2. Publish a GitHub Release for that tag — this triggers `release.yaml`, which calls `docfx.yaml` via `workflow_call` to automatically build and publish the docs. You can also run `docfx.yaml` directly via `workflow_dispatch` for ad-hoc or dry-run builds. +3. To backfill all historical versions at once, run the **Build All Versioned Docs** workflow manually from the Actions tab. + +#### Dark Theme +The DocFX modern template is configured to default to dark mode. This is controlled by: +- `"colorMode": "dark"` in `docfx_project/docfx.json` → `build.globalMetadata` +- `"_enableDarkMode": true` enables the light/dark toggle so visitors can switch themes + diff --git a/SETUP.md b/SETUP.md deleted file mode 100644 index 5dea6d3..0000000 --- a/SETUP.md +++ /dev/null @@ -1,84 +0,0 @@ -# Setting Up Your Repository -After you create your repo from the template you will still need to configure some settings. -Below is a list of what needs to be done. Once you have completed the checklist below you can delete this file - -## Creating Your Repository - -1. On the `Repositories` page click `New` -1. On the `Create a new repository` page enter - 1. `Repository name` - 2. `Description` - 3. Select `Public` or `Private` -1. `Start with a template` select `Chris-Wolfgang/repo-template` -1. `Include all branches` set `On` - this will include the `develop` branch. If you don't want the `develop` branch or if there are other branches you don't want you can leave this `off` and create the `develop` branch in your new repository - - -## Add Branch Protection Rules - -1. Go to your repository’s Settings → Branches. -2. Under “Branch protection rules,” click `Add branch ruleset` -3. `Ruleset Name` enter `main` -4. `Target branches` click `Add target` -5. Select `Include by pattern` -6. `Branch naming pattern` enter `main` -7. Click `Add Inclusion pattern` - - -## Security Settings - -Prevent Merging When Checks Fail -These settings require that all checks in the pr.yaml file succeed before you can merge a branch into main - -1. Go to your repository’s Settings → Branches. -2. Under “Branch protection rules,” edit the rule for main. -3. Check “Require status checks to pass before merging.” -4. Select your PR workflow (it will be listed after it runs at least once). -5. Enable “Require branches to be up to date before merging.” -6. Check `Restrict deletions` -7. Check `Require a pull request before merging` - 1. Check `Dismiss stale pull request approvals when new commits are pushed` - 2. Check `Require review from Code Owners` - 3. Check `Require pull request review from Copilot` -8. Check `Block force pushes` -9. Check `Require code scanning` - - -## Add Custom Labels - -1. On the `Actions` tab select `Create Custom Labels` -2. Select `Run workflow` -3. Select `main` branch and click `Run` -You will need to create the following custom labels - -If that doesn't work try the following - -Go to `Issues` tab at the top of your repo and the select `Labels` and click `New label` - -1. dependabot-dependencies -2. dependabot-security - - -## Creating the project - -1. Create a blank solution and save it in the root folder -2. Add new projects to the solution. Each application project will be in its own folder in the /src folder -3. Add one or more test projects each in its own folder in the /tests folder -4. If the solution will have benchmark project add each project in its own folder under /benchmarks - -``` -root -├── MySolution.sln -├── src -│ ├── MyApp -│ │ └── MyApp.csproj -│ └── MyLib -│ └── MyLib.csproj -├── tests -│ ├── MyApp.Tests -│ │ └── MyApp.Tests.csproj -│ └── MyLib.Tests -│ └── MyLib.Tests.csproj -└── benchmarks - └── MyApp.Benchmarks - └── MyApp.Benchmarks.csproj -``` diff --git a/docfx_project/_site/manifest.json b/docfx_project/_site/manifest.json deleted file mode 100644 index bf7358d..0000000 --- a/docfx_project/_site/manifest.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "source_base_path": "C:/Source/GitHub/DbContextBuilder/docfx_project", - "files": [] -} \ No newline at end of file diff --git a/docfx_project/api/README.md b/docfx_project/api/README.md new file mode 100644 index 0000000..6a63e1a --- /dev/null +++ b/docfx_project/api/README.md @@ -0,0 +1,22 @@ +# API Documentation Directory + +This directory is auto-generated by DocFX during the build process. + +## How It Works + +When you run `docfx docfx_project/docfx.json` from the repository root, DocFX will: +1. Scan the C# projects specified in `docfx.json` (configured to look for `src/**/*.csproj`) +2. Extract XML documentation comments from your code +3. Generate API reference documentation in this directory +4. Create a `toc.yml` file that organizes the API documentation + +## Important Notes + +- **Do not manually edit generated DocFX output files in this folder** (such as `*.yml` and `toc.yml`) — they will be overwritten each time you run the DocFX build +- Hand-authored files like `index.md` and this `README.md` are intentionally maintained by hand and will be preserved across DocFX runs +- The actual API reference metadata files (`*.yml` files) will be generated automatically + +## Template Placeholders + +The `index.md` file uses the following template placeholder: +- `{{PROJECT_NAME}}` - Will be replaced with your project name diff --git a/docfx_project/api/index.md b/docfx_project/api/index.md new file mode 100644 index 0000000..be9b2bb --- /dev/null +++ b/docfx_project/api/index.md @@ -0,0 +1,17 @@ +# API Reference + +Welcome to the Wolfgang.Extensions.Logging.InMemoryLogger API documentation. + +This section contains the complete API reference, automatically generated from XML documentation comments in the source code. + +Browse the navigation menu to explore available namespaces and types. + +## Conventions + +- **Public APIs** are stable and follow semantic versioning +- **Internal APIs** may change between minor versions +- **Obsolete APIs** are marked with deprecation warnings + +## Getting Started + +For usage examples and guides, see the [Documentation](../docs/getting-started.md) section. diff --git a/docfx_project/docfx.json b/docfx_project/docfx.json index 0bcc566..4cd30de 100644 --- a/docfx_project/docfx.json +++ b/docfx_project/docfx.json @@ -5,7 +5,7 @@ "src": [ { "files": [ - "src//.csproj" + "src/**/*.csproj" ], "src": "../" } @@ -37,8 +37,8 @@ "default" ], "globalMetadata": { - "_appName": "", - "_appTitle": "", + "_appName": "Wolfgang.Extensions.Logging.InMemoryLogger", + "_appTitle": "Wolfgang.Extensions.Logging.InMemoryLogger Documentation", "_enableSearch": true, "pdf": true } diff --git a/docfx_project/docs/index.md b/docfx_project/docs/index.md new file mode 100644 index 0000000..aa987fe --- /dev/null +++ b/docfx_project/docs/index.md @@ -0,0 +1,9 @@ +# Wolfgang.Extensions.Logging.InMemoryLogger Documentation + +Welcome to the documentation section. Browse the topics in the navigation menu to get started. + +## Available Documentation + +- [Introduction](introduction.md) - Overview and introduction +- [Getting Started](getting-started.md) - Quick start guide + diff --git a/docfx_project/docs/toc.yml b/docfx_project/docs/toc.yml index 669b07d..35b4a8e 100644 --- a/docfx_project/docs/toc.yml +++ b/docfx_project/docs/toc.yml @@ -5,4 +5,4 @@ - name: Getting Started href: getting-started.md - name: Project website - href: https://github.com/Chris-Wolfgang/DbContextBuilder + href: https://github.com/Chris-Wolfgang/In-memory-Logger diff --git a/docfx_project/index.md b/docfx_project/index.md index 9bc17e9..b001639 100644 --- a/docfx_project/index.md +++ b/docfx_project/index.md @@ -2,7 +2,7 @@ _layout: landing --- -# Title +# Wolfgang.Extensions.Logging.InMemoryLogger ## Quick Start Notes: diff --git a/docs/RELEASE-WORKFLOW-SETUP.md b/docs/RELEASE-WORKFLOW-SETUP.md new file mode 100644 index 0000000..91fe07d --- /dev/null +++ b/docs/RELEASE-WORKFLOW-SETUP.md @@ -0,0 +1,221 @@ +# Release Workflow Setup Guide + +This guide explains how to configure the repository after merging the updated `release.yaml` workflow. + +## Overview + +The release workflow triggers when you **publish a GitHub Release** and implements a comprehensive validation and automatic deployment process that: +- ✅ Tests all target frameworks per test project on Windows +- ✅ Enforces 90% code coverage threshold +- ✅ Validates NuGet package integrity with smoke tests +- ✅ Automatically publishes to NuGet.org after validation passes +- ✅ Eliminates duplicate build work for faster releases + +## Required Post-Merge Configuration + +After merging this PR, complete the following setup step: + +### Add NuGet API Key Secret + +**Location:** Settings → Secrets and variables → Actions → New repository secret + +1. Click **"New repository secret"** +2. **Name:** `NUGET_API_KEY` +3. **Value:** Your NuGet.org API key + - Get your key from [NuGet.org Account → API Keys](https://www.nuget.org/account/apikeys) + - Recommended scopes: **Push new packages and package versions** + - Set expiration date (recommended: 1 year) +4. Click **"Add secret"** + +**What this does:** Allows the workflow to authenticate with NuGet.org and publish packages. The workflow validates this secret exists before attempting to publish. + +### Verify Branch Protection Rules + +**Location:** Settings → Branches → main + +> **Note:** By default, the template is configured for single developer repositories. The branch protection setup script (`scripts/Setup-BranchRuleset.ps1`) includes interactive prompts that allow you to choose between single-developer or multi-developer settings during execution. Simply run the script and select option [1] for single-developer mode (0 approvals) or option [2] for multi-developer mode (1+ approvals and code owner review required). + +Ensure the following settings are enabled: + +- ✅ **Require a pull request before merging** + - **Single developer repos:** 0 approvals (default) + - **Multi-developer repos:** 1+ approvals (recommended) +- ✅ **Require status checks to pass before merging** + - Required checks should include the following status check contexts: + - "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" + - "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" + - "Stage 3: macOS Tests (.NET 6.0-10.0)" + - "Security Scan (DevSkim)" + - "Security Scan (CodeQL)" +- ✅ **Require branches to be up to date before merging** +- ✅ **Require conversation resolution before merging** +- ✅ **Do not allow bypassing the above settings** (recommended, even for admins) +- ✅ **Restrict deletions** +- ✅ **Require linear history** (optional but recommended) + +**What this does:** Ensures all code merged to `main` has passed comprehensive validation, preventing broken releases. + +## Testing the Release Workflow + +After completing the setup, test the workflow by creating a GitHub Release: + +1. Go to your repository's **Releases** page +2. Click **"Draft a new release"** +3. Choose or create a tag (e.g., `v0.0.1-test`) +4. Add a title and description (optional for a test) +5. Check **"Set as a pre-release"** for test releases +6. Click **"Publish release"** + +The workflow triggers automatically when the release is published. + +### Expected Workflow Behavior + +1. **Job 1: validate-release** (3-10 minutes) + - Runs all framework tests with coverage + - Enforces 90% coverage threshold + - Uploads coverage report + - ✅ Auto-passes if tests succeed + +2. **Job 2: pack-and-validate** (2-5 minutes) + - Packs NuGet packages + - Performs smoke test installation + - Uploads packages as artifacts + - ✅ Auto-passes if packages are valid + +3. **Job 3: publish-nuget** (1-2 minutes) + - Validates NUGET_API_KEY secret + - Publishes packages to NuGet.org automatically + - ✅ Auto-completes if secret is valid + +### Monitoring the Workflow + +- **Actions Tab:** Shows workflow progress in real-time +- **Artifacts:** Each job uploads artifacts (coverage reports, packages) +- **Releases:** Check the Releases page after successful completion + +## Troubleshooting + +### "NUGET_API_KEY secret not configured" Error + +**Problem:** The `publish-nuget` job fails with secret validation error. + +**Solution:** +1. Verify the secret name is exactly `NUGET_API_KEY` (case-sensitive) +2. Re-add the secret in Settings → Secrets → Actions +3. Re-run the workflow from the Actions tab (do not re-publish the release) + +### Tests Fail on Specific Framework + +**Problem:** Tests pass on some frameworks but fail on others (e.g., net462). + +**Solution:** +1. Check the test logs for framework-specific issues +2. Fix compatibility issues in your code +3. Test locally: `dotnet test --framework net462` +4. Push fix, then re-publish the release (or re-run the workflow from the Actions tab) + +### Coverage Below 90% Threshold + +**Problem:** Workflow fails at coverage validation step. + +**Solution:** +1. Review `CoverageReport/Summary.txt` artifact +2. Add tests for uncovered code paths +3. Ensure tests run on all frameworks +4. Push fix, then re-publish the release (or re-run the workflow from the Actions tab) + +### Smoke Test Fails to Install Package + +**Problem:** Package packs successfully but fails smoke test installation. + +**Solution:** +1. Check package dependencies in `.csproj` +2. Verify framework compatibility in `<TargetFrameworks>` +3. Test locally: `dotnet pack` then try installing in a test project +4. Fix packaging issues and re-publish the release (or re-run the workflow from the Actions tab) + +## Production Release Checklist + +Before creating a production GitHub Release (e.g., `v1.0.0`): + +- [ ] All tests pass on all platforms (pr.yaml workflow) +- [ ] Code coverage meets 90% threshold +- [ ] Security scan shows no critical issues +- [ ] Version numbers updated in `.csproj` files +- [ ] `CHANGELOG.md` updated with release notes (if applicable) +- [ ] All PRs merged to `main` branch +- [ ] Local build succeeds: `dotnet build --configuration Release` +- [ ] Local tests pass: `dotnet test --configuration Release` + +**Create a production release:** +1. Go to your repository's **Releases** page +2. Click **"Draft a new release"** +3. Choose or create the version tag (e.g., `v1.0.0`) targeting `main` +4. Add a title and release notes +5. Click **"Publish release"** + +**After workflow completes:** +- [ ] Verify packages appear on NuGet.org +- [ ] Test installing package from NuGet.org in a clean project +- [ ] Announce release (if applicable) + +## Workflow Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Trigger: Published GitHub Release │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Job 1: validate-release (Windows) │ +│ • Restore & Build │ +│ • Test all frameworks (net5.0-10.0, net462-481) │ +│ • Collect coverage │ +│ • Enforce 90% threshold │ +│ • Upload coverage artifacts │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ (only if tests pass) +┌─────────────────────────────────────────────────────────────┐ +│ Job 2: pack-and-validate (Windows) │ +│ • Restore & Build (fresh) │ +│ • Pack NuGet packages │ +│ • Smoke test installation │ +│ • Upload package artifacts │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ (only if packing succeeds) +┌─────────────────────────────────────────────────────────────┐ +│ Job 3: publish-nuget (Windows) │ +│ • Download packages │ +│ • Validate NUGET_API_KEY │ +│ • Publish to NuGet.org automatically │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Key Improvements Over Previous Workflow + +| Issue | Before | After | +|-------|--------|-------| +| **Framework Coverage** | Default framework only | All frameworks (net5.0-10.0, net462-481) | +| **Code Coverage** | Not enforced | 90% threshold enforced | +| **Package Validation** | None | Smoke test installation | +| **Deployment** | Incomplete publish script | Automatic publishing after validation | +| **Secret Validation** | None | Validates before publishing | +| **GitHub Releases** | Not used as trigger | Workflow triggered by published release | +| **Build Efficiency** | Duplicate builds in each job | Build once per job with dependencies | +| **Test Logging** | No logger parameter | Console logging with verbosity | +| **Permissions** | Read-only | Write access for releases | + +## Support + +If you encounter issues not covered in this guide: + +1. Check the [Actions tab](../../actions) for detailed logs +2. Review artifacts uploaded by failed jobs +3. Consult the [GitHub Actions documentation](https://docs.github.com/en/actions) +4. Open an issue in this repository with: + - Workflow run URL + - Error message and logs + - Steps to reproduce diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..e670a48 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability, please follow these steps: + +1. **Do not** create an issue on this repository. **Do not** disclose the vulnerability publicly. +1. In the top navigation of this repository, click the **Security** tab. +1. In the top right, click the **Report a vulnerability** button. +1. Fill out the provided form. It will request information like: + - A description of the vulnerability + - Steps to reproduce the issue + - Potential impact on student data or website functionality + - Suggested fix (if you have one) + +## Response Timeline +TBD/ASAP + +## Thank You + +Your help is greatly appreciated! +Responsible disclosure of security vulnerabilities helps protect our entire community diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index b21f38e..0000000 --- a/docs/index.html +++ /dev/null @@ -1,4 +0,0 @@ -<h1>Welcome to {repository name}</h1> - - -To Do: Add description diff --git a/scripts/Fix-BranchRuleset.ps1 b/scripts/Fix-BranchRuleset.ps1 index 363ea0b..f69c673 100644 --- a/scripts/Fix-BranchRuleset.ps1 +++ b/scripts/Fix-BranchRuleset.ps1 @@ -12,7 +12,7 @@ .PARAMETER Repository The repository in owner/repo format. If not provided, uses the current repository. -.PARAMETER Confirm +.PARAMETER Force Skip the confirmation prompt and proceed automatically. Alias: -y .EXAMPLE @@ -20,7 +20,7 @@ Inspects and fixes rulesets for the current repository with interactive confirmation .EXAMPLE - .\Fix-BranchRuleset.ps1 -y + .\Fix-BranchRuleset.ps1 -Force Inspects and fixes rulesets without prompting for confirmation .EXAMPLE @@ -35,11 +35,11 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "Chris-Wolfgang/In-memory-Logger", + [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", [Parameter()] [Alias("y")] - [switch]$Confirm + [switch]$Force ) # Check if gh CLI is installed @@ -65,14 +65,14 @@ try { } # Determine repository -if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { +if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { Write-Host "Detecting current repository..." -ForegroundColor Cyan try { $repoInfo = gh repo view --json nameWithOwner | ConvertFrom-Json $Repository = $repoInfo.nameWithOwner Write-Host "Using repository: $Repository" -ForegroundColor Green } catch { - if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { + if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { Write-Error "Could not detect repository. Please run the setup script first to replace placeholders, or specify -Repository parameter." } else { Write-Error "Could not detect repository. Please run from within a git repository or specify -Repository parameter." @@ -169,8 +169,8 @@ foreach ($item in $plan) { Write-Host "" # Prompt for confirmation -if ($Confirm) { - Write-Host "Auto-confirmed via -Confirm flag." -ForegroundColor Green +if ($Force) { + Write-Host "Auto-confirmed via -Force flag." -ForegroundColor Green } else { $response = Read-Host "Proceed with these changes? (y/N)" if ($response -ne 'y' -and $response -ne 'Y') { diff --git a/scripts/Setup-BranchRuleset.ps1 b/scripts/Setup-BranchRuleset.ps1 index fbbcb5a..c9dc7ae 100644 --- a/scripts/Setup-BranchRuleset.ps1 +++ b/scripts/Setup-BranchRuleset.ps1 @@ -16,10 +16,6 @@ The ruleset includes: - Pull request reviews with configurable approval requirements - Required status checks (tests, security scans) - - CodeQL code scanning enforcement (High+ severity) - - Automatic Copilot code review for pull requests - - Copilot review of new pushes and draft PRs - - CodeQL standard queries integration with Copilot reviews - Force push and deletion protection .PARAMETER Repository @@ -46,9 +42,8 @@ These permissions are necessary to create and modify repository rulesets. - Note: The copilot_code_review ruleset type requires GitHub Copilot access - and may require GitHub Enterprise or specific subscription plans. Verify your organization has the - necessary subscriptions before running this script. + Note: Copilot code review is not supported through the rulesets API and must be + enabled manually in the GitHub repository UI after running this script. #> [CmdletBinding()] @@ -193,51 +188,20 @@ $rulesetConfig = @{ # must NOT have path filters (paths/paths-ignore). If a workflow is path-filtered # and doesn't run for a PR, GitHub will treat the required check as missing and # block the merge. All required status checks must run on every PR. - # This also applies to the CodeQL workflow (codeql.yml) which provides the code_scanning - # rule below - see that section for details on how CodeQL handles graceful skipping. required_status_checks = @( @{ context = "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" }, - @{ context = "Stage 2a: Windows Tests (.NET 5.0-10.0)" }, - @{ context = "Stage 2b: macOS Tests (.NET 6.0-10.0)" }, + @{ context = "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" }, + @{ context = "Stage 3: macOS Tests (.NET 6.0-10.0)" }, @{ context = "Security Scan (DevSkim)" } ) } }, - @{ - type = "code_scanning" - parameters = @{ - # NOTE: CodeQL uses the 'code_scanning' ruleset type instead of 'required_status_checks' - # because it has built-in intelligence to handle cases where scans don't run - # The workflow (.github/workflows/codeql.yml) has no path filters to ensure - # GitHub can properly evaluate this rule. The workflow runs on all PRs and gracefully - # skips analysis when there's no C# code, preventing false merge blocks while still - # enforcing security scanning when needed. - code_scanning_tools = @( - @{ - tool = "CodeQL" - security_alerts_threshold = "high_or_higher" - alerts_threshold = "errors" - } - ) - } - }, - @{ - type = "copilot_code_review" - # Not yet supported through API, must be set via UI - # <-- parameters = @{ - # Automatically request Copilot code review for new pull requests - # if the author has Copilot access and hasn't reached their review request limit - # <-- auto_request_copilot_review = $true - # Review new pushes to the pull request automatically - # <-- review_new_pushes = $true - # Review draft pull requests before they are marked as ready - # <-- review_draft_pull_requests = $true - # Static analysis tools to include in Copilot code review - # <-- static_analysis_tools = @("CodeQL") - # Query suite for CodeQL - # <-- codeql_query_suite = "standard" - # } - }, + # NOTE: code_scanning (CodeQL) is not included in this API-created ruleset because + # it requires a CodeQL workflow to be present and have run on the repo. Without prior + # analyses, the rule blocks all PRs. Add CodeQL integration separately if needed. + # NOTE: Copilot code review is not included in this API-created payload because + # it is not currently supported through the rulesets API. After the ruleset is + # created, enable Copilot code review settings manually in the GitHub repository UI. @{ type = "non_fast_forward" }, @@ -277,19 +241,14 @@ try { } Write-Host " ✅ Required status checks (must pass before merging):" -ForegroundColor Gray Write-Host " - Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate" -ForegroundColor DarkGray - Write-Host " - Stage 2a: Windows Tests (.NET 5.0-10.0)" -ForegroundColor DarkGray - Write-Host " - Stage 2b: macOS Tests (.NET 6.0-10.0)" -ForegroundColor DarkGray - Write-Host " - Stage 3: Windows .NET Framework Tests (4.6.2-4.8.1)" -ForegroundColor DarkGray + Write-Host " - Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)" -ForegroundColor DarkGray + Write-Host " - Stage 3: macOS Tests (.NET 6.0-10.0)" -ForegroundColor DarkGray Write-Host " - Security Scan (DevSkim)" -ForegroundColor DarkGray Write-Host " ✅ Branches must be up to date before merging" -ForegroundColor Gray Write-Host " ✅ Conversation resolution required before merging" -ForegroundColor Gray Write-Host " ✅ Stale reviews dismissed when new commits are pushed" -ForegroundColor Gray - Write-Host " ✅ CodeQL code scanning enforcement (blocks on High+ severity findings)" -ForegroundColor Gray - Write-Host " ✅ Automatic Copilot code review enabled:" -ForegroundColor Gray - Write-Host " - Auto-request for new pull requests" -ForegroundColor DarkGray - Write-Host " - Review new pushes automatically" -ForegroundColor DarkGray - Write-Host " - Review draft pull requests" -ForegroundColor DarkGray - Write-Host " - Static analysis tools: CodeQL (standard queries)" -ForegroundColor DarkGray + Write-Host " ⚠️ Copilot code review: enable manually in repository settings" -ForegroundColor Yellow + Write-Host " (Not yet supported through the rulesets API)" -ForegroundColor DarkGray Write-Host " ✅ Force pushes blocked on $BranchName branch" -ForegroundColor Gray Write-Host " ✅ Branch deletion prevented for $BranchName" -ForegroundColor Gray Write-Host " ✅ No bypass allowed - all users must follow these rules" -ForegroundColor Gray diff --git a/scripts/Setup-GitHubPages.ps1 b/scripts/Setup-GitHubPages.ps1 index 334266d..de6776b 100644 --- a/scripts/Setup-GitHubPages.ps1 +++ b/scripts/Setup-GitHubPages.ps1 @@ -49,7 +49,7 @@ [CmdletBinding()] param( [Parameter()] - [string]$Repository = "{{GITHUB_USERNAME}}/{{REPO_NAME}}", + [string]$Repository, [Parameter()] [switch]$EnablePages, @@ -62,6 +62,19 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Auto-detect repository if not provided +if (-not $Repository) { + try { + $Repository = (gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>$null) + if (-not $Repository) { + throw "Could not auto-detect repository" + } + } catch { + Write-Error "Repository not specified and auto-detection failed. Use -Repository 'owner/repo'." + exit 1 + } +} + # Color output functions function Write-Success { param([string]$Message) @@ -211,7 +224,7 @@ try { } # Determine repository -if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { +if ($Repository -eq "Chris-Wolfgang/In-memory-Logger" -or -not $Repository) { # Placeholders not replaced or no repository specified - auto-detect Write-Info "Detecting current repository..." try { @@ -219,7 +232,7 @@ if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}" -or -not $Repository) { $Repository = $repoInfo.nameWithOwner Write-Success "Using repository: $Repository" } catch { - if ($Repository -eq "{{GITHUB_USERNAME}}/{{REPO_NAME}}") { + if ($Repository -eq "Chris-Wolfgang/In-memory-Logger") { Write-Error-Custom "Could not detect repository. Please run the setup script (scripts/setup.ps1 or scripts/setup.sh) first to replace placeholders, or specify -Repository parameter." } else { Write-Error-Custom "Could not detect repository. Please run from within a git repository or specify -Repository parameter." diff --git a/scripts/build-pr.ps1 b/scripts/build-pr.ps1 index d7fd64c..611d126 100644 --- a/scripts/build-pr.ps1 +++ b/scripts/build-pr.ps1 @@ -84,7 +84,8 @@ if (-not $SkipTests -and $failed.Count -eq 0) { $testProjects = @(Get-ChildItem -Path './tests' -Recurse -File -Include '*.csproj', '*.vbproj', '*.fsproj' -ErrorAction SilentlyContinue) if ($testProjects.Count -eq 0) { - Write-Host "No test projects found in ./tests — skipping" + Write-Fail "No test projects found in ./tests" + $failed += "Tests" } else { foreach ($testProj in $testProjects) { @@ -267,7 +268,10 @@ if (-not $SkipSecurity) { else { $archive = "gitleaks_${version}_linux_x64.tar.gz" $url = "https://github.com/gitleaks/gitleaks/releases/download/v${version}/$archive" - curl -sSfL $url | tar xz -C /usr/local/bin gitleaks + $dest = Join-Path $HOME ".local/bin" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + curl -sSfL $url | tar xz -C $dest gitleaks + $env:PATH = "$dest$([IO.Path]::PathSeparator)$env:PATH" } }